diff --git a/Cargo.lock b/Cargo.lock index 2f0ed2a7353046ddd92cc23e10675b351d8b3461..b80017633661a297d29e118e126a2a0c36832b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1888,6 +1888,7 @@ dependencies = [ "onestore", "parley", "pollster", + "resvg", "serde", "serde_json", "tempfile", diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index 88845507d9930415dab3827ebcffc87e2a38aa6e..d536d95c80df1c3ac722916bb0dc81ff58a1c92e 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { sb_set_coordinator(coordinate) + sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } } #if DEBUG Presenter.watch() #endif @@ -77,11 +78,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro split.setViewController(pages, for: .supplementary) split.setViewController(UIViewController(), for: .secondary) notebooks.onOpen = { [weak self] tab, notebook in self?.open(tab, of: notebook) } - pages.onOpen = { [weak self] section, id, find in self?.show(section: section, page: id, find: find) } - pages.onOpenSection = { [weak self] notebook, path, id, find in - guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return } - self?.open(tab, of: notebook, page: id, find: find) - } + pages.onOpen = { [weak self] section, id in self?.show(section: section, page: id) } window.rootViewController = split window.makeKeyAndVisible() self.window = window @@ -116,11 +113,13 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro } func open( - _ tab: Tab, of notebook: Notebook, page: String? = nil, find: String? = nil, + _ tab: Tab, of notebook: Notebook, page: String? = nil, reveal: Reveal? = nil, then: ((Section) -> Void)? = nil ) { guard Author.name != nil else { - return Author.ask(from: split) { [weak self] in self?.open(tab, of: notebook, page: page, find: find, then: then) } + return Author.ask(from: split) { [weak self] in + self?.open(tab, of: notebook, page: page, reveal: reveal, then: then) + } } pages.loading(tab) split.show(.supplementary) @@ -134,15 +133,37 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro notebooks.select(tab, of: notebook) Place.saved = Place(notebook: notebook.id, section: tab.path, page: page) if let page, section.row(of: page) != nil { - show(section: section, page: page, find: find) + show(section: section, page: page, reveal: reveal) } then?(section) } } - func show(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) { + /// Opens `page` of the section at catalog `path` in `notebook`, as a search result or + /// the Tags Summary leads there. + func open(_ path: String, of notebook: Notebook, page: String, reveal: Reveal) { + if let section = pages.section, section.notebook === notebook, section.tab.path == path { + return show(section: section, page: page, reveal: reveal) + } + guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return } + open(tab, of: notebook, page: page, reveal: reveal) + } + + /// OneNote's Tags Summary for `notebook`, as a sheet over `controller`. + func showTags(of notebook: Notebook, from controller: UIViewController) { + let tags = TagsViewController(notebook: notebook) + tags.onOpen = { [weak self] notebook, path, page, paragraph in + self?.open(path, of: notebook, page: page, reveal: .paragraph(paragraph)) + } + let navigation = UINavigationController(rootViewController: tags) + navigation.sheetPresentationController?.detents = [.medium(), .large()] + navigation.sheetPresentationController?.prefersGrabberVisible = true + controller.present(navigation, animated: true) + } + + func show(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) { showingPage = true - let controller = PageViewController(section: section, page: page, find: find, titleFocus: titleFocus) + let controller = PageViewController(section: section, page: page, reveal: reveal, titleFocus: titleFocus) controller.onOpen = { [weak self] id in self?.show(section: section, page: id) } split.setViewController(UINavigationController(rootViewController: controller), for: .secondary) split.show(.secondary) @@ -204,7 +225,8 @@ final class RootViewController: UISplitViewController { [ UIKeyCommand(title: "New Page", action: #selector(newPage), input: "n", modifierFlags: .command), UIKeyCommand( - title: "New Subpage", action: #selector(newSubpage), input: "n", modifierFlags: [.command, .shift]), + title: "New Subpage", action: #selector(newSubpage), input: "n", + modifierFlags: [.command, .shift, .alternate]), UIKeyCommand(title: "Search", action: #selector(search), input: "f", modifierFlags: [.command, .alternate]), ] } diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index 3ed7f64baebb62122e3d5f047ac13caf88cd4918..a3e3a588c8bad5ad0941d56f8149b0f1c7947d43 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -106,6 +106,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// gestures move; the canvas paints neither. private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self) private let history = CanvasUndoManager() + private let spaceHint = Hint() weak var inputDelegate: UITextInputDelegate? lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self) @@ -134,6 +135,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI minimumZoomScale = 0.25 maximumZoomScale = 4 keyboardDismissMode = .interactive + // A phone on its side keeps the page clear of the sensor housing, as it keeps the top. + contentInsetAdjustmentBehavior = .always addSubview(content) insertSubview(metal, at: 0) // Touches land on the scroll view itself, where the text interaction looks for them. @@ -487,6 +490,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI default: display.isActivated = isFirstResponder edit(external: true) { sb_view_release(handle) } + spaceHint.hide() } } @@ -512,7 +516,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// Tells the bar buttons and the format bar what the selection now has. private func changedSelection() { - if let handle { formatBar.show(sb_view_format(handle)) } + formatBar.show(format) onChange?() } @@ -576,14 +580,20 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } } + /// A page selection copies every outline, as OneNote 2010 copies it. override func copy(_ sender: Any?) { - guard let range = selectedTextRange, let text = text(in: range) else { return } + guard let handle, let text = take(sb_view_copy(handle, false)) else { return } UIPasteboard.general.string = text } override func cut(_ sender: Any?) { - copy(sender) - delete(sender) + guard let handle else { return } + var text: String? + edit(external: true) { + text = take(sb_view_copy(handle, true)) + return text != nil + } + if let text { UIPasteboard.general.string = text } } override func delete(_ sender: Any?) { @@ -614,6 +624,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI override func toggleItalics(_ sender: Any?) { apply(1) } override func toggleUnderline(_ sender: Any?) { apply(2) } + /// Tags and the highlighter join the system's text actions. + func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + guard let handle else { return nil } + let bits = sb_view_format(handle) + let tag = UIMenu( + title: "Tag", image: UIImage(systemName: "tag"), + children: Tags.menu(bits: bits) { [weak self] in self?.apply($0) }) + var extra: [UIMenuElement] = [tag] + if !textRange.isEmpty { + extra.insert( + UIAction(title: "Highlight", image: UIImage(systemName: "highlighter")) { [weak self] _ in + self?.apply(10) + }, at: 0) + } + return UIMenu(children: suggestedActions + [UIMenu(options: .displayInline, children: extra)]) + } + // MARK: Page /// A conflict page, which shows what is stored and takes no edits. @@ -624,6 +651,55 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" } + /// The selection's formatting, from `sb_view_format`. + var format: UInt64 { handle.map(sb_view_format) ?? 0 } + + var pagePaper: Paper? { handle.flatMap { decode(Paper.self, sb_view_paper($0)) } } + + /// Gives the page colour `color` and rule lines `ruled` of `paper.rules`, or none. + func setPaper(color: [UInt8]?, ruled: Int?) { + guard let handle else { return } + edit(external: true) { + sb_view_set_paper( + handle, color.map { Int16($0[0]) } ?? -1, color?[1] ?? 0, color?[2] ?? 0, Int8(ruled ?? -1)) + } + } + + /// Puts template `name`'s art behind the page, or none. + func setArt(_ name: String?) { + guard let handle else { return } + edit(external: true) { sb_view_set_art(handle, name) } + } + + /// Insert Space: the next drag moves what lies below or right of where it starts. + func insertSpace() { + guard let handle else { return } + sb_view_insert_space(handle) + spaceHint.show("Drag down or right to add space", in: self) + } + + /// Types the date, time or both as the system writes them short, and a space, as + /// OneNote's Insert Date and Time do. + func insertDate(_ date: Bool, time: Bool) { + let now = Date() + let text = DateFormatter.localizedString( + from: now, dateStyle: date ? .short : .none, timeStyle: time ? .short : .none) + if !isFirstResponder { _ = becomeFirstResponder() } + insertText(text + " ") + } + + /// Selects paragraph `id` and brings it into view. + func selectParagraph(_ id: String) -> Bool { + guard let handle else { return false } + var found = false + edit(external: true) { + found = sb_view_select_paragraph(handle, id) + return found + } + if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } + return found + } + /// Applies `sb_view_apply` formatting to the selection. func apply(_ command: UInt8) { guard let handle else { return } @@ -682,26 +758,38 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI override var keyCommands: [UIKeyCommand]? { guard isFirstResponder else { return nil } // Ctrl+1 to Ctrl+9 in OneNote, where the Mac's Command stands for Control. - let tags = FormatBar.tags.enumerated().map { index, name in - let command = UIKeyCommand( - title: name, action: #selector(tagged), input: "\(index + 1)", modifierFlags: .command, - propertyList: index) - command.wantsPriorityOverSystemBehavior = true - return command + let tags = Tags.all.prefix(Tags.keyed).enumerated().map { index, tag in + UIKeyCommand( + title: tag.name, action: #selector(formatted), input: "\(index + 1)", modifierFlags: .command, + propertyList: 16 + index) } - let outdent = UIKeyCommand( - title: "Decrease Indent", action: #selector(outdented), input: "\t", modifierFlags: .shift) - outdent.wantsPriorityOverSystemBehavior = true + // The desktop's chords, as OneNote for Mac's. + let formats: [(String, String, UIKeyModifierFlags, Int)] = [ + ("Strikethrough", "x", [.command, .shift], 3), + ("Bullets", ".", .command, 4), + ("Numbering", "/", .command, 5), + ("Increase Indent", "]", .command, 6), + ("Decrease Indent", "[", .command, 7), + ("Remove Tag", "0", [.command, .control], 8), + ("Clear Formatting", "n", [.command, .shift], 9), + ("Highlight", "h", [.command, .control], 10), + ("Decrease Indent", "\t", .shift, 7), + ] + let commands = tags + formats.map { title, input, modifiers, command in + UIKeyCommand( + title: title, action: #selector(formatted), input: input, modifierFlags: modifiers, + propertyList: command) + } + for command in commands { command.wantsPriorityOverSystemBehavior = true } let done = UIKeyCommand(title: "Done", action: #selector(finished), input: UIKeyCommand.inputEscape) - return tags + [outdent, done] + return commands + [done] } - @objc private func tagged(_ command: UIKeyCommand) { - guard let index = command.propertyList as? Int else { return } - apply(16 + UInt8(index)) + @objc private func formatted(_ command: UIKeyCommand) { + guard let command = command.propertyList as? Int else { return } + apply(UInt8(command)) } - @objc private func outdented() { apply(7) } @objc private func finished() { _ = resignFirstResponder() } // MARK: Coordinates @@ -948,3 +1036,47 @@ private final class LineTokenizer: UITextInputStringTokenizer { granularity == .line || super.isPosition(position, withinTextUnit: granularity, inDirection: direction) } } + +/// A short instruction floating at the bottom of the page until the gesture it asks for. +private final class Hint: UIVisualEffectView { + private let label = UILabel() + + init() { + super.init(effect: UIBlurEffect(style: .systemThickMaterial)) + label.font = .preferredFont(forTextStyle: .subheadline) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), + label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), + ]) + layer.cornerRadius = 18 + clipsToBounds = true + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { fatalError() } + + /// Shows `text` over the bottom of `view`'s visible area, and says it. + func show(_ text: String, in view: UIScrollView) { + label.text = text + guard let host = view.superview else { return } + translatesAutoresizingMaskIntoConstraints = false + host.addSubview(self) + NSLayoutConstraint.activate([ + centerXAnchor.constraint(equalTo: host.centerXAnchor), + bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -16), + ]) + alpha = 0 + UIView.animate(withDuration: 0.2) { self.alpha = 1 } + UIAccessibility.post(notification: .announcement, argument: text) + } + + func hide() { + guard superview != nil else { return } + UIView.animate(withDuration: 0.2, animations: { self.alpha = 0 }) { _ in self.removeFromSuperview() } + } +} diff --git a/apps/ios/Snowbound/FormatBar.swift b/apps/ios/Snowbound/FormatBar.swift index 56ff25f1127105b2ed585a93ef0855b1ed301167..926c1da50065f09b965881b60a61b067396414e0 100644 --- a/apps/ios/Snowbound/FormatBar.swift +++ b/apps/ios/Snowbound/FormatBar.swift @@ -1,7 +1,7 @@ import UIKit -/// Formatting above the keyboard, as Notes offers it: tags and lists first, then text -/// styles, indentation, tags and pictures, the keyboard's dismissal at the end. +/// Formatting above the keyboard, as Notes offers it: To Do first, then text styles, lists, +/// indentation, tags and pictures, the keyboard's dismissal at the end. final class FormatBar: UIInputView { /// `sb_view_apply` commands and `sb_view_format` bits. private static let buttons: [(symbol: String, label: String, command: UInt8)] = [ @@ -10,18 +10,14 @@ final class FormatBar: UIInputView { ("italic", "Italic", 1), ("underline", "Underline", 2), ("strikethrough", "Strikethrough", 3), + ("highlighter", "Highlight", 10), ("list.bullet", "Bullets", 4), ("list.number", "Numbering", 5), ("decrease.indent", "Decrease Indent", 7), ("increase.indent", "Increase Indent", 6), ] - /// OneNote 2010's default tags in their order, Ctrl+1 to Ctrl+9. - static let tags = [ - "To Do", "Important", "Question", "Remember for Later", "Definition", "Highlight", "Contact", "Address", - "Phone Number", - ] - private var toggles: [(UIButton, UInt8)] = [] + private var bits: UInt64 = 0 var onApply: ((UInt8) -> Void)? /// Asks for a picture from the camera (true) or the photo library. var onPicture: ((Bool) -> Void)? @@ -44,9 +40,11 @@ final class FormatBar: UIInputView { row.addArrangedSubview(button) } let tag = Self.button("tag", "Tags") - tag.menu = UIMenu(children: Self.tags.enumerated().map { index, name in - UIAction(title: name) { [weak self] _ in self?.onApply?(16 + UInt8(index)) } - }) + tag.menu = UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak self] done in + done(Tags.menu(bits: self?.bits ?? 0) { self?.onApply?($0) }) + } + ]) tag.showsMenuAsPrimaryAction = true row.addArrangedSubview(tag) let picture = Self.button("photo", "Insert Picture") @@ -105,9 +103,10 @@ final class FormatBar: UIInputView { } /// Marks what the selection has, from `sb_view_format`. - func show(_ bits: UInt32) { + func show(_ bits: UInt64) { + self.bits = bits for (button, command) in toggles { - button.isSelected = bits & (1 << UInt32(command)) != 0 + button.isSelected = bits & (1 << UInt64(command)) != 0 } } } diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index c7590ae1b346196bb0f3fc1a2add3bb5f3a9a518..11597dcb29136324d96d479f375634578adf3c5d 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -39,11 +39,14 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele ]) navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add) + navigationItem.searchController = SearchViewController.controller() + toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()] } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.navigationBar.prefersLargeTitles = true + navigationController?.setToolbarHidden(false, animated: false) } func reload() { @@ -170,6 +173,10 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) { [weak self] _ in self?.newSection(in: notebook) }, + UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in + guard let self else { return } + (view.window?.windowScene?.delegate as? SceneDelegate)?.showTags(of: notebook, from: self) + }, UIAction(title: "Close Notebook", image: UIImage(systemName: "xmark.circle"), attributes: .destructive) { [weak self] _ in Notebooks.remove(notebook) @@ -250,7 +257,7 @@ extension UIFont { } /// A section's pages, subpages indented, each page's conflict pages beneath it. -final class PagesViewController: UITableViewController, UISearchResultsUpdating { +final class PagesViewController: UITableViewController { private struct Item { let row: Row let version: Row.Version? @@ -260,13 +267,9 @@ final class PagesViewController: UITableViewController, UISearchResultsUpdating private(set) var section: Section? private var items: [Item] = [] private var selected: String? - private let results = SearchViewController() - private lazy var search = UISearchController(searchResultsController: results) - private var searching: DispatchWorkItem? + private let search = SearchViewController.controller() /// Opens a page of the section. - var onOpen: ((Section, String, String?) -> Void)? - /// Opens a page of another section of the notebook, found by search. - var onOpenSection: ((Notebook, String, String, String?) -> Void)? + var onOpen: ((Section, String) -> Void)? init() { super.init(style: .plain) @@ -277,22 +280,14 @@ final class PagesViewController: UITableViewController, UISearchResultsUpdating override func viewDidLoad() { super.viewDidLoad() tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") - search.searchResultsUpdater = self - search.searchBar.placeholder = "Search Notebook" navigationItem.searchController = search navigationItem.hidesSearchBarWhenScrolling = false - results.onOpen = { [weak self] found, query in - guard let self, let section, let notebook = Optional(section.notebook) else { return } - if found.section == section.tab.path { - onOpen?(section, found.page, query) - } else { - onOpenSection?(notebook, found.section, found.page, query) - } - } let compose = UIBarButtonItem( title: "New Page", image: UIImage(systemName: "square.and.pencil"), primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) }) - toolbarItems = [.flexibleSpace(), compose] + toolbarItems = [ + .flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace(), compose, + ] NotificationCenter.default.addObserver( self, selector: #selector(changed), name: Section.changed, object: nil) } @@ -398,7 +393,7 @@ final class PagesViewController: UITableViewController, UISearchResultsUpdating override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let section else { return } selected = items[indexPath.row].id - onOpen?(section, items[indexPath.row].id, nil) + onOpen?(section, items[indexPath.row].id) } override func tableView( @@ -432,80 +427,100 @@ final class PagesViewController: UITableViewController, UISearchResultsUpdating return UIMenu(children: actions) }) } +} + +/// A page `sb_library_search` found. +struct Found: Decodable { + let section: String + let page: String + let title: String + let snippet: String +} + +/// Every open notebook's pages holding a query, grouped by notebook, as Notes searches +/// every folder. +final class SearchViewController: UITableViewController, UISearchResultsUpdating { + private var found: [(notebook: Notebook, pages: [Found])] = [] + private var query = "" + private var searching: DispatchWorkItem? + + /// A search bar whose results this shows. + static func controller() -> UISearchController { + let results = SearchViewController(style: .insetGrouped) + let search = UISearchController(searchResultsController: results) + search.searchResultsUpdater = results + search.searchBar.placeholder = "Search Notebooks" + return search + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found") + } func updateSearchResults(for controller: UISearchController) { searching?.cancel() let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? "" - guard let section, let library = section.notebook.handle, !query.isEmpty else { - results.show([], query: query) - return - } - // Each keystroke waits for the next before the notebook is read. + guard !query.isEmpty else { return show([], query: query) } + // Each keystroke waits for the next before the notebooks are read. let work = DispatchWorkItem { [weak self] in - let (library, open, path) = (Int(bitPattern: library), Int(bitPattern: section.handle), section.tab.path) - self?.results.searching(query) + if self?.found.isEmpty == true { + var loading = UIContentUnavailableConfiguration.loading() + loading.text = "Searching…" + self?.contentUnavailableConfiguration = loading + } + let notebooks = Notebooks.all.compactMap { notebook -> (Notebook, Int, Int, String?)? in + guard let library = notebook.handle else { return nil } + // Open sections are searched as their edits leave them. + let open = Section.all.first { $0.notebook === notebook } + return (notebook, Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path) + } background({ - decode( - [Found].self, - sb_library_search( - OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query)) ?? [] + notebooks.map { notebook, library, open, path in + let found = decode( + [Found].self, + sb_library_search( + OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query)) + return (notebook: notebook, pages: found ?? []) + } }) { [weak self] found in guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return } - self?.results.show( - found, query: query, - sections: Dictionary( - section.notebook.tabs.map { ($0.path, $0.name) }, uniquingKeysWith: { first, _ in first })) + self?.show(found.filter { !$0.pages.isEmpty }, query: query) } } searching = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) } -} -/// A page `sb_library_search` found. -struct Found: Decodable { - let section: String - let page: String - let title: String - let snippet: String -} - -final class SearchViewController: UITableViewController { - private var found: [Found] = [] - private var sections: [String: String] = [:] - private var query = "" - var onOpen: ((Found, String) -> Void)? - - override func viewDidLoad() { - super.viewDidLoad() - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found") - } - - func searching(_ query: String) { - guard found.isEmpty else { return } - var loading = UIContentUnavailableConfiguration.loading() - loading.text = "Searching…" - contentUnavailableConfiguration = loading - } - - func show(_ found: [Found], query: String, sections: [String: String] = [:]) { + private func show(_ found: [(notebook: Notebook, pages: [Found])], query: String) { self.found = found self.query = query - self.sections = sections tableView.reloadData() - contentUnavailableConfiguration = found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil + contentUnavailableConfiguration = + found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { found.count } + override func numberOfSections(in tableView: UITableView) -> Int { found.count } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + found[section].notebook.name + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + found[section].pages.count + } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let found = found[indexPath.row] + let (notebook, pages) = found[indexPath.section] + let page = pages[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath) var content = UIListContentConfiguration.subtitleCell() - content.text = found.title.isEmpty ? "Untitled Page" : found.title - let section = sections[found.section] ?? "" + content.text = page.title.isEmpty ? "Untitled Page" : page.title + let tab = notebook.tabs.first { $0.path == page.section } + content.image = UIImage(systemName: "rectangle.portrait.fill") + content.imageProperties.tintColor = tab?.uiColor ?? .systemGray content.secondaryAttributedText = highlighted( - [section, found.snippet].filter { !$0.isEmpty }.joined(separator: " · ")) + [tab?.name ?? "", page.snippet].filter { !$0.isEmpty }.joined(separator: " · ")) content.secondaryTextProperties.numberOfLines = 2 cell.contentConfiguration = content return cell @@ -526,6 +541,9 @@ final class SearchViewController: UITableViewController { } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - onOpen?(found[indexPath.row], query) + let (notebook, pages) = found[indexPath.section] + let page = pages[indexPath.row] + (view.window?.windowScene?.delegate as? SceneDelegate)? + .open(page.section, of: notebook, page: page.page, reveal: .text(query)) } } diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index 6cec50e0b6977f18e3ee0b55a9d1135b8f445cb5..89e75a8eabd3b6ded4ebe0a3401653c9859833ff 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -209,10 +209,27 @@ final class Notebook { if let handle { sb_library_free(handle) } handle = library self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil + follow() reload(done: done) } } + /// Works offline or online, as Work Offline stands. + func follow() { + if let handle { sb_library_set_offline(handle, Sync.offline) } + } + + /// Where the notebook is kept, as the sync sheet names it. + var location: String { + switch source { + case .files: "In Files" + case .documents: "On this \(UIDevice.current.model), in Snowbound’s folder" + case .server(let server): + "smb://\(server.host)/\([server.share, server.root].filter { !$0.isEmpty }.joined(separator: "/"))" + case .path(let path): path + } + } + /// Lists the sections again, off the main thread. func reload(done: @escaping () -> Void) { guard let handle else { diff --git a/apps/ios/Snowbound/PageViewController.swift b/apps/ios/Snowbound/PageViewController.swift index 8b35e356073c6998f3cc0547bdeee476b12604b6..58ea550c72d33c27e9229f3281dde9dc58dfafb5 100644 --- a/apps/ios/Snowbound/PageViewController.swift +++ b/apps/ios/Snowbound/PageViewController.swift @@ -1,6 +1,14 @@ import PhotosUI import UIKit +/// What a page shows selected as it opens. +enum Reveal { + /// The first match of a search. + case text(String) + /// A paragraph the Tags Summary lists, by id. + case paragraph(String) +} + final class PageViewController: UIViewController, PHPickerViewControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate { @@ -9,7 +17,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate private let section: Section private let page: String - private let find: String? + private let reveal: Reveal? private let titleFocus: Bool private lazy var canvas = CanvasView(section: section, page: page) private let bar = ConflictBar() @@ -21,14 +29,18 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) private lazy var redo = UIBarButtonItem( systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) + private lazy var more = UIBarButtonItem( + title: "Page", image: UIImage(systemName: "ellipsis.circle"), + menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }])) - init(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) { + init(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) { self.section = section self.page = page - self.find = find + self.reveal = reveal self.titleFocus = titleFocus super.init(nibName: nil, bundle: nil) navigationItem.largeTitleDisplayMode = .never + navigationItem.rightBarButtonItems = [more] showTitle(section.row(of: page)?.row.title ?? "") } @@ -60,13 +72,21 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate self, selector: #selector(sectionChanged), name: Section.changed, object: section) NotificationCenter.default.addObserver(self, selector: #selector(leaving), name: Self.leaving, object: nil) conflictChanged() + registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (controller: PageViewController, _) in + controller.fitTitle() + } } - /// The page is on screen: a found word is selected, a new page's title takes the caret. + /// The page is on screen: a found word or tagged paragraph is selected, a new page's + /// title takes the caret. private func opened() { - if let find, canvas.find(find) { - _ = canvas.becomeFirstResponder() - } else if titleFocus, canvas.focusTitle() { + let revealed = + switch reveal { + case .text(let query): canvas.find(query) + case .paragraph(let id): canvas.selectParagraph(id) + case nil: false + } + if revealed || titleFocus && canvas.focusTitle() { _ = canvas.becomeFirstResponder() } } @@ -75,23 +95,92 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate self.title = title.isEmpty ? "Untitled Page" : title } + /// A phone's bar has no room for the title beside the editing buttons, and the page + /// shows it anyway, as Notes leaves it out. + private func fitTitle() { + let crowded = canvas.isFirstResponder && traitCollection.horizontalSizeClass == .compact + navigationItem.titleView = crowded ? UIView() : nil + } + /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it. private func editingChanged() { if !canvas.readOnly, let title = canvas.pageTitle { showTitle(title) section.retitle(page, title) } + fitTitle() guard canvas.isFirstResponder else { - navigationItem.rightBarButtonItems = nil + if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more] } return } undo.isEnabled = canvas.canUndo(redo: false) redo.isEnabled = canvas.canUndo(redo: true) if navigationItem.rightBarButtonItems?.first !== done { - navigationItem.rightBarButtonItems = [done, redo, undo] + navigationItem.rightBarButtonItems = [done, more, redo, undo] } } + // MARK: Page menu + + /// The desktop's page and insert commands that suit a touch screen. + private func pageMenu() -> [UIMenuElement] { + let editable: UIMenuElement.Attributes = canvas.readOnly ? .disabled : [] + let scene = view.window?.windowScene?.delegate as? SceneDelegate + let insert = UIMenu( + title: "Insert", image: UIImage(systemName: "plus.circle"), + children: [ + UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle"), attributes: editable) { + [weak self] _ in self?.pickPicture(camera: false) + }, + UIAction( + title: "Take Photo", image: UIImage(systemName: "camera"), + attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? editable : .disabled + ) { [weak self] _ in self?.pickPicture(camera: true) }, + UIMenu(options: .displayInline, children: [ + UIAction(title: "Date", image: UIImage(systemName: "calendar"), attributes: editable) { + [weak self] _ in self?.canvas.insertDate(true, time: false) + }, + UIAction(title: "Time", image: UIImage(systemName: "clock"), attributes: editable) { + [weak self] _ in self?.canvas.insertDate(false, time: true) + }, + UIAction(title: "Date & Time", image: UIImage(systemName: "calendar.badge.clock"), attributes: editable) { + [weak self] _ in self?.canvas.insertDate(true, time: true) + }, + ]), + UIAction( + title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable + ) { [weak self] _ in self?.canvas.insertSpace() }, + ]) + return [ + insert, + UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) { + [weak self] _ in self?.showPaper() + }, + UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in + guard let self else { return } + scene?.showTags(of: section.notebook, from: self) + }, + UIMenu(options: .displayInline, children: [ + UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in + scene?.newPage(subpage: true) + }, + UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) { + [weak self] _ in + guard let self else { return } + section.delete(page) { _ in } + }, + ]), + ] + } + + private func showPaper() { + let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas)) + navigation.sheetPresentationController?.detents = [.medium(), .large()] + navigation.sheetPresentationController?.prefersGrabberVisible = true + navigation.sheetPresentationController?.largestUndimmedDetentIdentifier = .medium + present(navigation, animated: true) + } + @objc private func leaving() { canvas.commitComposition() } @objc private func sectionChanged(_ notification: Notification) { diff --git a/apps/ios/Snowbound/PaperViewController.swift b/apps/ios/Snowbound/PaperViewController.swift new file mode 100644 index 0000000000000000000000000000000000000000..847e24fd942f70b93de17e54acbe717f5d7ab3b0 --- /dev/null +++ b/apps/ios/Snowbound/PaperViewController.swift @@ -0,0 +1,183 @@ +import UIKit + +/// The page's paper from `sb_view_paper`, with the choices OneNote offers. +struct Paper: Decodable { + struct Color: Decodable { + let name: String + let rgb: [UInt8] + + init(from decoder: Decoder) throws { + var pair = try decoder.unkeyedContainer() + name = try pair.decode(String.self) + rgb = try pair.decode([UInt8].self) + } + } + + let color: [UInt8]? + let ruled: Int? + let colors: [Color] + let rules: [String] + let templates: [String] +} + +extension UIColor { + convenience init(rgb: [UInt8]) { + self.init(red: CGFloat(rgb[0]) / 255, green: CGFloat(rgb[1]) / 255, blue: CGFloat(rgb[2]) / 255, alpha: 1) + } + + var rgb: [UInt8] { + var (red, green, blue, alpha): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return [red, green, blue].map { UInt8((min(1, max(0, $0)) * 255).rounded()) } + } +} + +/// OneNote's View, Page Color and Rule Lines, and a template's art behind the page, as a +/// sheet over the page, each choice taking effect at once. +final class PaperViewController: UITableViewController { + private enum Part: Int, CaseIterable { case color, rules, background } + + private let canvas: CanvasView + private var paper: Paper? + private let swatches = UIStackView() + private let well = UIColorWell() + + init(canvas: CanvasView) { + self.canvas = canvas + super.init(style: .insetGrouped) + title = "Page Background" + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row") + navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) + well.title = "Custom Color" + well.supportsAlpha = false + well.addAction(UIAction { [weak self] _ in + guard let self, let color = well.selectedColor else { return } + canvas.setPaper(color: color.rgb, ruled: paper?.ruled) + reload() + }, for: .valueChanged) + reload() + } + + private func reload() { + paper = canvas.pagePaper + showSwatches() + tableView.reloadData() + } + + private func showSwatches() { + swatches.arrangedSubviews.forEach { $0.removeFromSuperview() } + guard let paper else { return } + let none = swatch(nil, name: "No Color", chosen: paper.color == nil) + swatches.addArrangedSubview(none) + for color in paper.colors { + swatches.addArrangedSubview(swatch(color.rgb, name: color.name, chosen: paper.color == color.rgb)) + } + let custom = paper.color.flatMap { color in paper.colors.contains { $0.rgb == color } ? nil : color } + well.selectedColor = custom.map(UIColor.init(rgb:)) + swatches.addArrangedSubview(well) + } + + private func swatch(_ rgb: [UInt8]?, name: String, chosen: Bool) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.background.backgroundColor = rgb.map(UIColor.init(rgb:)) ?? .white + configuration.background.cornerRadius = 16 + configuration.background.strokeColor = chosen ? .tintColor : .separator + configuration.background.strokeWidth = chosen ? 3 : 1 + if rgb == nil { + configuration.image = UIImage(systemName: "line.diagonal") + configuration.baseForegroundColor = .systemRed + } + let button = UIButton(configuration: configuration, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + canvas.setPaper(color: rgb, ruled: paper?.ruled) + reload() + }) + button.accessibilityLabel = name + button.accessibilityTraits.insert(chosen ? .selected : []) + button.widthAnchor.constraint(equalToConstant: 32).isActive = true + button.heightAnchor.constraint(equalToConstant: 32).isActive = true + return button + } + + private let parts = Part.allCases + + override func numberOfSections(in tableView: UITableView) -> Int { parts.count } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch parts[section] { + case .color: "Page Color" + case .rules: "Rule Lines" + case .background: "Background" + } + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let paper else { return 0 } + return switch parts[section] { + case .color: 1 + case .rules: 1 + paper.rules.count + case .background: 1 + paper.templates.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath) + cell.contentView.subviews.filter { $0 is UIScrollView }.forEach { $0.removeFromSuperview() } + cell.accessoryType = .none + cell.contentConfiguration = nil + guard let paper else { return cell } + switch parts[indexPath.section] { + case .color: + cell.selectionStyle = .none + let scroll = UIScrollView() + scroll.showsHorizontalScrollIndicator = false + swatches.spacing = 12 + swatches.alignment = .center + swatches.translatesAutoresizingMaskIntoConstraints = false + scroll.translatesAutoresizingMaskIntoConstraints = false + scroll.addSubview(swatches) + cell.contentView.addSubview(scroll) + NSLayoutConstraint.activate([ + scroll.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor), + scroll.topAnchor.constraint(equalTo: cell.contentView.topAnchor), + scroll.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor), + scroll.heightAnchor.constraint(equalToConstant: 56), + swatches.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 16), + swatches.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -16), + swatches.centerYAnchor.constraint(equalTo: scroll.frameLayoutGuide.centerYAnchor), + ]) + case .rules: + cell.selectionStyle = .default + var content = cell.defaultContentConfiguration() + content.text = indexPath.row == 0 ? "None" : paper.rules[indexPath.row - 1] + cell.contentConfiguration = content + let chosen = indexPath.row == 0 ? paper.ruled == nil : paper.ruled == indexPath.row - 1 + cell.accessoryType = chosen ? .checkmark : .none + case .background: + cell.selectionStyle = .default + var content = cell.defaultContentConfiguration() + content.text = indexPath.row == 0 ? "None" : paper.templates[indexPath.row - 1] + cell.contentConfiguration = content + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let paper else { return } + switch parts[indexPath.section] { + case .color: return + case .rules: canvas.setPaper(color: paper.color, ruled: indexPath.row == 0 ? nil : indexPath.row - 1) + case .background: canvas.setArt(indexPath.row == 0 ? nil : paper.templates[indexPath.row - 1]) + } + reload() + } +} diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift index d5bf52cb775dec2fc44a9701a3a95b4f328a87c7..2e52abd094eaaa191214e12a1483b878edc820bd 100644 --- a/apps/ios/Snowbound/Script.swift +++ b/apps/ios/Snowbound/Script.swift @@ -10,7 +10,7 @@ extension CanvasView { /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word` /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall` - /// is the Select All command; `done` + /// is the Select All command; `rotate:landscape` or `rotate:portrait` turns the device; `done` /// ends editing, `tree` saves the view hierarchy to /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no /// screenshot command. @@ -54,6 +54,9 @@ extension CanvasView { case "doubletap": let values = argument.split(separator: ",").compactMap { Double($0) } zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) + case "rotate": + let orientation: UIInterfaceOrientationMask = argument == "portrait" ? .portrait : .landscapeRight + window?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation)) case "done": _ = resignFirstResponder() case "selectall": selectAll(nil) case "format": apply(UInt8(argument) ?? 0) diff --git a/apps/ios/Snowbound/Sync.swift b/apps/ios/Snowbound/Sync.swift new file mode 100644 index 0000000000000000000000000000000000000000..cfa00d2b66d77a6bce55ab999f586967638f25be --- /dev/null +++ b/apps/ios/Snowbound/Sync.swift @@ -0,0 +1,260 @@ +import UIKit + +/// A section's sync status from `sb_library_sync_status`. +struct SectionSync: Decodable { + enum State: UInt8, Decodable, Comparable { + case upToDate, syncing, inUse, notConnected, readOnly, failed + + static func < (a: State, b: State) -> Bool { a.rawValue < b.rawValue } + } + + let path: String + let state: State + /// Seconds since 1970 when the section file was last reached. + let synced: Double? + let queued: Int + let error: String? +} + +/// OneNote's Work Offline and Sync Now over every open notebook, and their status. +enum Sync { + /// Posted on the main thread when a notebook's closed sections report. + static let changed = Notification.Name("SyncChanged") + private static let key = "offline" + + static var offline: Bool { + get { UserDefaults.standard.bool(forKey: key) } + set { + UserDefaults.standard.set(newValue, forKey: key) + for notebook in Notebooks.all { notebook.follow() } + NotificationCenter.default.post(name: changed, object: nil) + } + } + + static func now() { + for notebook in Notebooks.all { + if let handle = notebook.handle { sb_library_sync_now(handle) } + } + } + + /// Every open notebook's sections with their status, read off the main thread. + static func status(done: @escaping ([(Notebook, [SectionSync])]) -> Void) { + let notebooks = Notebooks.all.compactMap { notebook in notebook.handle.map { (notebook, Int(bitPattern: $0)) } } + background({ + notebooks.map { notebook, pointer in + (notebook, decode([SectionSync].self, sb_library_sync_status(OpaquePointer(bitPattern: pointer))) ?? []) + } + }, done: done) + } + + /// What `state` comes to for the reader, as the desktop names it. + static func label(_ state: SectionSync.State) -> String { + if offline { return "Working offline" } + return switch state { + case .upToDate: "Up to date" + case .syncing: "Syncing…" + case .inUse: "Section in use" + case .notConnected: "Not connected" + case .readOnly, .failed: "Unable to sync" + } + } + + static func symbol(_ state: SectionSync.State) -> String { + if offline { return "icloud.slash" } + return switch state { + case .upToDate: "checkmark.circle" + case .syncing, .inUse: "arrow.triangle.2.circlepath" + case .notConnected: "wifi.slash" + case .readOnly, .failed: "exclamationmark.triangle" + } + } + + /// What the reader can do about `state`. + static func advice(_ state: SectionSync.State) -> String? { + let device = UIDevice.current.model + if offline { return "Changes stay on this \(device) until you sync." } + return switch state { + case .readOnly: "You can’t change this notebook where it’s stored. Changes stay on this \(device)." + case .inUse: "Someone else is saving this section. Sync continues when they finish." + case .notConnected: "Changes stay on this \(device) and sync when the notebook is back." + default: nil + } + } + + static func changes(_ count: Int) -> String { count == 1 ? "1 change" : "\(count) changes" } + + /// When a section was last reached: the time, and the date too before today. + static func when(_ seconds: Double) -> String { + let date = Date(timeIntervalSince1970: seconds) + return Calendar.current.isDateInToday(date) + ? DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short) + : DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short) + } +} + +/// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync +/// sheet. +final class SyncIndicator: UIButton { + init() { + var configuration = UIButton.Configuration.plain() + configuration.imagePadding = 4 + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1) + configuration.baseForegroundColor = .secondaryLabel + configuration.titleLineBreakMode = .byTruncatingTail + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = .preferredFont(forTextStyle: .caption1) + return attributes + } + super.init(frame: .zero) + self.configuration = configuration + addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered) + for name in [Sync.changed, Section.changed] { + NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil) + } + refresh() + } + + required init?(coder: NSCoder) { fatalError() } + + @objc private func refresh() { + Sync.status { [weak self] notebooks in + let sections = notebooks.flatMap(\.1) + guard let self, let worst = sections.map(\.state).max() else { + self?.isHidden = true + return + } + isHidden = false + let queued = sections.map(\.queued).reduce(0, +) + let label = Sync.label(worst) + configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label + configuration?.image = UIImage(systemName: Sync.symbol(worst)) + configuration?.baseForegroundColor = worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel + accessibilityLabel = "Sync status: \(configuration?.title ?? label)" + sizeToFit() + } + } + + private func present() { + guard let controller = window?.rootViewController else { return } + let navigation = UINavigationController(rootViewController: SyncViewController()) + navigation.sheetPresentationController?.detents = [.medium(), .large()] + navigation.sheetPresentationController?.prefersGrabberVisible = true + (controller.presentedViewController ?? controller).present(navigation, animated: true) + } +} + +/// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections. +final class SyncViewController: UITableViewController { + private var notebooks: [(Notebook, [SectionSync])] = [] + private var timer: Timer? + private let offline = UISwitch() + + init() { + super.init(style: .insetGrouped) + title = "Sync Status" + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row") + navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) + offline.addAction(UIAction { [weak self] _ in + Sync.offline = self?.offline.isOn ?? false + }, for: .valueChanged) + for name in [Sync.changed, Section.changed] { + NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + refresh() + // The last sync time follows each round, which reports only when something changes. + timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in self?.refresh() } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + timer?.invalidate() + } + + @objc private func refresh() { + Sync.status { [weak self] notebooks in + self?.notebooks = notebooks.filter { !$0.1.isEmpty } + self?.tableView.reloadData() + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { 1 + notebooks.count } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 2 : notebooks[section - 1].1.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? nil : notebooks[section - 1].0.name + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + guard section > 0 else { return Sync.offline ? Sync.advice(.upToDate) : nil } + let (notebook, sections) = notebooks[section - 1] + let worst = sections.map(\.state).max() ?? .upToDate + let synced = sections.map(\.synced) + let oldest = synced.contains(where: { $0 == nil }) ? nil : synced.compactMap { $0 }.min() + return [ + notebook.location, + oldest.map { "Last sync \(Sync.when($0))" }, + Sync.offline ? nil : Sync.advice(worst), + ].compactMap { $0 }.joined(separator: "\n") + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath) + cell.accessoryView = nil + cell.selectionStyle = .none + if indexPath.section == 0 { + var content = UIListContentConfiguration.cell() + if indexPath.row == 0 { + content.text = "Work Offline" + offline.isOn = Sync.offline + cell.accessoryView = offline + } else { + content.text = "Sync Now" + content.textProperties.color = .tintColor + cell.selectionStyle = .default + } + cell.contentConfiguration = content + return cell + } + let (notebook, sections) = notebooks[indexPath.section - 1] + let sync = sections[indexPath.row] + var content = UIListContentConfiguration.valueCell() + let tab = notebook.tabs.first { $0.path == sync.path } + content.text = tab?.name ?? URL(fileURLWithPath: sync.path).deletingPathExtension().lastPathComponent + content.image = UIImage(systemName: "rectangle.portrait.fill") + content.imageProperties.tintColor = tab?.uiColor ?? .systemGray + let label = Sync.label(sync.state) + content.secondaryText = sync.queued > 0 ? "\(label), \(Sync.changes(sync.queued))" : label + if let error = sync.error { + content = UIListContentConfiguration.subtitleCell() + content.text = tab?.name ?? sync.path + content.image = UIImage(systemName: "rectangle.portrait.fill") + content.imageProperties.tintColor = tab?.uiColor ?? .systemGray + content.secondaryText = "\(label): \(error)" + content.secondaryTextProperties.color = .secondaryLabel + } + cell.contentConfiguration = content + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard indexPath == IndexPath(row: 1, section: 0) else { return } + Sync.now() + refresh() + } +} diff --git a/apps/ios/Snowbound/Tags.swift b/apps/ios/Snowbound/Tags.swift new file mode 100644 index 0000000000000000000000000000000000000000..cc2676721b620785a5750ea238973328d7ece294 --- /dev/null +++ b/apps/ios/Snowbound/Tags.swift @@ -0,0 +1,193 @@ +import UIKit + +/// OneNote's tags in `sb_view_apply`'s order from 16, drawn with the page's artwork. +enum Tags { + struct Tag { + let name: String + /// The symbol `sb_tag_icon` draws; 0 for a highlighting tag. + let shape: UInt16 + let highlight: UIColor? + } + + static let all: [Tag] = { + guard let text = take(sb_tags()), + let rows = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [[Any]] + else { return [] } + return rows.compactMap { row in + guard row.count == 3, let name = row[0] as? String, let shape = row[1] as? NSNumber else { return nil } + let highlight = (row[2] as? [NSNumber]).map { UIColor(rgb: $0.map(\.uint8Value)) } + return Tag(name: name, shape: shape.uint16Value, highlight: highlight) + } + }() + + /// The first nine, which ⌘1 to ⌘9 apply, as Ctrl+1 to Ctrl+9 do in OneNote. + static let keyed = 9 + + private static var icons: [Int: UIImage] = [:] + + /// Tag symbol `shape` as OneNote draws it, a check box `checked`; a highlighter in + /// `highlight` for a highlighting tag. + static func icon(_ shape: UInt16, checked: Bool = false, highlight: UIColor? = nil) -> UIImage? { + guard shape != 0 else { + // Highlighted lines, as the desktop draws these tags. + let fill = highlight ?? .systemYellow + return UIGraphicsImageRenderer(size: CGSize(width: 18, height: 18)).image { context in + let card = UIBezierPath(roundedRect: CGRect(x: 1.5, y: 3.5, width: 15, height: 11), cornerRadius: 1.5) + fill.setFill() + card.fill() + UIColor(white: 0, alpha: 0.35).setStroke() + card.stroke() + UIColor(white: 0.25, alpha: 1).setFill() + context.fill(CGRect(x: 4, y: 6.5, width: 10, height: 1.5)) + context.fill(CGRect(x: 4, y: 10, width: 7, height: 1.5)) + } + } + let key = Int(shape) << 1 | (checked ? 1 : 0) + if let icon = icons[key] { return icon } + let points: CGFloat = 18 + let scale = UIScreen.main.scale + let pixels = Int(points * scale) + var rgba = [UInt8](repeating: 0, count: pixels * pixels * 4) + guard sb_tag_icon(shape, checked, UInt32(pixels), &rgba), + let provider = CGDataProvider(data: Data(rgba) as CFData), + let image = CGImage( + width: pixels, height: pixels, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: pixels * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) + else { return UIImage(systemName: "tag") } + let icon = UIImage(cgImage: image, scale: scale, orientation: .up).withRenderingMode(.alwaysOriginal) + icons[key] = icon + return icon + } + + /// Every tag as a menu, those the selection has checked, then Remove Tag; `apply` takes + /// the `sb_view_apply` command. + static func menu(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIMenuElement] { + let actions = all.enumerated().map { index, tag in + UIAction( + title: tag.name, image: icon(tag.shape, highlight: tag.highlight), + state: bits & (1 << UInt64(16 + index)) != 0 ? .on : .off + ) { _ in apply(16 + UInt8(index)) } + } + let tagged = bits >> 16 != 0 + return [ + UIMenu(options: .displayInline, children: Array(actions.prefix(keyed))), + UIMenu(title: "More Tags", image: UIImage(systemName: "tag"), children: Array(actions.dropFirst(keyed))), + UIAction( + title: "Remove Tag", image: UIImage(systemName: "tag.slash"), + attributes: tagged ? [] : .disabled + ) { _ in apply(8) }, + ] + } +} + +/// A tagged paragraph from `sb_library_tagged`. +struct Tagged: Decodable { + let section: String + let page: String + let title: String + let paragraph: String + let name: String + let shape: UInt16 + let checked: Bool + let text: String +} + +/// OneNote's Tags Summary: a notebook's tagged paragraphs by tag, each opening its page with +/// the paragraph selected. +final class TagsViewController: UITableViewController { + private let notebook: Notebook + private var groups: [(name: String, items: [Tagged])] = [] + private var unchecked = false + private var all: [Tagged] = [] + /// Opens a page of the notebook: section path, page and paragraph. + var onOpen: ((Notebook, String, String, String) -> Void)? + + init(notebook: Notebook) { + self.notebook = notebook + super.init(style: .insetGrouped) + title = "Tags Summary" + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "tagged") + navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "Filter", image: UIImage(systemName: "line.3.horizontal.decrease.circle"), + menu: UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak self] done in + done([ + UIAction(title: "Unchecked Only", state: self?.unchecked == true ? .on : .off) { _ in + self?.unchecked.toggle() + self?.show() + } + ]) + } + ])) + var loading = UIContentUnavailableConfiguration.loading() + loading.text = "Finding Tags…" + contentUnavailableConfiguration = loading + guard let library = notebook.handle else { return } + let open = Section.all.first { $0.notebook === notebook } + let (pointer, section, path) = (Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path) + background({ + decode([Tagged].self, sb_library_tagged(OpaquePointer(bitPattern: pointer), OpaquePointer(bitPattern: section), path)) + ?? [] + }) { [weak self] tagged in + self?.all = tagged + self?.show() + } + } + + private func show() { + var order: [String] = [] + var byName: [String: [Tagged]] = [:] + for tagged in all where !unchecked || !tagged.checked { + if byName[tagged.name] == nil { order.append(tagged.name) } + byName[tagged.name, default: []].append(tagged) + } + groups = order.map { ($0, byName[$0] ?? []) } + tableView.reloadData() + var empty = UIContentUnavailableConfiguration.empty() + empty.image = UIImage(systemName: "tag") + empty.text = unchecked ? "No Unchecked Tags" : "No Tags" + empty.secondaryText = unchecked ? nil : "Tag a paragraph from the format bar or its menu." + contentUnavailableConfiguration = groups.isEmpty ? empty : nil + } + + override func numberOfSections(in tableView: UITableView) -> Int { groups.count } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + groups[section].name + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + groups[section].items.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let tagged = groups[indexPath.section].items[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: "tagged", for: indexPath) + var content = UIListContentConfiguration.subtitleCell() + content.image = Tags.icon(tagged.shape, checked: tagged.checked) + content.text = tagged.text.isEmpty ? " " : tagged.text + content.textProperties.numberOfLines = 3 + let section = notebook.tabs.first { $0.path == tagged.section }?.name + content.secondaryText = [tagged.title.isEmpty ? "Untitled Page" : tagged.title, section] + .compactMap { $0 }.joined(separator: " · ") + content.secondaryTextProperties.color = .secondaryLabel + cell.contentConfiguration = content + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let tagged = groups[indexPath.section].items[indexPath.row] + dismiss(animated: true) + onOpen?(notebook, tagged.section, tagged.page, tagged.paragraph) + } +} diff --git a/arc/canvas.md b/arc/canvas.md index 9258569391cb92a012cd45b43b950457f97af6ca..22f8e65311ad6a893d32cd6e473959a137c4b7f2 100644 --- a/arc/canvas.md +++ b/arc/canvas.md @@ -41,7 +41,10 @@ the size of the edit, not the size of the page. Undo lives in the editor, not in storage. Each history entry keeps the inverse of the change it made. Undoing applies that inverse and emits its ops as a new -edit, so the file only ever moves forward, like OneNote's. Undoing a deletion +edit, so the file only ever moves forward, like OneNote's. Undo steps are +OneNote 2010's too: typing and backspaces at one caret are a single step until +the caret moves or another kind of edit comes between, though each keystroke's +ops still reach storage as it is typed. Undoing a deletion brings back the original identities, so internal links to those paragraphs survive an undo. @@ -87,6 +90,22 @@ OneNote links it when it opens a page, without being stored as one. Ink draws st coordinates. Page templates' background art is recreated as vector art and recognised by the stored picture's hash. OneNote's bitmaps aren't shipped. +## Attached files + +A file sits in an outline's flow as OneNote draws it: its icon over its name, +extension hidden, in a 54-point column. Attaching splits the caret's paragraph +around the file, and the text after the caret follows it with the caret, as +OneNote 2010 attaches and drops files. Every new file stores the icon OneNote +stores beside it, because OneNote draws a broken picture for a file without +one: the host's system icon where it has one, the canvas's blank page +otherwise. A double click or the context menu's Open asks the host to open a +copy; Save As writes the bytes where the user chooses. + +Attached or dropped where a click on blank page left the caret, the file goes +on the page itself, as OneNote 2010 places it: the same column at the caret's +grid point, outside any outline. It selects, drags on the grid, deletes and +moves with Insert Space as a picture does, and opens and saves as above. + ## Content the editor doesn't understand Nothing is lost for being unfamiliar. A paragraph the canvas can't draw diff --git a/arc/file-format.md b/arc/file-format.md index f8bfbeefe7d4403dbf534bebc479d450949430bf..76fbfd1bd3251b488fe3633c138eea344ba26eaa 100644 --- a/arc/file-format.md +++ b/arc/file-format.md @@ -54,9 +54,14 @@ the file grows): file mean nothing until the header's transaction count says a transaction holding them has committed. -External payloads (large pictures, attachments, recordings) can live beside -the section as `.onebin` files in a `_onefiles` folder. The section refers to -them by name. +The format lets payloads (pictures, attachments, recordings) live beside the +section as `.onebin` files in a `_onefiles` folder, referred to by name. OneNote +2010 never writes them: every attachment it stores, 300 MiB ones included and +on a share too, goes into the section's own file data store, with a 32-pixel +PNG of the file's icon beside it. Snowbound writes the same. OneNote also +stores identical bytes once per section, so two attachments of one file (or +one icon) share a payload; Snowbound stores each anew, which OneNote reads the +same. ## What "append one revision" means diff --git a/arc/sync.md b/arc/sync.md index 0d53c438c69479bb488f8060cbce3eba52042830..426a30ff8fd5f147df3c0223c536ec0f475935f0 100644 --- a/arc/sync.md +++ b/arc/sync.md @@ -58,6 +58,13 @@ replayed and the open batch applied, rebuilt on open. The database runs in WAL mode with full synchronous commits, and every setting is read back to check it took. +An attached file is a payload like a picture: its bytes ride in the edit that +inserts it and publish inside that edit's revision. No other file is written, +so nothing needs to reach the share before the revision that names it. That +ordering would matter only for `_onefiles` payloads, which OneNote 2010 never +writes ([file format](file-format.md)). A large file makes a publication as +large as itself. + A keystroke that publishes immediately costs three commits: the edit, the seal (which also records the publication attempt), and the receipt. Each one is ordered against something outside the cache. The edit must be durable before diff --git a/arc/ui.md b/arc/ui.md index 4287b45b2d1028c9ef760e7538ed0b517c7a739d..8c9749f5a91599285930b57b5f6b42f8a3fa1215 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -116,7 +116,8 @@ rounded or gradient rectangles, pen strokes and raster images. - **Glyphs** are rasterized with Swash into an atlas, at quarter-pixel phases, so text placed at fractional positions stays crisp and doesn't shimmer as it moves. Icons and paths rasterize once per size and phase into the same - atlas. + atlas. A rounded box's soft shadow is worked out in the shader instead, so + popups of any size, widening or not, cost the atlas nothing. - **The renderer never measures text.** Text reaches it through a trait that visits positioned glyph runs. Line metrics stay with whoever laid the text out, which is how the page keeps its Windows metrics while the chrome uses @@ -128,9 +129,10 @@ rounded or gradient rectangles, pen strokes and raster images. or within shows through a row. A popup's contents clip to its rounded outline, and one opening over a combo lays its rows out at their final width while only the outline widens. -- **Caches are bounded.** The glyph atlas and image cache have fixed budgets. - Eviction keeps everything the current frame needs and rebuilds after - pressure. Images are filtered in linear light with premultiplied alpha. +- **Caches are bounded.** The image cache has a fixed budget. A full glyph + atlas is cleared and rebuilt with only what the current frame needs; where + one frame alone needs more than half of it, it doubles, up to what the GPU + allows. Images are filtered in linear light with premultiplied alpha. GPU readback tests pin the rasterization down: successive quarter-pixel translations have to move the ink's centroid in quarter-pixel steps, and diff --git a/corpus/attachment-edit/README.md b/corpus/attachment-edit/README.md index cb168e20325c7e9341e9e18f62d3776e6e12d523..4d550f6ea431829078d2fe0d12f083a3643d94a2 100644 --- a/corpus/attachment-edit/README.md +++ b/corpus/attachment-edit/README.md @@ -10,7 +10,8 @@ section's file-data store list, an embedded-file object declaring it by identity and extension, and an attachment object with the file name, the 24 pt icon size and the flags every native attachment carries. `icon/` also embeds the native fixture's 724-byte icon PNG as the preview picture; -`plain/` omits it, which OneNote accepts (it renders its own icon). +`plain/` omits it, which OneNote opens but draws as a broken picture +(`corpus/attachment-insert/without-icon.png`), so the editor always stores one. OneNote's integrity check refuses an attachment object without `0x1c001d61` (twenty bytes: 16, 1, zeros), so the writer always stores it. diff --git a/corpus/attachment-floating/README.md b/corpus/attachment-floating/README.md new file mode 100644 index 0000000000000000000000000000000000000000..30333ecbe18077d2898cbef468320b4c951f5675 --- /dev/null +++ b/corpus/attachment-floating/README.md @@ -0,0 +1,38 @@ +# Files on the page + +`native/files.one` is OneNote 2010's own section, made in a lab clone on +2026-09-29. Its second page holds two copies of `float.txt` (38 bytes) outside +any outline: one inserted through COM `UpdatePageContent` as a page-level +`InsertedFile` at (216, 180) and then dragged to (288, 140.4), the other +attached with Insert > Attach File after a click on blank page, at (342, 284.4). + +`candidate/` is `crates/canvas/tests/attachment_insert.rs`'s second section: +the editor places the caret on blank page at (342, 284.4) and attaches +`float 🦀.txt` (37 bytes, with the icon OneNote stored for a text file), then +at (126, 410.4) attaches `large.bin` (2 MiB of noise, with the canvas's +blank-page icon) and drags it to (288, 140.4). Each step is its own edit of the +ops the editor recorded, sealed into one appended revision. `cold/` is a fresh +OneNote 2010 read: both files are page-level `InsertedFile`s at those +positions, and the bytes OneNote materializes to open them +(`read/*.attachment`) hash as written. `tools/test_attachment_floating.py` +checks the capture without a VM. Regenerate with `CANVAS_FLOATING_EXPORT` set to +a directory while running the test, then `tools/native_runner.py DIR COLD +--expected-pages 1 --collect-notebook --screenshots`. + +## What OneNote 2010 does + +- **Placement.** Attached or dropped on blank page, the file becomes a child of + the page at the point a click there puts the caret: on the 18 pt grid from + the margin origin, 7 px above the pointer. It draws as a file in an outline + does, its 32-pixel icon at 24 points centred in a 54-point column over its + name without the extension, and selects as a shaded column framed in dashes, + without handles. +- **Storage.** The attachment object (jcid 0x60035) carries what a paragraph's + file does, except LayoutAlignmentInParent; its position (0x14001c14/15), + LayoutAlignmentSelf 9, and the column's LayoutMaxWidth/Height (54 by about 63 + points) with the size not set by the user. A file inserted through COM has + neither alignment nor size. +- **Moves.** Dragging rewrites the position alone, on the grid; the + modification time stays. +- **Payloads.** Both copies' embedded-file objects, and both icons, name one + payload in the file data store. diff --git a/corpus/attachment-floating/candidate/Open Notebook.onetoc2 b/corpus/attachment-floating/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..b29c1db7203d7c9d5349230df4d715f533d71a35 Binary files /dev/null and b/corpus/attachment-floating/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/attachment-floating/candidate/files.one b/corpus/attachment-floating/candidate/files.one new file mode 100644 index 0000000000000000000000000000000000000000..905e7d4c7d1dc2720b22fedbf895ae0d43dae17f Binary files /dev/null and b/corpus/attachment-floating/candidate/files.one differ diff --git a/corpus/attachment-floating/cold/commands.jsonl b/corpus/attachment-floating/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..0e015ed5e18134a2f31be5ea120cf81847101b62 --- /dev/null +++ b/corpus/attachment-floating/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-45BB9354 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/attachment-floating/cold/machine.json b/corpus/attachment-floating/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..21fb874d077363c9691a36d3cc0b91de9f5d96aa --- /dev/null +++ b/corpus/attachment-floating/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-45bb9354", "hostname": "ONE-M6-45BB9354"} diff --git a/corpus/attachment-floating/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment b/corpus/attachment-floating/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment new file mode 100644 index 0000000000000000000000000000000000000000..8e1f70cf2640fa404f4399f624f84db6853af846 Binary files /dev/null and b/corpus/attachment-floating/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment differ diff --git a/corpus/attachment-floating/cold/read/78b7cac526a295294b0a4f001cfdc3a8a30ea5dad8d18cc53022a6a5285ca58f.attachment b/corpus/attachment-floating/cold/read/78b7cac526a295294b0a4f001cfdc3a8a30ea5dad8d18cc53022a6a5285ca58f.attachment new file mode 100644 index 0000000000000000000000000000000000000000..11fece2d64368f44102dcece5b19bb3d527b1ce7 --- /dev/null +++ b/corpus/attachment-floating/cold/read/78b7cac526a295294b0a4f001cfdc3a8a30ea5dad8d18cc53022a6a5285ca58f.attachment @@ -0,0 +1 @@ +Snowbound put this file on the page. diff --git a/corpus/attachment-floating/cold/read/environment.json b/corpus/attachment-floating/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..4220e6ae2b32510afa9807de54fe2992ef22cf0b --- /dev/null +++ b/corpus/attachment-floating/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-45BB9354", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/attachment-floating/cold/read/hierarchy.xml b/corpus/attachment-floating/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..a5c0ebd2bd7e2e4aac8657b09f9dccbb6b78c66f --- /dev/null +++ b/corpus/attachment-floating/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/attachment-floating/cold/read/page-000.navigation.json b/corpus/attachment-floating/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..5bc1c19afca5f08217613a447359257f6a310573 --- /dev/null +++ b/corpus/attachment-floating/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Floating files - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/attachment-floating/cold/read/page-000.png b/corpus/attachment-floating/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..674b8248b0e0343261a903a432992451a88c0d88 Binary files /dev/null and b/corpus/attachment-floating/cold/read/page-000.png differ diff --git a/corpus/attachment-floating/cold/read/page-000.xml b/corpus/attachment-floating/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..c827a48ba1c7d4e8bfb00037bc85a794823afda7 --- /dev/null +++ b/corpus/attachment-floating/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/attachment-floating/cold/read/payloads.json b/corpus/attachment-floating/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..804d83602a0ca10ae32a46c7f44407b2a81798a2 --- /dev/null +++ b/corpus/attachment-floating/cold/read/payloads.json @@ -0,0 +1,18 @@ +[ + { + "sha256": "1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a", + "name": "large.bin", + "object": "{01634379-E6DF-0FE9-1F9E-D863E5BCE12A}{4}{B0}", + "kind": "InsertedFile", + "page": "{6A838D53-0497-0161-3E0F-95FC73741A4D}{1}{B0}", + "bytes": 2097152 + }, + { + "sha256": "78b7cac526a295294b0a4f001cfdc3a8a30ea5dad8d18cc53022a6a5285ca58f", + "name": "float 🦀.txt", + "object": "{01634379-E6DF-0FE9-1F9E-D863E5BCE12A}{2}{B0}", + "kind": "InsertedFile", + "page": "{6A838D53-0497-0161-3E0F-95FC73741A4D}{1}{B0}", + "bytes": 37 + } +] \ No newline at end of file diff --git a/corpus/attachment-floating/cold/run.json b/corpus/attachment-floating/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..b790e1bf29e935a2dc7a48d6fa4222fc44c16b79 --- /dev/null +++ b/corpus/attachment-floating/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-attachments/export", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/attachment-floating/cold/scripts/cold.ps1 b/corpus/attachment-floating/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/attachment-floating/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/attachment-floating/cold/scripts/read.ps1 b/corpus/attachment-floating/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/attachment-floating/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/attachment-floating/cold/source.json b/corpus/attachment-floating/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..2bd44a8b45a520d1c0559c03e0c2a7b34ead6158 --- /dev/null +++ b/corpus/attachment-floating/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "46e74799daaccb7e37abf1696a1920ce47bb39bd721a2e7e15af8d0f6db7416f", + "mtime_ns": 1790706678169228316 + }, + { + "path": "files.one", + "bytes": 2106848, + "sha256": "cb063e9a0656899d90d4f95e469d9fec1c72bc259d44f673071d6b03e55ab241", + "mtime_ns": 1790706678168913736 + } +] diff --git a/corpus/attachment-floating/cold/teardown.json b/corpus/attachment-floating/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/attachment-floating/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/attachment-floating/native/files.one b/corpus/attachment-floating/native/files.one new file mode 100644 index 0000000000000000000000000000000000000000..b8880db03e219a89b9becd68744d49320f6c97c3 Binary files /dev/null and b/corpus/attachment-floating/native/files.one differ diff --git a/corpus/attachment-insert/README.md b/corpus/attachment-insert/README.md new file mode 100644 index 0000000000000000000000000000000000000000..498f9c717030e13e105a52ec010df04e1f627393 --- /dev/null +++ b/corpus/attachment-insert/README.md @@ -0,0 +1,42 @@ +# Attaching files from the editor + +`candidate/` is `crates/canvas/tests/attachment_insert.rs`'s section: the editor +attaches `notes 🦀.txt` (30 bytes, with the icon OneNote stored for a text file) +at the end of "Before the file", "After the file" is typed where the caret +lands, and `large.bin` (2 MiB of noise, with the canvas's blank-page icon, as +a host without system icons stores it) is attached after that. Each step is its +own edit of the ops the editor recorded, sealed into one appended revision. +`cold/` is a fresh OneNote 2010 read: both files in place, and the bytes OneNote +materializes to open them (`read/*.attachment`) hash as written. Opened by +double click and saved by Save As in a lab clone, both matched too. +`tools/test_attachment_insert.py` checks the capture without a VM. Regenerate +with `CANVAS_ATTACHMENT_EXPORT` set to a directory while running the test, then +`tools/native_runner.py DIR COLD --expected-pages 1 --collect-notebook +--screenshots`. + +`without-icon.png` is OneNote opening a file stored without the icon: it draws +a broken picture, so the editor always stores one. + +## What OneNote 2010 does + +Observed in a lab clone on 2026-09-29: + +- **Storage.** Every attachment goes into the section's own file data store, + whatever its size (1 KiB to 300 MiB, through COM and through Insert > Attach + File, on a local folder and on a share). OneNote never wrote a `_onefiles` + folder. Identical bytes are stored once per section: a second attachment of + the same file, or the same icon, references the first payload. Snowbound + stores each attachment's bytes anew, which OneNote reads the same. +- **Icon.** Beside each file it stores a 32-pixel PNG of the file type's + system icon, drawn at 24 points, over the name without its extension. +- **Attach File and drops.** At the caret, the paragraph splits there: the file + follows the text before the caret (or replaces an empty first half) and the + text after it follows the file with the caret, as an empty paragraph at the + end of one. A drop on text does the same at the drop point, after asking + whether to attach a copy (the default) or link to the original. Attached or + dropped on blank page, the file becomes a page-level object at that point. +- **Open.** A double click or Open warns once ("Opening attachments could harm + your computer and data") and opens a copy from + `%TEMP%\OneNote\14.0\NT\N\` under the file's own name. The context menu + offers Open, Save As (in the source's folder, under its name), Insert as + Printout, Open Original and Copy Link to Original. diff --git a/corpus/attachment-insert/candidate/Open Notebook.onetoc2 b/corpus/attachment-insert/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..f7d1fb8af7c6af07f21b48f9f775e6db0e1d02f6 Binary files /dev/null and b/corpus/attachment-insert/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/attachment-insert/candidate/files.one b/corpus/attachment-insert/candidate/files.one new file mode 100644 index 0000000000000000000000000000000000000000..e8af42e5676f6829fc91e6738065fc1c25d9aa7c Binary files /dev/null and b/corpus/attachment-insert/candidate/files.one differ diff --git a/corpus/attachment-insert/cold/commands.jsonl b/corpus/attachment-insert/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..616c2bf78e4841cc56ebd6a962b1a8edc018610c --- /dev/null +++ b/corpus/attachment-insert/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-DDCBCDBF -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/attachment-insert/cold/machine.json b/corpus/attachment-insert/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..f6fc1e3eabcd10ca9a742fcc49c75e26fb1db08f --- /dev/null +++ b/corpus/attachment-insert/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-ddcbcdbf", "hostname": "ONE-M6-DDCBCDBF"} diff --git a/corpus/attachment-insert/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment b/corpus/attachment-insert/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment new file mode 100644 index 0000000000000000000000000000000000000000..8e1f70cf2640fa404f4399f624f84db6853af846 Binary files /dev/null and b/corpus/attachment-insert/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment differ diff --git a/corpus/attachment-insert/cold/read/6762b4d4a7d68d79ade02f878b9c64dbce0b0d4830d7ef6dd36d932339bb8ed3.attachment b/corpus/attachment-insert/cold/read/6762b4d4a7d68d79ade02f878b9c64dbce0b0d4830d7ef6dd36d932339bb8ed3.attachment new file mode 100644 index 0000000000000000000000000000000000000000..3ff7d0c64c0a009d923f16fd1c9f849ce53e7a05 --- /dev/null +++ b/corpus/attachment-insert/cold/read/6762b4d4a7d68d79ade02f878b9c64dbce0b0d4830d7ef6dd36d932339bb8ed3.attachment @@ -0,0 +1 @@ +Snowbound attached this file. diff --git a/corpus/attachment-insert/cold/read/environment.json b/corpus/attachment-insert/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..6b59d517e90eae7f066f471698c20d89d9d8fb54 --- /dev/null +++ b/corpus/attachment-insert/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-DDCBCDBF", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/attachment-insert/cold/read/hierarchy.xml b/corpus/attachment-insert/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..0da73567126737e7cd47d9b9fbf8ce590785e396 --- /dev/null +++ b/corpus/attachment-insert/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/attachment-insert/cold/read/page-000.navigation.json b/corpus/attachment-insert/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..b2cc9630cdb428ace6cdb6faa135abf85f7b4fa0 --- /dev/null +++ b/corpus/attachment-insert/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Before the file - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/attachment-insert/cold/read/page-000.png b/corpus/attachment-insert/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..7e5a633f142496932435f09b5acf57ab1a307177 Binary files /dev/null and b/corpus/attachment-insert/cold/read/page-000.png differ diff --git a/corpus/attachment-insert/cold/read/page-000.xml b/corpus/attachment-insert/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..bc87bc2c24125507706e63d3a7760d8bb117829a --- /dev/null +++ b/corpus/attachment-insert/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/attachment-insert/cold/read/payloads.json b/corpus/attachment-insert/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..4568f4a4595543ea922e7d74630d74fd96a2e45d --- /dev/null +++ b/corpus/attachment-insert/cold/read/payloads.json @@ -0,0 +1,18 @@ +[ + { + "sha256": "6762b4d4a7d68d79ade02f878b9c64dbce0b0d4830d7ef6dd36d932339bb8ed3", + "name": "notes 🦀.txt", + "object": "{0E43FDD7-2BBF-0D36-2F38-3F49329E11AD}{5}{B0}", + "kind": "InsertedFile", + "page": "{7AC920BC-0677-0C27-1640-BEFF46EEC119}{1}{B0}", + "bytes": 30 + }, + { + "sha256": "1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a", + "name": "large.bin", + "object": "{0E43FDD7-2BBF-0D36-2F38-3F49329E11AD}{8}{B0}", + "kind": "InsertedFile", + "page": "{7AC920BC-0677-0C27-1640-BEFF46EEC119}{1}{B0}", + "bytes": 2097152 + } +] \ No newline at end of file diff --git a/corpus/attachment-insert/cold/run.json b/corpus/attachment-insert/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..580ee3a030b830081d0d7a707bedde99b3fb8db6 --- /dev/null +++ b/corpus/attachment-insert/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-attachments/candidate", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/attachment-insert/cold/scripts/cold.ps1 b/corpus/attachment-insert/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/attachment-insert/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/attachment-insert/cold/scripts/read.ps1 b/corpus/attachment-insert/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/attachment-insert/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/attachment-insert/cold/source.json b/corpus/attachment-insert/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..56347bc632c8449999180c6f0354b23beb1342b0 --- /dev/null +++ b/corpus/attachment-insert/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "8e2a1137f47d89425a05053b93f0789be77f219ae2bd4d8bb0bdd1b052a93074", + "mtime_ns": 1790703504369465697 + }, + { + "path": "files.one", + "bytes": 2108416, + "sha256": "3d60c4a8628ec91e1b96c0a4f43391a2e78206ac2d0918dc0c9b3ef8e851c8de", + "mtime_ns": 1790703504369204448 + } +] diff --git a/corpus/attachment-insert/cold/teardown.json b/corpus/attachment-insert/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/attachment-insert/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/attachment-insert/without-icon.png b/corpus/attachment-insert/without-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1893322b982c9602dbbb5e4180385d2222234276 Binary files /dev/null and b/corpus/attachment-insert/without-icon.png differ diff --git a/corpus/custom-tags/README.md b/corpus/custom-tags/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8710cd7036300fc69bfa957e33a63997f74bc96f --- /dev/null +++ b/corpus/custom-tags/README.md @@ -0,0 +1,29 @@ +# Customized note tags + +`candidate/` is the editor's output for `custom_tags_store_what_customize_tags_stores` +in `crates/canvas/tests/custom_tags.rs`: on a section created in Rust, four tags of a +customized list are applied through `Formatting::Tag`, each definition storing the tag's +place in the list as its action type: "Snow check" (Green Star, 61, dark red text on Sky +Blue), "Snow task" (Green Check Box 1, 48, once open and once checked), "Snow circle" +(Blue Circle 2, 31) and "Snow ink" (no symbol, blue text). Definitions carry the +NoteTagPropertyStatus bits OneNote's Customize Tags writes: hasLabel, hasFontColor and +hasHighlightColor where set, hasIcon for a symbol. + +`cold/` is a fresh OneNote 2010 read: the four `TagDef` entries keep their type, symbol, +name and colours, and the tags their completion state. `tools/test_custom_tags.py` +checks this without a VM. Regenerate with `ONESTORE_CUSTOM_TAGS_EXPORT` set to a new +absolute directory while running the test, then cold-open it with +`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`. + +`native/shapes.one` is OneNote 2010's own work in a lab clone: + +- "Shapes 1-72" and "Shapes 73-143" tag a paragraph with every NoteTagShape, set through + COM (its definitions store NoteTagPropertyStatus 0). +- "Custom tags" holds a tag made with New Tag (Snow check, as above), applied at the top + of the list (action type 0), then after moving it below To Do (a second definition, + action type 1), with To Do applied from both places. The UI stores status 15 and 9. +- "Cells" tags a paragraph `cR-C` with the symbol picked from row R, column C of Modify + Tag's symbol gallery, which `crates/snowbound/src/tags.rs` lays out. + +OneNote keeps the tag list in `%APPDATA%\Microsoft\OneNote\14.0\Preferences.dat`, written +when it exits; Snowbound keeps its own in its settings. diff --git a/corpus/custom-tags/candidate/Open Notebook.onetoc2 b/corpus/custom-tags/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..75410ed4dd0e1ab783b9301179bceb39faa0b2b8 Binary files /dev/null and b/corpus/custom-tags/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/custom-tags/candidate/custom-tags.one b/corpus/custom-tags/candidate/custom-tags.one new file mode 100644 index 0000000000000000000000000000000000000000..8fb1985f3c4b0cef7de3974b1a4a5b4d89f46787 Binary files /dev/null and b/corpus/custom-tags/candidate/custom-tags.one differ diff --git a/corpus/custom-tags/cold/commands.jsonl b/corpus/custom-tags/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..39a0043a0ea6cf92b7ee8fbc3a95fc72f54392ba --- /dev/null +++ b/corpus/custom-tags/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-DEFAE25B -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/custom-tags/cold/machine.json b/corpus/custom-tags/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..ed28841e4e3bfa0d812ba6fa3001eea6686eafcc --- /dev/null +++ b/corpus/custom-tags/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-defae25b", "hostname": "ONE-M6-DEFAE25B"} diff --git a/corpus/custom-tags/cold/notebook/Open Notebook.onetoc2 b/corpus/custom-tags/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..9107b84ed5024f65d19534bf110923d5f8f105a6 Binary files /dev/null and b/corpus/custom-tags/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/custom-tags/cold/notebook/custom-tags.one b/corpus/custom-tags/cold/notebook/custom-tags.one new file mode 100644 index 0000000000000000000000000000000000000000..a4df65945f934434be9ba7ba2eaf32e6a0821343 Binary files /dev/null and b/corpus/custom-tags/cold/notebook/custom-tags.one differ diff --git a/corpus/custom-tags/cold/read/environment.json b/corpus/custom-tags/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..3085c200ddf4808428ffb90de50939445964b7de --- /dev/null +++ b/corpus/custom-tags/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-DEFAE25B", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/custom-tags/cold/read/hierarchy.xml b/corpus/custom-tags/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..3a70431bdac77cf400e7b9fc8f6641d05f94b466 --- /dev/null +++ b/corpus/custom-tags/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/custom-tags/cold/read/page-000.navigation.json b/corpus/custom-tags/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..29097c05d706d0c86e38de57994d67f0a12bf136 --- /dev/null +++ b/corpus/custom-tags/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Custom symbol and colours - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/custom-tags/cold/read/page-000.png b/corpus/custom-tags/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..73b2c1c59ed8797e84fa670eb69f1b40e86ac61d Binary files /dev/null and b/corpus/custom-tags/cold/read/page-000.png differ diff --git a/corpus/custom-tags/cold/read/page-000.xml b/corpus/custom-tags/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..a58d4d2c6b165c82c620388c341a349d7f8920e6 --- /dev/null +++ b/corpus/custom-tags/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/custom-tags/cold/read/payloads.json b/corpus/custom-tags/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/custom-tags/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/custom-tags/cold/run.json b/corpus/custom-tags/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..7ac24489b6c6a12af0fa37ff2bb458acf2ed882e --- /dev/null +++ b/corpus/custom-tags/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-custom-tags/candidate", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/custom-tags/cold/scripts/cold.ps1 b/corpus/custom-tags/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/custom-tags/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/custom-tags/cold/scripts/read.ps1 b/corpus/custom-tags/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/custom-tags/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/custom-tags/cold/source.json b/corpus/custom-tags/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..7bc7590ba024c289f9fa3fce0f789406b3db276f --- /dev/null +++ b/corpus/custom-tags/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1612, + "sha256": "2a6009814bda4756e9d474b02433f075adf6071d7cc0650da8502e46519dea1c", + "mtime_ns": 1790704660620140274 + }, + { + "path": "custom-tags.one", + "bytes": 8296, + "sha256": "91b1b0c54326b6e686b467e74fe51e080b8bd748c4d62f328072c3ace2a3c226", + "mtime_ns": 1790704660619982358 + } +] diff --git a/corpus/custom-tags/cold/teardown.json b/corpus/custom-tags/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/custom-tags/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/custom-tags/native/shapes.one b/corpus/custom-tags/native/shapes.one new file mode 100644 index 0000000000000000000000000000000000000000..b1934259ba37839633301110213d244b4b887d44 Binary files /dev/null and b/corpus/custom-tags/native/shapes.one differ diff --git a/corpus/page-background/README.md b/corpus/page-background/README.md index ebf4088f7a77eee2effcad6dea6e302366d59088..8c1d3346f9484bb5156da16935f9b2c97914752b 100644 --- a/corpus/page-background/README.md +++ b/corpus/page-background/README.md @@ -2,12 +2,16 @@ `candidate` is `backgrounds_go_on_and_off_pages_that_have_content` in `crates/snowbound/src/manage.rs` (`SNOWBOUND_BACKGROUND_EXPORT`), on a copy of -`rule-lines/native/notebook`: the Page Color menu's ops give the ruled "Standard" page -Ivy's art (`manage::art_ops`), give "Wide" the art and take it away again, and colour -"SmallGrid" `0x00c8d8e8`, a COLORREF outside OneNote's menu. +`rule-lines/native/notebook`: the Page Color menu's steps through the editor +(`CanvasEditor::set_paper`), each stored as one edit. "Standard" takes Ivy's art; "Wide" +takes it and has it taken away; "College" takes it and undoes; "MediumGrid" takes it, +undoes and redoes; "LargeGrid" takes Ivy, then Tulips in its place, and undoes; "SmallGrid" +is coloured `0x00c8d8e8`, a COLORREF outside OneNote's menu; "VeryLargeGrid" is coloured +Lemon and takes Sparks' art. -`cold` is its fresh OneNote 2010 read: "Standard" keeps its rule lines under one -background image (`backgroundImage="true"`), "Wide" keeps its lines and no image, and -"SmallGrid" reads back `color="#E8D8C8"` and shows it under its grid. `read/page-006.png` -shows OneNote drawing the art over the rule lines. `tools/test_page_background.py` -checks this without a VM. +`cold` is its fresh OneNote 2010 read: every ruled page keeps its lines; "Standard", +"MediumGrid" and "LargeGrid" show one Ivy background image (`backgroundImage="true"`), +"Wide" and "College" none; "SmallGrid" reads back `color="#E8D8C8"` under its grid, and +"VeryLargeGrid" `#FDFDDD` under Sparks. `read/page-003.png` shows OneNote's opaque art +hiding the rule lines, and `read/page-005.png` its white hiding the Lemon page colour. +`tools/test_page_background.py` checks this without a VM. diff --git a/corpus/page-background/candidate/Rules.one b/corpus/page-background/candidate/Rules.one index b3cac5439567e52d91afb41e0aea8bfe756f673d..f5cf24f04c8978e6c017ab599fc405a94d2ac0db 100644 Binary files a/corpus/page-background/candidate/Rules.one and b/corpus/page-background/candidate/Rules.one differ diff --git a/corpus/page-background/cold/commands.jsonl b/corpus/page-background/cold/commands.jsonl index 078755456d9b165dd24331a220e48eef3cdfdbea..68e73b20aceedb4e263f8a939cddf07fe9aa5d0a 100644 --- a/corpus/page-background/cold/commands.jsonl +++ b/corpus/page-background/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-5EF70348 -ExpectedPages 11 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 11 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-0EA92EBC -ExpectedPages 11 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 11 pages.\r\n", "stderr": "", "error": null} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/page-background/cold/machine.json b/corpus/page-background/cold/machine.json index e58b873f2d473eca43130194d68fecdd79deb49b..d27bf23f1daec1f4a13dbb468ddfae0dfc9aef6b 100644 --- a/corpus/page-background/cold/machine.json +++ b/corpus/page-background/cold/machine.json @@ -1 +1 @@ -{"name": "m6-5ef70348", "hostname": "ONE-M6-5EF70348"} +{"name": "m6-0ea92ebc", "hostname": "ONE-M6-0EA92EBC"} diff --git a/corpus/page-background/cold/read/environment.json b/corpus/page-background/cold/read/environment.json index fccbb69c546565860b9d41302c324d6405a6f1fd..f2721d580a8388144caa836b7781533fd2b1fd6a 100644 --- a/corpus/page-background/cold/read/environment.json +++ b/corpus/page-background/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-5EF70348", + "hostname": "ONE-M6-0EA92EBC", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/page-background/cold/read/hierarchy.xml b/corpus/page-background/cold/read/hierarchy.xml index 2480abbaf9351670fbea32ca881eda9ec2b47ce7..121d13d9eb8bfb9c9fa19d7805dd0405878f551d 100644 --- a/corpus/page-background/cold/read/hierarchy.xml +++ b/corpus/page-background/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-background/cold/read/page-000.navigation.json b/corpus/page-background/cold/read/page-000.navigation.json index 7824d62a06fb14853ccd8fca71946931cbe8e9ec..3cda85f0889301dcc34bd7f3046be58554002323 100644 --- a/corpus/page-background/cold/read/page-000.navigation.json +++ b/corpus/page-background/cold/read/page-000.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "None - Microsoft OneNote", + "title": "SmallGrid - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-000.png b/corpus/page-background/cold/read/page-000.png index 6d53a16941a97c24ef8e89388659739401287191..a53ce7f39242c5e8200b518dc04a34136cf30475 100644 Binary files a/corpus/page-background/cold/read/page-000.png and b/corpus/page-background/cold/read/page-000.png differ diff --git a/corpus/page-background/cold/read/page-000.xml b/corpus/page-background/cold/read/page-000.xml index 12cf428804b54db3214d4d440d9a26a08090c293..c13920e172821bda7a9bd874863f2311a42f9683 100644 --- a/corpus/page-background/cold/read/page-000.xml +++ b/corpus/page-background/cold/read/page-000.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-background/cold/read/page-001.navigation.json b/corpus/page-background/cold/read/page-001.navigation.json index 1d051ab18621c4450d424efbbd3056b504248b1b..5c585338bca1397dd69f5878808de906cd5ff0ae 100644 --- a/corpus/page-background/cold/read/page-001.navigation.json +++ b/corpus/page-background/cold/read/page-001.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "College - Microsoft OneNote", + "title": "Hidden - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-001.png b/corpus/page-background/cold/read/page-001.png index 86f49f9425b6b98a8ed88aa8c1675759632059e3..25437d316cbd03ff3c85b33cc4ebe07f1105d9d5 100644 Binary files a/corpus/page-background/cold/read/page-001.png and b/corpus/page-background/cold/read/page-001.png differ diff --git a/corpus/page-background/cold/read/page-001.xml b/corpus/page-background/cold/read/page-001.xml index 3578db1b2139354625912e9473a87ebbed2eff28..dd8b252fedfd711c1df59170e7106729a813c1d7 100644 --- a/corpus/page-background/cold/read/page-001.xml +++ b/corpus/page-background/cold/read/page-001.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-background/cold/read/page-002.navigation.json b/corpus/page-background/cold/read/page-002.navigation.json index 909744712d947f9080464d99bb701cd7de4071c8..6ea1c2e7cfb4aec222874c087cbbaf51b80cb3c7 100644 --- a/corpus/page-background/cold/read/page-002.navigation.json +++ b/corpus/page-background/cold/read/page-002.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "VeryLargeGrid - Microsoft OneNote", + "title": "LargeGrid - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-002.png b/corpus/page-background/cold/read/page-002.png index 75a53d42b8eb63e08cdc6d28336d940273b7e2f4..6747da8bcc5b530c3a932fe3e7ce1ee8720981d5 100644 Binary files a/corpus/page-background/cold/read/page-002.png and b/corpus/page-background/cold/read/page-002.png differ diff --git a/corpus/page-background/cold/read/page-002.xml b/corpus/page-background/cold/read/page-002.xml index 7b7b9f465790ce5737d5f211006ff058494757be..afd45de00c93ebb8d8e1f073af589826f9dcb499 100644 --- a/corpus/page-background/cold/read/page-002.xml +++ b/corpus/page-background/cold/read/page-002.xml @@ -1,2 +1,353 @@  - +/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB +EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL +EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU +FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID +BAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHB +FVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2 +d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna +4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1 +EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ0 +4SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeI +iYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery +8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAOS8f+KbvwbFYamkX2nThL5V3EB8wDfdcH2wRjoc1 +5GYYqpg+SqleN7NfkzGtV9jDnauup0el6nbazp8F7aSCW2nUOjjuK9KlVhXgqkHdM1TUldbHK/Cf +/kW7r/sIXH/odeblf8F/4pfmcGB/hv1Z2leuegFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAB +QAUAFABQAUAFABQBW1HTrbVrGazu4lntpl2PG3QisqtKFaDpzV0xNKSszmNP0uP4X+ENUZLh7y1t +vMuIUkGCgxwme/PfjrXlU6X9l4ao+bmSu1/l95lCEcPTstlf/Mz/AIM3Dt4GM03+ta6mZsjHzFuf +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 diff --git a/corpus/page-background/cold/read/page-003.navigation.json b/corpus/page-background/cold/read/page-003.navigation.json index 7ccafd7fd59c95368f51d575f411c3fb815d3863..74f74505c909f96ff84679b418cd721b7be7459f 100644 --- a/corpus/page-background/cold/read/page-003.navigation.json +++ b/corpus/page-background/cold/read/page-003.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "MediumGrid - Microsoft OneNote", + "title": "Standard - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-003.png b/corpus/page-background/cold/read/page-003.png index 4adfda1220e6a91319553962aa0b842b9c535abe..f5590c79315f620da923111dd269c02a8fd9e42b 100644 Binary files a/corpus/page-background/cold/read/page-003.png and b/corpus/page-background/cold/read/page-003.png differ diff --git a/corpus/page-background/cold/read/page-003.xml b/corpus/page-background/cold/read/page-003.xml index 3544c37d82f5ce4bbe4d70bdc5ac292060eed29b..4afa95c1595214cd2f572acc2051a4d3eb3969e4 100644 --- a/corpus/page-background/cold/read/page-003.xml +++ b/corpus/page-background/cold/read/page-003.xml @@ -1,2 +1,353 @@  - +/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB +EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL +EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU +FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID +BAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHB +FVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2 +d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna +4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1 +EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ0 +4SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeI +iYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery +8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAOS8f+KbvwbFYamkX2nThL5V3EB8wDfdcH2wRjoc1 +5GYYqpg+SqleN7NfkzGtV9jDnauup0el6nbazp8F7aSCW2nUOjjuK9KlVhXgqkHdM1TUldbHK/Cf +/kW7r/sIXH/odeblf8F/4pfmcGB/hv1Z2leuegFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAB +QAUAFABQAUAFABQBW1HTrbVrGazu4lntpl2PG3QisqtKFaDpzV0xNKSszmNP0uP4X+ENUZLh7y1t +vMuIUkGCgxwme/PfjrXlU6X9l4ao+bmSu1/l95lCEcPTstlf/Mz/AIM3Dt4GM03+ta6mZsjHzFuf +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 diff --git a/corpus/page-background/cold/read/page-004.navigation.json b/corpus/page-background/cold/read/page-004.navigation.json index 007050b6d7a6917406f9ab04289d337fab5d8d43..d2a29b6a0602e35842b0605919ee79aa85216a62 100644 --- a/corpus/page-background/cold/read/page-004.navigation.json +++ b/corpus/page-background/cold/read/page-004.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Wide - Microsoft OneNote", + "title": "ColorRed - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-004.png b/corpus/page-background/cold/read/page-004.png index d70d67d33fff88c810540c9cc6048c0da36b0d47..e6c86006d290b84f16ec3ab6ebde2170313d7a3d 100644 Binary files a/corpus/page-background/cold/read/page-004.png and b/corpus/page-background/cold/read/page-004.png differ diff --git a/corpus/page-background/cold/read/page-004.xml b/corpus/page-background/cold/read/page-004.xml index cd5eb9a8d6fd489b97c26548fb5c08c58b3d5e71..1de9b7b4dab480874c9f1bc841871590d95540c7 100644 --- a/corpus/page-background/cold/read/page-004.xml +++ b/corpus/page-background/cold/read/page-004.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-background/cold/read/page-005.navigation.json b/corpus/page-background/cold/read/page-005.navigation.json index 7f25097d7917248dd57c3d10c5466fadaaa84a95..909744712d947f9080464d99bb701cd7de4071c8 100644 --- a/corpus/page-background/cold/read/page-005.navigation.json +++ b/corpus/page-background/cold/read/page-005.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Narrow - Microsoft OneNote", + "title": "VeryLargeGrid - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-005.png b/corpus/page-background/cold/read/page-005.png index 0ff75c9f29678987fa5562d0c36b6d9438c63975..1524b8667172edf877b7149a3338fb2437a967cd 100644 Binary files a/corpus/page-background/cold/read/page-005.png and b/corpus/page-background/cold/read/page-005.png differ diff --git a/corpus/page-background/cold/read/page-005.xml b/corpus/page-background/cold/read/page-005.xml index a50c40d773c0bc67794d26ec2a6f57a39c985c87..3191154b5de39b0109296ef1222a3ab2cd5e88cb 100644 --- a/corpus/page-background/cold/read/page-005.xml +++ b/corpus/page-background/cold/read/page-005.xml @@ -1,2 +1,770 @@  - +/9j/4AAQSkZJRgABAgAAAQABAAD//gAbU25vd2JvdW5kIHRlbXBsYXRlIHNwYXJrc//AABEIAxgC +ZAMBEQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4R +DgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQU +FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAA +AQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgj +QrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpz +dHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX +2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/ +xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEK +FiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SF +hoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo +6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAPLNc/wCSlTf8B/8ARIr8OzL/AJKqfy/9No+4 +w3/IqX9faNivpjzAoAKpAOXpTAWmgCqAVaCWLTQBTAKCWOXpVIQo60wH0AFUgLEC/aIjH/GnzL7j +uK66cfawcOq1X6oyk+R36EaJv3ewzWUI81/ItuxJbw+YxZv9Wgyx/pWlKnzu8tluTOXLot2Ru5kc +se/as5S5m2xpcqsC9KSAWmAUCYVSEOHSqQDl61SAWqJY5asQo600QPq0Aq9asTFqkIeOlUQKtUA8 +DjNaWExaaJH1YCjrTQMdVEjh0q0Sxy1QhR1qgH1SIHDpVoTFXrViHVSAfTICrAKACgAoAKACgAoA +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 diff --git a/corpus/page-background/cold/read/page-006.navigation.json b/corpus/page-background/cold/read/page-006.navigation.json index 74f74505c909f96ff84679b418cd721b7be7459f..7ccafd7fd59c95368f51d575f411c3fb815d3863 100644 --- a/corpus/page-background/cold/read/page-006.navigation.json +++ b/corpus/page-background/cold/read/page-006.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Standard - Microsoft OneNote", + "title": "MediumGrid - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-006.png b/corpus/page-background/cold/read/page-006.png index b529348ff769e5ea373a48ffec8425339c59d681..927fd3db08d6c6da0a7b70bd6bfa9d2780ece922 100644 Binary files a/corpus/page-background/cold/read/page-006.png and b/corpus/page-background/cold/read/page-006.png differ diff --git a/corpus/page-background/cold/read/page-006.xml b/corpus/page-background/cold/read/page-006.xml index 3d7a8ca5f93aef9d28145f04b73701ba123c7855..4b19f1ff1ec8d4fd3b844fa670df4e936bf991cd 100644 --- a/corpus/page-background/cold/read/page-006.xml +++ b/corpus/page-background/cold/read/page-006.xml @@ -1,5 +1,5 @@  -/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB +/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID diff --git a/corpus/page-background/cold/read/page-007.navigation.json b/corpus/page-background/cold/read/page-007.navigation.json index 6ea1c2e7cfb4aec222874c087cbbaf51b80cb3c7..007050b6d7a6917406f9ab04289d337fab5d8d43 100644 --- a/corpus/page-background/cold/read/page-007.navigation.json +++ b/corpus/page-background/cold/read/page-007.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "LargeGrid - Microsoft OneNote", + "title": "Wide - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-007.png b/corpus/page-background/cold/read/page-007.png index 16bcfdc5106ef59235784c52de70b3b2e78111cb..eccce01fe07d242b54e51b56e423d164a3f850de 100644 Binary files a/corpus/page-background/cold/read/page-007.png and b/corpus/page-background/cold/read/page-007.png differ diff --git a/corpus/page-background/cold/read/page-007.xml b/corpus/page-background/cold/read/page-007.xml index f6255c7de82987f898fd5b3afa20bdb00fac5dd9..6e726e368339ce3a0112d573c93f56cefca5cb73 100644 --- a/corpus/page-background/cold/read/page-007.xml +++ b/corpus/page-background/cold/read/page-007.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-background/cold/read/page-008.navigation.json b/corpus/page-background/cold/read/page-008.navigation.json index d2a29b6a0602e35842b0605919ee79aa85216a62..7f25097d7917248dd57c3d10c5466fadaaa84a95 100644 --- a/corpus/page-background/cold/read/page-008.navigation.json +++ b/corpus/page-background/cold/read/page-008.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "ColorRed - Microsoft OneNote", + "title": "Narrow - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-008.png b/corpus/page-background/cold/read/page-008.png index 9b309ee670f3838fcab2d007a7549ba9921aeec7..cf7fecdf42989be69d24ec1e43686d4a2b503111 100644 Binary files a/corpus/page-background/cold/read/page-008.png and b/corpus/page-background/cold/read/page-008.png differ diff --git a/corpus/page-background/cold/read/page-008.xml b/corpus/page-background/cold/read/page-008.xml index 2d6b4f741bc5f03ec29303610f43fbb8d56de2c1..838f862aecd61c5b71e421991e108eb9d6bd51b4 100644 --- a/corpus/page-background/cold/read/page-008.xml +++ b/corpus/page-background/cold/read/page-008.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-background/cold/read/page-009.navigation.json b/corpus/page-background/cold/read/page-009.navigation.json index 3cda85f0889301dcc34bd7f3046be58554002323..7824d62a06fb14853ccd8fca71946931cbe8e9ec 100644 --- a/corpus/page-background/cold/read/page-009.navigation.json +++ b/corpus/page-background/cold/read/page-009.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "SmallGrid - Microsoft OneNote", + "title": "None - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-009.png b/corpus/page-background/cold/read/page-009.png index 07af1f35fa610f3eb4fb07e679391ccc07846f74..616d089022c06aac2ab76f6e26a11302d690f27f 100644 Binary files a/corpus/page-background/cold/read/page-009.png and b/corpus/page-background/cold/read/page-009.png differ diff --git a/corpus/page-background/cold/read/page-009.xml b/corpus/page-background/cold/read/page-009.xml index cdff2258459a79cf71abc3a86be1f028e65dbe19..24ab09edb7d858d5ebe1c36411153bff2723cf38 100644 --- a/corpus/page-background/cold/read/page-009.xml +++ b/corpus/page-background/cold/read/page-009.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-background/cold/read/page-010.navigation.json b/corpus/page-background/cold/read/page-010.navigation.json index 5c585338bca1397dd69f5878808de906cd5ff0ae..1d051ab18621c4450d424efbbd3056b504248b1b 100644 --- a/corpus/page-background/cold/read/page-010.navigation.json +++ b/corpus/page-background/cold/read/page-010.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Hidden - Microsoft OneNote", + "title": "College - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-background/cold/read/page-010.png b/corpus/page-background/cold/read/page-010.png index 756322df669e4169f0910aadc989f3608b11c603..3c21de14d9a31497580f2b669ac74cf3582994e0 100644 Binary files a/corpus/page-background/cold/read/page-010.png and b/corpus/page-background/cold/read/page-010.png differ diff --git a/corpus/page-background/cold/read/page-010.xml b/corpus/page-background/cold/read/page-010.xml index b22513a98ff771e9450f944bcccc1dc1a187e55e..9c2c179ce5a53d907f1ee0844314d30a44d234fb 100644 --- a/corpus/page-background/cold/read/page-010.xml +++ b/corpus/page-background/cold/read/page-010.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-background/cold/run.json b/corpus/page-background/cold/run.json index 2e035ab67362f60cf49d3ae690d4fca77cef7237..2562945997444483cf49dca0f763a80f512ae1ca 100644 --- a/corpus/page-background/cold/run.json +++ b/corpus/page-background/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/snowbound-page-background/candidate", + "notebook": "/private/tmp/snowbound-art-undo/candidate", "expected_pages": 11, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/page-background/cold/source.json b/corpus/page-background/cold/source.json index d05a2062f5d643ca05e92a4d82d237e2bc8e322a..1c2d8c51c21790d966f20a319f5703aba24036b1 100644 --- a/corpus/page-background/cold/source.json +++ b/corpus/page-background/cold/source.json @@ -7,8 +7,8 @@ }, { "path": "Rules.one", - "bytes": 132704, - "sha256": "a513e56274ae794cf558da34dca7189225159d7eb6ab7d2256531fd7a2a55b44", - "mtime_ns": 1790671178905073494 + "bytes": 324016, + "sha256": "53c49a8bd35b4aee2ec840ef79fd7bdb6e6b4bc3cb3885d56cccbac145389a8b", + "mtime_ns": 1790701954004992686 } ] diff --git a/crates/canvas/assets/file.svg b/crates/canvas/assets/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..54e4203f4383cc0fb954ae638ae8331ec119de9e --- /dev/null +++ b/crates/canvas/assets/file.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-down-blue.svg b/crates/canvas/assets/tags/arrow-down-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..21ebf9fdfc55f868d11ea037b993c2c71de789d6 --- /dev/null +++ b/crates/canvas/assets/tags/arrow-down-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-down-green.svg b/crates/canvas/assets/tags/arrow-down-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..e60e9600eef247ea407430f9cb0969522d92d217 --- /dev/null +++ b/crates/canvas/assets/tags/arrow-down-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-down-yellow.svg b/crates/canvas/assets/tags/arrow-down-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..0b6aea7e4139e56b185d2ceee67433ff78522c89 --- /dev/null +++ b/crates/canvas/assets/tags/arrow-down-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-left-blue.svg b/crates/canvas/assets/tags/arrow-left-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..762f595a16abe28964b176ddb10e5a12f463679c --- /dev/null +++ b/crates/canvas/assets/tags/arrow-left-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-left-green.svg b/crates/canvas/assets/tags/arrow-left-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..462e7bdd363067db2805444044aff4f17ce58dc1 --- /dev/null +++ b/crates/canvas/assets/tags/arrow-left-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-left-yellow.svg b/crates/canvas/assets/tags/arrow-left-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..131ba5f6f5a1efb8e2833af502fb32ce3c19f3c8 --- /dev/null +++ b/crates/canvas/assets/tags/arrow-left-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-right-blue.svg b/crates/canvas/assets/tags/arrow-right-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..2ff8c4ed1446a44d7ad073370e865173eb666eca --- /dev/null +++ b/crates/canvas/assets/tags/arrow-right-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-right-green.svg b/crates/canvas/assets/tags/arrow-right-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..a312022654fca268e4e36f0afa1cef4825356154 --- /dev/null +++ b/crates/canvas/assets/tags/arrow-right-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-right-yellow.svg b/crates/canvas/assets/tags/arrow-right-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..5615dd5c417423027d4e17d0672f8b91d5627da9 --- /dev/null +++ b/crates/canvas/assets/tags/arrow-right-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-up-blue.svg b/crates/canvas/assets/tags/arrow-up-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..056f56d291e26dab526d9e931e8622bbdd1f2cfa --- /dev/null +++ b/crates/canvas/assets/tags/arrow-up-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-up-green.svg b/crates/canvas/assets/tags/arrow-up-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..580b5bb37993ea94f4d066b1b8b9ccd82b36063c --- /dev/null +++ b/crates/canvas/assets/tags/arrow-up-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/arrow-up-yellow.svg b/crates/canvas/assets/tags/arrow-up-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..a17874ae0bbc03a025d095d05808cfcff534ad05 --- /dev/null +++ b/crates/canvas/assets/tags/arrow-up-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/bell.svg b/crates/canvas/assets/tags/bell.svg new file mode 100644 index 0000000000000000000000000000000000000000..76e9286f4004e3356ced5df23667252b0ac79742 --- /dev/null +++ b/crates/canvas/assets/tags/bell.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/binoculars.svg b/crates/canvas/assets/tags/binoculars.svg new file mode 100644 index 0000000000000000000000000000000000000000..20f50a22eaeae62aee1cb3f8be69ec24b7284d15 --- /dev/null +++ b/crates/canvas/assets/tags/binoculars.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/calendar.svg b/crates/canvas/assets/tags/calendar.svg new file mode 100644 index 0000000000000000000000000000000000000000..36354895fde58d92f49d3187edf5ea84d0bc4f6e --- /dev/null +++ b/crates/canvas/assets/tags/calendar.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/car.svg b/crates/canvas/assets/tags/car.svg new file mode 100644 index 0000000000000000000000000000000000000000..27bcc80a7b8de061bbbb6d3f3fa32c0075dd343a --- /dev/null +++ b/crates/canvas/assets/tags/car.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/checkbox-green.svg b/crates/canvas/assets/tags/checkbox-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..26a689bc0d346500fb20cddcb55bb7b8cfda1d45 --- /dev/null +++ b/crates/canvas/assets/tags/checkbox-green.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/checkbox-yellow.svg b/crates/canvas/assets/tags/checkbox-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..20d79b4f4cb3c19d23e417529533d4693455260a --- /dev/null +++ b/crates/canvas/assets/tags/checkbox-yellow.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-1-blue.svg b/crates/canvas/assets/tags/circle-1-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b5f63b38e352e7bd311b8fc3e3ce5c33f74cada --- /dev/null +++ b/crates/canvas/assets/tags/circle-1-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-1-green.svg b/crates/canvas/assets/tags/circle-1-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..787846fa423ed588f789494eb0a2b179769d3f21 --- /dev/null +++ b/crates/canvas/assets/tags/circle-1-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-1-yellow.svg b/crates/canvas/assets/tags/circle-1-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..618ff6e4a24caedcf0fae04607636339deafa6cb --- /dev/null +++ b/crates/canvas/assets/tags/circle-1-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-2-blue.svg b/crates/canvas/assets/tags/circle-2-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..9029590157f17586c158aa3fb300e4d15d4c2f8b --- /dev/null +++ b/crates/canvas/assets/tags/circle-2-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-2-green.svg b/crates/canvas/assets/tags/circle-2-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..da31358f66626543353d8a75a019b479450f03a7 --- /dev/null +++ b/crates/canvas/assets/tags/circle-2-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-2-yellow.svg b/crates/canvas/assets/tags/circle-2-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..82022f6fa060589b9200a0f46d2b4aa15eed138b --- /dev/null +++ b/crates/canvas/assets/tags/circle-2-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-3-blue.svg b/crates/canvas/assets/tags/circle-3-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..b691add4bcca2e9061d8aaeb742f3f54e2c34ab3 --- /dev/null +++ b/crates/canvas/assets/tags/circle-3-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-3-green.svg b/crates/canvas/assets/tags/circle-3-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..f46d22e25695ad9de0ff9cde08e8cc03fda5a4ae --- /dev/null +++ b/crates/canvas/assets/tags/circle-3-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-3-yellow.svg b/crates/canvas/assets/tags/circle-3-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..94428e5b10a79357a9f21fcf23678722e4c03a82 --- /dev/null +++ b/crates/canvas/assets/tags/circle-3-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-blue.svg b/crates/canvas/assets/tags/circle-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..1822f9dbf90b0bdfc142f5f13ff2853a540f142b --- /dev/null +++ b/crates/canvas/assets/tags/circle-blue.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-green.svg b/crates/canvas/assets/tags/circle-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..a2a1d72b1efb6ea10eafd4e1802d82fa099b9e9c --- /dev/null +++ b/crates/canvas/assets/tags/circle-green.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/canvas/assets/tags/circle-yellow.svg b/crates/canvas/assets/tags/circle-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..81e821e594b583a1eae9bb6dd4520bf964f5e40a --- /dev/null +++ b/crates/canvas/assets/tags/circle-yellow.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/canvas/assets/tags/clock.svg b/crates/canvas/assets/tags/clock.svg new file mode 100644 index 0000000000000000000000000000000000000000..e879f70949ce28cf5db576c38b40f2fa38950dc7 --- /dev/null +++ b/crates/canvas/assets/tags/clock.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/cloud.svg b/crates/canvas/assets/tags/cloud.svg new file mode 100644 index 0000000000000000000000000000000000000000..6d6aef78d7396e120169a832ad6a41f6d4d9f866 --- /dev/null +++ b/crates/canvas/assets/tags/cloud.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/coins.svg b/crates/canvas/assets/tags/coins.svg new file mode 100644 index 0000000000000000000000000000000000000000..ac816813a2d67a6e244f9458cf1dc91112c46d82 --- /dev/null +++ b/crates/canvas/assets/tags/coins.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/date.svg b/crates/canvas/assets/tags/date.svg new file mode 100644 index 0000000000000000000000000000000000000000..aa402454b3a5e9d7e945265593804998917029d8 --- /dev/null +++ b/crates/canvas/assets/tags/date.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/dollar.svg b/crates/canvas/assets/tags/dollar.svg new file mode 100644 index 0000000000000000000000000000000000000000..04bb22a57765dc7430062ee35af0e81540dcd597 --- /dev/null +++ b/crates/canvas/assets/tags/dollar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/canvas/assets/tags/envelope-open.svg b/crates/canvas/assets/tags/envelope-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..62a0f78f205fb1a90f2e56b4d4c30a372fb1d96c --- /dev/null +++ b/crates/canvas/assets/tags/envelope-open.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/envelope.svg b/crates/canvas/assets/tags/envelope.svg new file mode 100644 index 0000000000000000000000000000000000000000..18fb355234249db23ccf4bcfde30e3842c92f1e3 --- /dev/null +++ b/crates/canvas/assets/tags/envelope.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/canvas/assets/tags/flag-next-week.svg b/crates/canvas/assets/tags/flag-next-week.svg new file mode 100644 index 0000000000000000000000000000000000000000..415b077018170c9c0b568cf02a69516d1c0b734a --- /dev/null +++ b/crates/canvas/assets/tags/flag-next-week.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/flag-no-date.svg b/crates/canvas/assets/tags/flag-no-date.svg new file mode 100644 index 0000000000000000000000000000000000000000..9fb50c1ae76f0c1ee9bf023b8f6ec8c806a4e66f --- /dev/null +++ b/crates/canvas/assets/tags/flag-no-date.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/flag-this-week.svg b/crates/canvas/assets/tags/flag-this-week.svg new file mode 100644 index 0000000000000000000000000000000000000000..578c5141d7ab7bae47284b52a8409f24e4b17f98 --- /dev/null +++ b/crates/canvas/assets/tags/flag-this-week.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/flag-today.svg b/crates/canvas/assets/tags/flag-today.svg new file mode 100644 index 0000000000000000000000000000000000000000..aa324b20abecc9a758fdad577617b21b19dc40dc --- /dev/null +++ b/crates/canvas/assets/tags/flag-today.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/flag-tomorrow.svg b/crates/canvas/assets/tags/flag-tomorrow.svg new file mode 100644 index 0000000000000000000000000000000000000000..3fc2e35252210e4f14a08e678c258ce8469edb28 --- /dev/null +++ b/crates/canvas/assets/tags/flag-tomorrow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/follow-up.svg b/crates/canvas/assets/tags/follow-up.svg new file mode 100644 index 0000000000000000000000000000000000000000..54c74592d54526a1159e1e8a1786368a14aa214a --- /dev/null +++ b/crates/canvas/assets/tags/follow-up.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/frown.svg b/crates/canvas/assets/tags/frown.svg new file mode 100644 index 0000000000000000000000000000000000000000..938ef19cddfd3282a5e04885df992d1a73d1dea2 --- /dev/null +++ b/crates/canvas/assets/tags/frown.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/globe.svg b/crates/canvas/assets/tags/globe.svg new file mode 100644 index 0000000000000000000000000000000000000000..619c313e389591c96a5839c32a6626260c4afeeb --- /dev/null +++ b/crates/canvas/assets/tags/globe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/green-square.svg b/crates/canvas/assets/tags/green-square.svg new file mode 100644 index 0000000000000000000000000000000000000000..694168a18dee92bff3696962e5f4d61b603183cf --- /dev/null +++ b/crates/canvas/assets/tags/green-square.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/heart.svg b/crates/canvas/assets/tags/heart.svg new file mode 100644 index 0000000000000000000000000000000000000000..6ae8f81901b7a2bed80eccf7dc7850c238c6ff5a --- /dev/null +++ b/crates/canvas/assets/tags/heart.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/canvas/assets/tags/im-contact.svg b/crates/canvas/assets/tags/im-contact.svg new file mode 100644 index 0000000000000000000000000000000000000000..937f79464b9e3f31c7651074ba82f63ec71dba15 --- /dev/null +++ b/crates/canvas/assets/tags/im-contact.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/canvas/assets/tags/key.svg b/crates/canvas/assets/tags/key.svg new file mode 100644 index 0000000000000000000000000000000000000000..d2fa291a4d63eacf3e3061caf5f0fb189fda362a --- /dev/null +++ b/crates/canvas/assets/tags/key.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/canvas/assets/tags/laptop.svg b/crates/canvas/assets/tags/laptop.svg new file mode 100644 index 0000000000000000000000000000000000000000..016059a8a2f969e43639baa85272009009c0f6eb --- /dev/null +++ b/crates/canvas/assets/tags/laptop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/canvas/assets/tags/lightning.svg b/crates/canvas/assets/tags/lightning.svg new file mode 100644 index 0000000000000000000000000000000000000000..f55218d3e77540cae3386db244f19c78ed62e564 --- /dev/null +++ b/crates/canvas/assets/tags/lightning.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-arrow-blue.svg b/crates/canvas/assets/tags/mark-arrow-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..97c2c3c44577ef5dad304ca3244098182964baa4 --- /dev/null +++ b/crates/canvas/assets/tags/mark-arrow-blue.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/mark-arrow-green.svg b/crates/canvas/assets/tags/mark-arrow-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..512ac40f957d091737b171cb1fc8ca4ea9d435d5 --- /dev/null +++ b/crates/canvas/assets/tags/mark-arrow-green.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/mark-arrow-yellow.svg b/crates/canvas/assets/tags/mark-arrow-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..34389c3c7493b5cc263937bac07d4e3e4a0372cc --- /dev/null +++ b/crates/canvas/assets/tags/mark-arrow-yellow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/mark-arrow.svg b/crates/canvas/assets/tags/mark-arrow.svg deleted file mode 100644 index 97c2c3c44577ef5dad304ca3244098182964baa4..0000000000000000000000000000000000000000 --- a/crates/canvas/assets/tags/mark-arrow.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/crates/canvas/assets/tags/mark-client.svg b/crates/canvas/assets/tags/mark-client.svg deleted file mode 100644 index b562e9082ec9bf2d0e6f6dad91a46658025f3f36..0000000000000000000000000000000000000000 --- a/crates/canvas/assets/tags/mark-client.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/crates/canvas/assets/tags/mark-exclamation-blue.svg b/crates/canvas/assets/tags/mark-exclamation-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..de951416c56df76d3df63727c07414ea48fb5bab --- /dev/null +++ b/crates/canvas/assets/tags/mark-exclamation-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-exclamation-green.svg b/crates/canvas/assets/tags/mark-exclamation-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..67a7f3799ecb6aea7aefa3536d92e6e5c9cba7f9 --- /dev/null +++ b/crates/canvas/assets/tags/mark-exclamation-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-exclamation-yellow.svg b/crates/canvas/assets/tags/mark-exclamation-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..b562e9082ec9bf2d0e6f6dad91a46658025f3f36 --- /dev/null +++ b/crates/canvas/assets/tags/mark-exclamation-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-flag-blue.svg b/crates/canvas/assets/tags/mark-flag-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..878943aa2791d75a33791f1ab3d7fe0daf4b7e90 --- /dev/null +++ b/crates/canvas/assets/tags/mark-flag-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-flag-green.svg b/crates/canvas/assets/tags/mark-flag-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..fa9780a1eb2742cfc2cc73232cfbc3dd75f41235 --- /dev/null +++ b/crates/canvas/assets/tags/mark-flag-green.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-flag-yellow.svg b/crates/canvas/assets/tags/mark-flag-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..7700e494609f2c94eb96cddbfcaa1dcaf960f9b8 --- /dev/null +++ b/crates/canvas/assets/tags/mark-flag-yellow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-manager.svg b/crates/canvas/assets/tags/mark-manager.svg deleted file mode 100644 index c7b1b3e74d3f5454151a4bd08edcabb2f54a4dee..0000000000000000000000000000000000000000 --- a/crates/canvas/assets/tags/mark-manager.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/crates/canvas/assets/tags/mark-one-blue.svg b/crates/canvas/assets/tags/mark-one-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..f95d8aa3643bd45b9b71e6c9d4756543526d9c00 --- /dev/null +++ b/crates/canvas/assets/tags/mark-one-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-one-green.svg b/crates/canvas/assets/tags/mark-one-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..e0f1a75f255736d27b2f38c20733cb0deabc2997 --- /dev/null +++ b/crates/canvas/assets/tags/mark-one-green.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-one-yellow.svg b/crates/canvas/assets/tags/mark-one-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..218834e755712a51e2d812bded96a093729f0cbe --- /dev/null +++ b/crates/canvas/assets/tags/mark-one-yellow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-one.svg b/crates/canvas/assets/tags/mark-one.svg deleted file mode 100644 index f95d8aa3643bd45b9b71e6c9d4756543526d9c00..0000000000000000000000000000000000000000 --- a/crates/canvas/assets/tags/mark-one.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/crates/canvas/assets/tags/mark-person-blue.svg b/crates/canvas/assets/tags/mark-person-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..1f2d6f0affa1495cf724a9825b126c6d175e5a81 --- /dev/null +++ b/crates/canvas/assets/tags/mark-person-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-person-green.svg b/crates/canvas/assets/tags/mark-person-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..e57d0341831f043771649d26b2d00876f0ae7ada --- /dev/null +++ b/crates/canvas/assets/tags/mark-person-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-person-yellow.svg b/crates/canvas/assets/tags/mark-person-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..c7b1b3e74d3f5454151a4bd08edcabb2f54a4dee --- /dev/null +++ b/crates/canvas/assets/tags/mark-person-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-person.svg b/crates/canvas/assets/tags/mark-person.svg deleted file mode 100644 index 1f2d6f0affa1495cf724a9825b126c6d175e5a81..0000000000000000000000000000000000000000 --- a/crates/canvas/assets/tags/mark-person.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/crates/canvas/assets/tags/mark-star-blue.svg b/crates/canvas/assets/tags/mark-star-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..64fc936e96b2dca0a40fc90e64909cbccd1b376c --- /dev/null +++ b/crates/canvas/assets/tags/mark-star-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-star-green.svg b/crates/canvas/assets/tags/mark-star-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..ab4796db3423d428c90af8be4b51038af88a5119 --- /dev/null +++ b/crates/canvas/assets/tags/mark-star-green.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-star-yellow.svg b/crates/canvas/assets/tags/mark-star-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..bd1f6c89d96aff646618f66d240d73b103f46cf3 --- /dev/null +++ b/crates/canvas/assets/tags/mark-star-yellow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-three-blue.svg b/crates/canvas/assets/tags/mark-three-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..0e8fb6b92842fa4224379bf207198bafbba11154 --- /dev/null +++ b/crates/canvas/assets/tags/mark-three-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-three-green.svg b/crates/canvas/assets/tags/mark-three-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..cb2c99e7a2d95c066a73922b046b00f0b6a53861 --- /dev/null +++ b/crates/canvas/assets/tags/mark-three-green.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-three-yellow.svg b/crates/canvas/assets/tags/mark-three-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..a742086017b7bfab0a90e6fc57b97b065f9e4bab --- /dev/null +++ b/crates/canvas/assets/tags/mark-three-yellow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-two-blue.svg b/crates/canvas/assets/tags/mark-two-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..40e030cafc219eb29bd7ee96e2208fa36dc01f32 --- /dev/null +++ b/crates/canvas/assets/tags/mark-two-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-two-green.svg b/crates/canvas/assets/tags/mark-two-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..a7484b31afe5f08b34e55e527546ded2bb5e8620 --- /dev/null +++ b/crates/canvas/assets/tags/mark-two-green.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-two-yellow.svg b/crates/canvas/assets/tags/mark-two-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..43c86f79bd98b9127b769d353f8089e1f89d1294 --- /dev/null +++ b/crates/canvas/assets/tags/mark-two-yellow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-two.svg b/crates/canvas/assets/tags/mark-two.svg deleted file mode 100644 index 43c86f79bd98b9127b769d353f8089e1f89d1294..0000000000000000000000000000000000000000 --- a/crates/canvas/assets/tags/mark-two.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/crates/canvas/assets/tags/mobile.svg b/crates/canvas/assets/tags/mobile.svg new file mode 100644 index 0000000000000000000000000000000000000000..c613fa51fd269089eff49101be045f3d93dfbc12 --- /dev/null +++ b/crates/canvas/assets/tags/mobile.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/notebook.svg b/crates/canvas/assets/tags/notebook.svg new file mode 100644 index 0000000000000000000000000000000000000000..08b1839da0ef41052b3734ea83b655021e686807 --- /dev/null +++ b/crates/canvas/assets/tags/notebook.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/orange-square.svg b/crates/canvas/assets/tags/orange-square.svg new file mode 100644 index 0000000000000000000000000000000000000000..60db6dbbd4dd9a3a35be73964ebd8d2f507b027e --- /dev/null +++ b/crates/canvas/assets/tags/orange-square.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/paper.svg b/crates/canvas/assets/tags/paper.svg new file mode 100644 index 0000000000000000000000000000000000000000..b016c869026b35e9f6d0700b7f2b2c8e4a072f87 --- /dev/null +++ b/crates/canvas/assets/tags/paper.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/paperclip.svg b/crates/canvas/assets/tags/paperclip.svg new file mode 100644 index 0000000000000000000000000000000000000000..e7868682171f3886824eadefff846e0101e3fc1e --- /dev/null +++ b/crates/canvas/assets/tags/paperclip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/canvas/assets/tags/people.svg b/crates/canvas/assets/tags/people.svg new file mode 100644 index 0000000000000000000000000000000000000000..9e05bbba8504a877d66ce3f1abde285ec779ef81 --- /dev/null +++ b/crates/canvas/assets/tags/people.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/person.svg b/crates/canvas/assets/tags/person.svg new file mode 100644 index 0000000000000000000000000000000000000000..c96629370d822862db07e8d2527d6299d38a305b --- /dev/null +++ b/crates/canvas/assets/tags/person.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/phone-clock.svg b/crates/canvas/assets/tags/phone-clock.svg new file mode 100644 index 0000000000000000000000000000000000000000..509dbb63c83db7f1fc8a4a7c646470959ae97387 --- /dev/null +++ b/crates/canvas/assets/tags/phone-clock.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/pink-square.svg b/crates/canvas/assets/tags/pink-square.svg new file mode 100644 index 0000000000000000000000000000000000000000..a31efcd9104e243fef08a9695d78bb2c081c47fe --- /dev/null +++ b/crates/canvas/assets/tags/pink-square.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/plane.svg b/crates/canvas/assets/tags/plane.svg new file mode 100644 index 0000000000000000000000000000000000000000..b73cf45d1d3fff97abbf4786cf0ff11d97d620da --- /dev/null +++ b/crates/canvas/assets/tags/plane.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/presentation.svg b/crates/canvas/assets/tags/presentation.svg new file mode 100644 index 0000000000000000000000000000000000000000..3fa77747b6e49935491d701df0f5c069913ae078 --- /dev/null +++ b/crates/canvas/assets/tags/presentation.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/pushpin.svg b/crates/canvas/assets/tags/pushpin.svg new file mode 100644 index 0000000000000000000000000000000000000000..285123b9ef713c597c843f1adaba032a68be6df3 --- /dev/null +++ b/crates/canvas/assets/tags/pushpin.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/canvas/assets/tags/question-balloon.svg b/crates/canvas/assets/tags/question-balloon.svg new file mode 100644 index 0000000000000000000000000000000000000000..afaf82230a868f0569bcedc270787cae7d20abbb --- /dev/null +++ b/crates/canvas/assets/tags/question-balloon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/quote.svg b/crates/canvas/assets/tags/quote.svg new file mode 100644 index 0000000000000000000000000000000000000000..56fea2e58a6d3ed557ea9b408c7e9f142424ee72 --- /dev/null +++ b/crates/canvas/assets/tags/quote.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/research.svg b/crates/canvas/assets/tags/research.svg new file mode 100644 index 0000000000000000000000000000000000000000..c95b06b8973e3ddb50e11c9025d801924bc4c53e --- /dev/null +++ b/crates/canvas/assets/tags/research.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/ribbon.svg b/crates/canvas/assets/tags/ribbon.svg new file mode 100644 index 0000000000000000000000000000000000000000..c91860ebb44eeb00ca5db6a26d9e393e36b31a9d --- /dev/null +++ b/crates/canvas/assets/tags/ribbon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/rose.svg b/crates/canvas/assets/tags/rose.svg new file mode 100644 index 0000000000000000000000000000000000000000..d263560e8b56c6361a736164afa497512750ca82 --- /dev/null +++ b/crates/canvas/assets/tags/rose.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/canvas/assets/tags/schedule.svg b/crates/canvas/assets/tags/schedule.svg new file mode 100644 index 0000000000000000000000000000000000000000..4f23380360a7af6dc31997c1d6df597ed1de3da8 --- /dev/null +++ b/crates/canvas/assets/tags/schedule.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/smiley.svg b/crates/canvas/assets/tags/smiley.svg new file mode 100644 index 0000000000000000000000000000000000000000..db13981ee4266c3f8b0ba4513e02985cb8786005 --- /dev/null +++ b/crates/canvas/assets/tags/smiley.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/solid-target-blue.svg b/crates/canvas/assets/tags/solid-target-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..483b645f5d773aa58fbf537f8d1ac58a974cc93c --- /dev/null +++ b/crates/canvas/assets/tags/solid-target-blue.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/solid-target-green.svg b/crates/canvas/assets/tags/solid-target-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..4ab536a38bbf22a5016d62d6c1b3408d61aadaaa --- /dev/null +++ b/crates/canvas/assets/tags/solid-target-green.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/solid-target-yellow.svg b/crates/canvas/assets/tags/solid-target-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..84534fa350815c8911fdb133ab190c228f81441c --- /dev/null +++ b/crates/canvas/assets/tags/solid-target-yellow.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/star-blue.svg b/crates/canvas/assets/tags/star-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..513607f50d639f609ec719cbc3e0dc8233e9bc53 --- /dev/null +++ b/crates/canvas/assets/tags/star-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/star-green.svg b/crates/canvas/assets/tags/star-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..4c9e878d5745479b09d166449125509a9926069d --- /dev/null +++ b/crates/canvas/assets/tags/star-green.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/star8-blue.svg b/crates/canvas/assets/tags/star8-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..fda1bd0f93ec7aef62ebfc48b21e237985a873c9 --- /dev/null +++ b/crates/canvas/assets/tags/star8-blue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/canvas/assets/tags/star8-yellow.svg b/crates/canvas/assets/tags/star8-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..c8c440467616655d5a0b5ac0cbc2c7b4587107cb --- /dev/null +++ b/crates/canvas/assets/tags/star8-yellow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/canvas/assets/tags/sun-blue.svg b/crates/canvas/assets/tags/sun-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..0c3e2e94d26241019449993c848617ee42577d3a --- /dev/null +++ b/crates/canvas/assets/tags/sun-blue.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/sun-green.svg b/crates/canvas/assets/tags/sun-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..fb3e37f0564e52e7d2884cd4fbb7445f5f29a06d --- /dev/null +++ b/crates/canvas/assets/tags/sun-green.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/sun-yellow.svg b/crates/canvas/assets/tags/sun-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..b0975d0c2f9265bb03dd940c99a2247c836d1d52 --- /dev/null +++ b/crates/canvas/assets/tags/sun-yellow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/sunflower.svg b/crates/canvas/assets/tags/sunflower.svg new file mode 100644 index 0000000000000000000000000000000000000000..1ec8e06903b1af3b555e390c5b8a1db89d314f4e --- /dev/null +++ b/crates/canvas/assets/tags/sunflower.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/target-blue.svg b/crates/canvas/assets/tags/target-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..2dc83f4fe091a2488df5758040fd37ea3aba26fa --- /dev/null +++ b/crates/canvas/assets/tags/target-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/target-green.svg b/crates/canvas/assets/tags/target-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..965dac80be74e5d50ecccdb55a1ed1d8a6383953 --- /dev/null +++ b/crates/canvas/assets/tags/target-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/target-yellow.svg b/crates/canvas/assets/tags/target-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..52450cfb864b904145230d3338f3ec3f0745d764 --- /dev/null +++ b/crates/canvas/assets/tags/target-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/tick-blue.svg b/crates/canvas/assets/tags/tick-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..01763e7be7b4b0fcd05643d1d669c5e1ed8730ba --- /dev/null +++ b/crates/canvas/assets/tags/tick-blue.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/tick-green.svg b/crates/canvas/assets/tags/tick-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..830da86e4e985132f6221b907154abe7f7cc1d7a --- /dev/null +++ b/crates/canvas/assets/tags/tick-green.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/tick-yellow.svg b/crates/canvas/assets/tags/tick-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..a9b64efdb4c4c365940e90dc6565a566a7d26c22 --- /dev/null +++ b/crates/canvas/assets/tags/tick-yellow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/triangle-blue.svg b/crates/canvas/assets/tags/triangle-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..4f3ca3f1e8e290eb62e105a2f66edd3e73d6446f --- /dev/null +++ b/crates/canvas/assets/tags/triangle-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/triangle-green.svg b/crates/canvas/assets/tags/triangle-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..9b123bcd0223c15b4f4905d7aa41a4e2d4ac50d4 --- /dev/null +++ b/crates/canvas/assets/tags/triangle-green.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/triangle-yellow.svg b/crates/canvas/assets/tags/triangle-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..5ef6040830c8931b762a1d4558a1acdd172b1064 --- /dev/null +++ b/crates/canvas/assets/tags/triangle-yellow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/umbrella-blue.svg b/crates/canvas/assets/tags/umbrella-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..f7ff89e7a54ed5ff03b89353109cfda2017b3912 --- /dev/null +++ b/crates/canvas/assets/tags/umbrella-blue.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/umbrella-green.svg b/crates/canvas/assets/tags/umbrella-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..f366e4f8662cc84442a7ae2a80181581229ee945 --- /dev/null +++ b/crates/canvas/assets/tags/umbrella-green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/umbrella-yellow.svg b/crates/canvas/assets/tags/umbrella-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..9af9dab87f409b9391729eb0a4e4fda0316cbb34 --- /dev/null +++ b/crates/canvas/assets/tags/umbrella-yellow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/x-blue.svg b/crates/canvas/assets/tags/x-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..6a9a21b9ecef7dc8c9b78776024ca88f6a67e1ef --- /dev/null +++ b/crates/canvas/assets/tags/x-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/canvas/assets/tags/x-dots-blue.svg b/crates/canvas/assets/tags/x-dots-blue.svg new file mode 100644 index 0000000000000000000000000000000000000000..2c07fb31412e963d69f966ac5fab32bb0605f8a7 --- /dev/null +++ b/crates/canvas/assets/tags/x-dots-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/x-dots-green.svg b/crates/canvas/assets/tags/x-dots-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..f8218cbe74fd363fc907cba32eb6aad2ebcef64c --- /dev/null +++ b/crates/canvas/assets/tags/x-dots-green.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/x-dots-yellow.svg b/crates/canvas/assets/tags/x-dots-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..152672099416b4b9eef079a1fa416a5d56bfe563 --- /dev/null +++ b/crates/canvas/assets/tags/x-dots-yellow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/x-green.svg b/crates/canvas/assets/tags/x-green.svg new file mode 100644 index 0000000000000000000000000000000000000000..596ebd0ab372806bb08206cf43028f0b2e39c9ff --- /dev/null +++ b/crates/canvas/assets/tags/x-green.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/canvas/assets/tags/x-yellow.svg b/crates/canvas/assets/tags/x-yellow.svg new file mode 100644 index 0000000000000000000000000000000000000000..9477943df1501075574c17aa39060b06346efa6b --- /dev/null +++ b/crates/canvas/assets/tags/x-yellow.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/canvas/src/bin/page_probe.rs b/crates/canvas/src/bin/page_probe.rs index f3fcad3abe3417881eb3de561e44957037526a05..fe226164b10660e47ea37c2d0f52eb5dde704362 100644 --- a/crates/canvas/src/bin/page_probe.rs +++ b/crates/canvas/src/bin/page_probe.rs @@ -65,6 +65,9 @@ fn main() -> Result<(), Box> { PageObject::Image(image) => objects.push(json!({"kind": "image", "id": image.id, "layout": image.layout, "bytes": image.bytes.as_ref().map(|b| b.len()), "alt": image.alt, "background": image.background})), + PageObject::Attachment(file) => objects.push(json!({"kind": "file", "id": file.id, + "layout": file.layout, "filename": file.filename, + "bytes": file.bytes.as_ref().map(|b| b.len())})), PageObject::Ink(ink) => objects.push(json!({"kind": "ink", "id": ink.id, "bounds": ink.bounds(), "strokes": ink.strokes.len(), "groups": ink.groups.len()})), PageObject::Unsupported(object) => objects.push(json!({"kind": "unsupported", diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index eb296741af44f2ac8598b4089186579b11ed943d..33f408bb3aaa50fc812d364d98ca855e0c238248 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -117,6 +117,9 @@ pub struct CanvasEditor { /// What Select All widened to, holding until the selection is moved or set, and while the /// focused outline and its selection stay as they were then. whole: Option<(Whole, ExGuid, Selection)>, + /// The outline, caret and undo depth a run of typing left, which typing there joins as + /// OneNote 2010 does until the caret moves or another kind of edit comes between. + typing: Option<(ExGuid, TextPosition, usize)>, } /// Imported page state the editable content does not carry. @@ -804,6 +807,12 @@ enum History { index: usize, image: Option>, }, + /// Restores the file on the page at `index` in paint order, or removes the one there + /// when `None`. + File { + index: usize, + file: Option>, + }, Remove { outline: onestore::ExGuid, focus: RestoreFocus, @@ -860,6 +869,7 @@ impl CanvasEditor { ops: Ok(Vec::new()), stored: None, whole: None, + typing: None, }) } @@ -877,6 +887,7 @@ impl CanvasEditor { .iter() .filter_map(|object| match object { page::Content::ReadOnly(object) => Some(object.rect()[2]), + page::Content::File { .. } => object.file().map(|(_, rect)| rect[2]), page::Content::Ink(ink) => page::ink_bounds(ink).map(|b| b[2]), _ => None, }) @@ -1047,7 +1058,9 @@ impl CanvasEditor { let reaches_one = |history: &History| match history { History::Date(_) => date_changed, History::Paper { .. } => false, - History::Image { .. } | History::Picture { .. } => objects_changed, + History::Image { .. } | History::Picture { .. } | History::File { .. } => { + objects_changed + } History::Draft { outlines, .. } => changed.contains(&outlines[0].id), History::Text { outline, change } => { changed.contains(outline) @@ -1070,10 +1083,15 @@ impl CanvasEditor { History::Group { entries, .. } => entries.iter().any(reaches_one), history => reaches_one(history), }; + // A typing run goes on while the change leaves its undo step alone. + let run = self + .typing + .filter(|_| self.undo.last().is_some_and(|last| !reaches(last))); fresh.undo = std::mem::take(&mut self.undo) .into_iter() .filter(|history| !reaches(history)) .collect(); + fresh.typing = run.map(|(outline, caret, _)| (outline, caret, fresh.undo.len())); fresh.redo = std::mem::take(&mut self.redo) .into_iter() .filter(|history| !reaches(history)) @@ -1108,6 +1126,10 @@ impl CanvasEditor { objects.push(PageObject::Image(image.clone())); continue; } + page::Content::File { source, .. } => { + objects.push(PageObject::Attachment(source.clone())); + continue; + } page::Content::Ink(ink) => { objects.push(PageObject::Ink(ink.clone())); continue; @@ -1423,6 +1445,7 @@ impl CanvasEditor { ops: Ok(Vec::new()), stored: None, whole: None, + typing: None, }) } @@ -1455,23 +1478,99 @@ impl CanvasEditor { Ok(true) } - /// Gives the page `color` (View, Page Color) and `rule_lines` (View, Rule Lines) as one - /// undo step; false when it had them. + /// Gives the page `color` (View, Page Color), `rule_lines` (View, Rule Lines) and, when + /// given, `art` in place of its background pictures, as one undo step; false when it had + /// them. pub fn set_paper( &mut self, color: Option, rule_lines: Option, + art: Option>, ) -> bool { - if (color, rule_lines) == (self.header.color, self.header.rule_lines) { + let backgrounds: Vec = (0..self.objects.len()) + .filter(|&index| { + self.objects[index] + .picture() + .is_some_and(|image| image.background) + }) + .collect(); + let art = art.filter(|art| { + art.len() != backgrounds.len() + || art.iter().zip(&backgrounds).any(|(new, &index)| { + let shown = self.objects[index].picture().unwrap(); + (&new.layout, new.size, &new.bytes) != (&shown.layout, shown.size, &shown.bytes) + }) + }); + let paper = (color, rule_lines) != (self.header.color, self.header.rule_lines); + if !paper && art.is_none() { return false; } self.finish_composition(); - let before = self.paper(color, rule_lines); - self.undo.push(before); + let mut entries = Vec::new(); + if paper { + entries.push(self.paper(color, rule_lines)); + } + if let Some(art) = art { + for index in backgrounds.into_iter().rev() { + let image = self.remove_picture(index); + entries.push(History::Picture { + index, + image: Some(Box::new(image)), + }); + } + // Art lies under everything else on the page. + for image in art.into_iter().rev() { + self.add_picture(0, image); + entries.push(History::Picture { + index: 0, + image: None, + }); + } + } + self.undo.push(match entries.len() { + 1 => entries.pop().unwrap(), + _ => History::Group { + entries, + page: false, + }, + }); self.redo.clear(); true } + /// Puts page-level picture `image` at `index` in paint order, recording its op. + fn add_picture(&mut self, index: usize, image: onestore::page::Image) { + let object = PageObject::Image(image.clone()); + self.objects.insert(index, page::Content::Image(image)); + let before = self.successor(object.id()); + self.record(Ok(vec![PageOp::Add { object, before }])); + } + + /// Takes the page-level picture at `index` in paint order off the page, recording its op. + fn remove_picture(&mut self, index: usize) -> onestore::page::Image { + let image = self.objects[index].picture().unwrap().clone(); + self.objects.remove(index); + self.record(Ok(vec![PageOp::Delete { object: image.id }])); + image + } + + /// Puts a file on the page, `file` being its `Content::File`, at `index` in paint order, + /// recording its op. + fn add_file(&mut self, index: usize, file: page::Content) { + let object = PageObject::Attachment(file.file().unwrap().0.clone()); + self.objects.insert(index, file); + let before = self.successor(object.id()); + self.record(Ok(vec![PageOp::Add { object, before }])); + } + + /// Takes the file at `index` in paint order off the page, recording its op. + fn remove_file(&mut self, index: usize) -> page::Content { + let file = self.objects.remove(index); + let object = file.file().unwrap().0.id; + self.record(Ok(vec![PageOp::Delete { object }])); + file + } + /// Shows `color` and `rule_lines`, recording the ops that store them; the history entry /// restoring what the page had. fn paper( @@ -1754,9 +1853,12 @@ impl CanvasEditor { Ok(()) } - /// Origin and size of a picture the user can select; OneNote passes clicks through - /// backgrounds. + /// Origin and size of a picture or file on the page the user can select; OneNote passes + /// clicks through backgrounds. pub fn image_placement(&self, id: ExGuid) -> Option<([f32; 2], [f32; 2])> { + if let Some((_, [x0, y0, x1, y1])) = self.page_file(id) { + return Some(([x0, y0], [x1 - x0, y1 - y0])); + } if let Some(image) = self.image(id) { let layout = &image.layout; return Some(( @@ -1776,20 +1878,50 @@ impl CanvasEditor { self.outline_picture(id).is_some() } - /// A picture held as a paragraph: its outline, the paragraph's container and index there, - /// and the paragraph. + /// A picture or file held as a paragraph: its outline, the paragraph's container and index + /// there, and the paragraph. fn outline_picture( &self, id: ExGuid, ) -> Option<(ExGuid, Option, usize, &PageParagraph)> { self.outlines.iter().find_map(|outline| { descendants(outline.document.nodes(), None).find_map(|(container, index, node)| { - matches!(&node.content, onestore::page::ParagraphContent::Image(image) if image.id == id) - .then_some((outline.id, container, index, node)) + holds(&node.content, id).then_some((outline.id, container, index, node)) }) }) } + /// The file a paragraph holds or the page shows, which the host opens and saves. + pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> { + if let Some((file, _)) = self.page_file(id) { + return Some(file); + } + match &self.outline_picture(id)?.3.content { + ParagraphContent::Attachment(file) => Some(file), + _ => None, + } + } + + /// A file on the page and where its column lies, in page coordinates. + fn page_file(&self, id: ExGuid) -> Option<(&onestore::page::Attachment, [f32; 4])> { + self.objects + .iter() + .filter_map(page::Content::file) + .find(|(file, _)| file.id == id) + } + + /// Where a file draws with its name, in page coordinates. + pub fn attachment_rect(&self, id: ExGuid) -> Option<[f32; 4]> { + if let Some((_, rect)) = self.page_file(id) { + return Some(rect); + } + let (outline, ..) = self.outline_picture(id)?; + let outline = self.outlines.iter().find(|item| item.id == outline)?; + let [x0, y0, x1, y1] = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds(); + let [x, y] = outline.origin(); + Some([x + x0, y + y0, x + x1, y + y1]) + } + /// Replaces a picture paragraph through the outline's text history. fn edit_outline_picture( &mut self, @@ -1827,8 +1959,8 @@ impl CanvasEditor { }) } - /// Moves a picture, and resizes it when `size` differs from the stored size. A picture in - /// an outline's flow keeps its place. + /// Moves a picture or a file on the page, and resizes a picture when `size` differs from + /// the stored size. A picture in an outline's flow keeps its place. pub fn place_image( &mut self, engine: &mut TextEngine, @@ -1844,7 +1976,7 @@ impl CanvasEditor { if let Some((.., node)) = self.outline_picture(id) { let mut node = node.clone(); let onestore::page::ParagraphContent::Image(image) = &mut node.content else { - unreachable!() + return Ok(()); }; if crate::outline::image_size(image) == Some(size) { return Ok(()); @@ -1853,6 +1985,25 @@ impl CanvasEditor { image.layout.width_set_by_user = Some(true); return self.edit_outline_picture(engine, id, Some(node)); } + if let Some((file, _)) = self.page_file(id) { + let previous = [file.layout.x, file.layout.y]; + if previous == origin.map(Some) { + return Ok(()); + } + self.finish_composition(); + let layout = self.object_layout_mut(id).unwrap(); + [layout.x, layout.y] = origin.map(Some); + self.undo.push(History::Position { + object: id, + position: previous, + }); + self.redo.clear(); + self.record(self.placement_ops(&Placement { + id, + position: previous, + })); + return Ok(()); + } let image = self.image(id).ok_or(EditError::InvalidRange)?; let mut layout = image.layout.clone(); [layout.x, layout.y] = origin.map(Some); @@ -1897,9 +2048,7 @@ impl CanvasEditor { let mut passed = false; for (_, _, node) in descendants(source.document.nodes(), None) { match &node.content { - onestore::page::ParagraphContent::Image(image) if image.id == id => { - passed = true - } + content if holds(content, id) => passed = true, onestore::page::ParagraphContent::Text(text) => { let end = text.text.utf16_offset(text.text.text().len())?; if passed { @@ -1925,7 +2074,10 @@ impl CanvasEditor { let index = self .objects .iter() - .position(|object| matches!(object, page::Content::Image(image) if image.id == id)) + .position(|object| { + matches!(object, page::Content::Image(image) if image.id == id) + || object.file().is_some_and(|(file, _)| file.id == id) + }) .ok_or(EditError::InvalidRange)?; // An emptied outline keeps its slot for undo after leaving `outlines`. let editable = |object: &page::Content| match object { @@ -1989,10 +2141,95 @@ impl CanvasEditor { ) } + /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the + /// caret's paragraph splits there, the file takes the place of an empty first half, and the + /// caret starts the second. A caret on blank page puts the file on the page there + /// (`corpus/attachment-floating`). + pub fn insert_attachment( + &mut self, + engine: &mut TextEngine, + mut file: onestore::page::Attachment, + ) -> Result<(), EditorError> { + if let Focus::Caret { outline, .. } = &self.active { + [file.layout.x, file.layout.y] = outline.origin().map(Some); + let layout = Box::new(crate::outline::page_file(engine, &file)?); + let [x0, y0, x1, y1] = layout.bounds(); + [file.layout.max_width, file.layout.max_height] = [Some(x1 - x0), Some(y1 - y0)]; + file.layout.width_set_by_user = Some(false); + self.finish_composition(); + let index = self.objects.len(); + self.add_file( + index, + page::Content::File { + source: file, + layout, + }, + ); + self.undo.push(History::File { index, file: None }); + self.redo.clear(); + return Ok(()); + } + let [anchor, focus] = self.selection().positions; + let (start, end) = (anchor.min(focus), anchor.max(focus)); + let mut format = self.typing_format(start)?; + link::unlinked(&mut format); + let mut edit = self + .active_outline() + .document + .replace(start..end, vec![Paragraph::new(String::new(), format); 2])?; + let [head, tail, ..] = &mut edit.replacement[..] else { + unreachable!("a split holds both halves") + }; + let node = PageParagraph { + id: onestore::page::text::new_id()?, + content: ParagraphContent::Attachment(file), + style: None, + lists: Vec::new(), + tags: Vec::new(), + collapsed: false, + ..head.clone() + }; + let empty = head.text().unwrap().text.text().is_empty(); + if empty { + tail.tags = std::mem::take(&mut head.tags); + tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags); + } + edit.replacement.insert(1, node); + if empty { + edit.replacement.remove(0); + } + let caret = TextPosition { + paragraph: start.paragraph + usize::from(!empty), + offset: 0, + }; + self.commit( + engine, + edit, + Selection { + positions: [caret; 2], + affinities: [Affinity::Upstream; 2], + }, + ) + } + pub fn remove_image(&mut self, engine: &mut TextEngine, id: ExGuid) -> Result<(), EditorError> { if self.outline_picture(id).is_some() { return self.edit_outline_picture(engine, id, None); } + if let Some(index) = self + .objects + .iter() + .position(|object| object.file().is_some_and(|(file, _)| file.id == id)) + { + self.finish_composition(); + let file = self.remove_file(index); + self.undo.push(History::File { + index, + file: Some(Box::new(file)), + }); + self.redo.clear(); + return Ok(()); + } let index = self .objects .iter() @@ -2001,15 +2238,12 @@ impl CanvasEditor { }) .ok_or(EditError::InvalidRange)?; self.finish_composition(); - let page::Content::Image(image) = self.objects.remove(index) else { - unreachable!() - }; + let image = self.remove_picture(index); self.undo.push(History::Picture { index, image: Some(Box::new(image)), }); self.redo.clear(); - self.record(Ok(vec![PageOp::Delete { object: id }])); Ok(()) } @@ -2227,6 +2461,55 @@ impl CanvasEditor { result } + /// Runs `edit`, typing or a backspace at a caret, as part of the typing run the last undo + /// step holds if the caret is where that run left it and nothing came between; OneNote 2010 + /// undoes a run whole however long it pauses. + fn typed( + &mut self, + edit: impl FnOnce(&mut Self) -> Result, + ) -> Result { + let [anchor, focus] = self.selection().positions; + let caret = match self.marked_range() { + Some(marked) => Some(marked.start), + None => (anchor == focus).then_some(focus), + }; + let joins = self.typing.take().is_some_and(|(outline, at, depth)| { + outline == self.active_outline().id + && caret == Some(at) + && depth == self.undo.len() + && self.pending.is_none() + }); + let depth = self.undo.len() - usize::from(joins); + let result = edit(self); + let (mut run, mut page) = (Vec::new(), false); + for entry in self.undo.split_off(depth) { + match entry { + History::Group { + entries, + page: selected, + } => { + page |= selected; + if run.is_empty() { + run = entries; + } else { + run.extend(entries); + } + } + entry => run.push(entry), + } + } + match run.len() { + 0 => return result, + 1 if !page => self.undo.extend(run), + _ => self.undo.push(History::Group { entries: run, page }), + } + if result.is_ok() { + let caret = self.selection().positions[1]; + self.typing = Some((self.active_outline().id, caret, self.undo.len())); + } + result + } + /// Removes the page selection's outlines, last first, but its last when `keep`, whose /// text stays selected to be replaced. Deleting the page selection leaves a caret where /// its first outline began, as OneNote 2010's does. @@ -2375,6 +2658,7 @@ impl CanvasEditor { ) -> Result<(), EditorError> { self.finish_composition(); self.whole = None; + self.typing = None; if !extend && movement == Movement::Right && self.leave_equation()? { return Ok(()); } @@ -2842,12 +3126,21 @@ impl CanvasEditor { ) } - /// Types `text` at the selection; a space ends a typed URL, which becomes a link. + /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line + /// of text joins the typing run before it. pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { + if text.is_empty() || text.contains('\n') { + self.insert_text(engine, text) + } else { + self.typed(|editor| editor.insert_text(engine, text)) + } + } + + fn insert_text(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { if self.page_selected() { return self.grouped(|editor| { editor.remove_page(engine, true)?; - editor.insert(engine, text) + editor.insert_text(engine, text) }); } self.leave_link_code()?; @@ -3137,7 +3430,8 @@ impl CanvasEditor { let selection = self.active_outline().selection; let [anchor, focus] = selection.positions; let mut range = anchor.min(focus)..anchor.max(focus); - if range.is_empty() { + let at_caret = range.is_empty(); + if at_caret { let outline = self.active_outline(); let paragraph = outline.paragraph_layout(focus.paragraph)?; let cursor = paragraph.cursor(focus.offset, selection.affinities[1])?; @@ -3213,7 +3507,11 @@ impl CanvasEditor { onestore::page::text::Affinity::Upstream, )?; } - self.delete_range(engine, range) + if backward && at_caret { + self.typed(|editor| editor.delete_range(engine, range)) + } else { + self.delete_range(engine, range) + } } pub fn delete_to( @@ -3226,19 +3524,26 @@ impl CanvasEditor { } self.finish_composition(); let original = self.selection(); - let preferred_x = self.preferred_x; - if original.positions[0] == original.positions[1] { + let (preferred_x, typing) = (self.preferred_x, self.typing); + let [caret, focus] = original.positions; + let collapsed = caret == focus; + if collapsed { let result = self.move_selection(engine, movement, true); if let Err(error) = result { self.active_outline_mut().selection = original; - self.preferred_x = preferred_x; + (self.preferred_x, self.typing) = (preferred_x, typing); return Err(error); } } let [anchor, focus] = self.selection().positions; self.active_outline_mut().selection = original; - self.preferred_x = preferred_x; - self.delete_range(engine, anchor.min(focus)..anchor.max(focus)) + (self.preferred_x, self.typing) = (preferred_x, typing); + let range = anchor.min(focus)..anchor.max(focus); + if collapsed && range.end == caret && range.start.paragraph == caret.paragraph { + self.typed(|editor| editor.delete_range(engine, range)) + } else { + self.delete_range(engine, range) + } } fn delete_range( @@ -3326,6 +3631,7 @@ impl CanvasEditor { history: History, ) -> Result { self.pending = None; + self.typing = None; let valid = match &history { History::Date(date) => self .date @@ -3363,9 +3669,20 @@ impl CanvasEditor { index, image: Some(_), } => *index <= self.objects.len(), - History::Picture { index, image: None } => { - matches!(self.objects.get(*index), Some(page::Content::Image(_))) - } + History::Picture { index, image: None } => self + .objects + .get(*index) + .and_then(page::Content::picture) + .is_some(), + History::File { + index, + file: Some(_), + } => *index <= self.objects.len(), + History::File { index, file: None } => self + .objects + .get(*index) + .and_then(page::Content::file) + .is_some(), History::Remove { outline, focus } => { self.outlines.iter().any(|item| item.id == *outline) && match focus { @@ -3584,24 +3901,20 @@ impl CanvasEditor { index, image: match image { Some(image) => { - self.objects.insert(index, page::Content::Image(*image)); - let page::Content::Image(image) = &self.objects[index] else { - unreachable!() - }; - let ops = vec![PageOp::Add { - object: PageObject::Image(image.clone()), - before: self.successor(image.id), - }]; - self.record(Ok(ops)); + self.add_picture(index, *image); None } - None => match self.objects.remove(index) { - page::Content::Image(image) => { - self.record(Ok(vec![PageOp::Delete { object: image.id }])); - Some(Box::new(image)) - } - _ => unreachable!(), - }, + None => Some(Box::new(self.remove_picture(index))), + }, + }, + History::File { index, file } => History::File { + index, + file: match file { + Some(file) => { + self.add_file(index, *file); + None + } + None => Some(Box::new(self.remove_file(index))), }, }, History::Remove { outline, focus } => { @@ -3827,9 +4140,17 @@ impl CanvasEditor { .count() .try_into() .map_err(|_| EditError::TextTooLong)?; - self.compose(engine, text, end..end)?; - self.finish_composition(); - Ok(()) + let lines = text.contains('\n'); + let commit = |editor: &mut Self| { + editor.compose(engine, text, end..end)?; + editor.finish_composition(); + Ok(()) + }; + if lines { + commit(self) + } else { + self.typed(commit) + } } else { self.insert(engine, &text) } @@ -4128,6 +4449,15 @@ impl CanvasEditor { /// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same /// paragraph, past what changed in its text when it lies after it; at the start of the /// paragraph now at its place when that one is gone. +/// Whether `content` is picture or file `id`. +fn holds(content: &ParagraphContent, id: ExGuid) -> bool { + match content { + ParagraphContent::Image(image) => image.id == id, + ParagraphContent::Attachment(file) => file.id == id, + _ => false, + } +} + fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { let found = old.leaf(position.paragraph).and_then(|(_, _, node)| { let paragraph = new @@ -5065,22 +5395,74 @@ mod tests { editor.active_outline().shaped.paragraphs.len(), fresh.paragraphs.len() ); - editor.undo(&mut engine).unwrap(); - assert_eq!( - editor - .active_outline() - .document - .paragraphs() - .last() - .unwrap() - .text(), - "Lasta" - ); + // The committed composition and the typing after it are one run. editor.undo(&mut engine).unwrap(); assert_eq!(editor.outlines()[0].document, source); assert_eq!(editor.active_outline().document, draft); } + /// OneNote 2010's undo steps (Windows 7 lab, 2026-09-29): typing, backspaces and word + /// backspaces within a paragraph are one step however long the pauses; a caret move, Enter, + /// a backspace joining paragraphs, Delete, paste and a format chosen at the caret each end it. + #[test] + fn typing_runs_undo_whole_until_the_caret_moves_or_another_edit() { + let mut engine = TextEngine::default(); + let source = + TextDocument::new(vec![Paragraph::new("Base".into(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap(); + let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); + let text = |editor: &CanvasEditor| editor.active_outline().shown_text(); + editor.select(at(0, 4)).unwrap(); + editor.insert(&mut engine, " quick").unwrap(); + editor.insert(&mut engine, " brown").unwrap(); + editor.delete(&mut engine, true).unwrap(); + editor.delete_to(&mut engine, Movement::WordLeft).unwrap(); + editor.commit_text(&mut engine, "fox".into()).unwrap(); + editor.select(editor.selection()).unwrap(); + editor.insert(&mut engine, " jumps").unwrap(); + assert_eq!(text(&editor), "Base quick fox jumps"); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(text(&editor), "Base"); + assert_eq!(editor.selection().positions, [at(0, 4).positions[0]; 2]); + assert!(!editor.can_undo()); + assert!(editor.redo(&mut engine).unwrap()); + assert_eq!(text(&editor), "Base quick fox jumps"); + + let mut shown = vec!["Base".to_owned(), text(&editor)]; + for movement in [Movement::Left, Movement::Right] { + editor.move_selection(&mut engine, movement, false).unwrap(); + } + editor.insert(&mut engine, "!").unwrap(); + editor.insert(&mut engine, "?").unwrap(); + shown.push(text(&editor)); + editor.enter(&mut engine, false).unwrap(); + shown.push(text(&editor)); + editor.insert(&mut engine, "ab").unwrap(); + shown.push(text(&editor)); + editor + .move_selection(&mut engine, Movement::Left, false) + .unwrap(); + editor.delete(&mut engine, false).unwrap(); + shown.push(text(&editor)); + editor.insert(&mut engine, "c").unwrap(); + shown.push(text(&editor)); + editor + .format(&mut engine, Formatting::Toggle(Toggle::Bold)) + .unwrap(); + editor.insert(&mut engine, "d").unwrap(); + editor.insert(&mut engine, "e").unwrap(); + shown.push(text(&editor)); + editor.select(at(1, 0)).unwrap(); + editor.delete(&mut engine, true).unwrap(); + shown.push(text(&editor)); + editor.insert(&mut engine, "f").unwrap(); + assert_eq!(text(&editor), "Base quick fox jumps!?facde"); + while editor.undo(&mut engine).unwrap() { + assert_eq!(text(&editor), shown.pop().unwrap()); + } + assert!(shown.is_empty()); + } + #[test] fn table_horizontal_arrows_follow_reading_order() { let mut engine = TextEngine::default(); @@ -5659,6 +6041,80 @@ mod tests { assert_eq!(ids(&editor), [background_id]); } + #[test] + fn art_and_paper_change_together_as_one_step_under_the_page() { + use onestore::page::{Image, Page, PageObject}; + let mut engine = TextEngine::default(); + let image = |background, bytes: &[u8]| Image { + size: None, + id: onestore::page::text::new_id().unwrap(), + layout: onestore::document::Layout { + x: Some(-27.0), + y: Some(-3.6), + max_width: Some(174.5), + max_height: Some(640.0), + ..Default::default() + }, + bytes: Some(std::sync::Arc::from(bytes)), + alt: None, + background, + }; + let [old, picture] = [image(true, b"old art"), image(false, b"picture")]; + let mut editor = CanvasEditor::from_page( + Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + color: None, + rule_lines: None, + definitions: BTreeMap::new(), + objects: vec![ + PageObject::Image(old.clone()), + PageObject::Image(picture.clone()), + ], + }, + &mut engine, + ) + .unwrap(); + let ids = |editor: &CanvasEditor| { + let page = editor.page().unwrap(); + let ids: Vec<_> = page.objects.iter().map(PageObject::id).collect(); + (ids, page.color) + }; + let art = || [image(true, b"art one"), image(true, b"art two")]; + let [one, two] = art(); + let blue = Some(0x00fef5ed); + assert!(editor.set_paper(blue, None, Some(vec![one.clone(), two.clone()]))); + let applied = (vec![one.id, two.id, picture.id], blue); + assert_eq!(ids(&editor), applied); + assert_eq!( + editor.take_ops().unwrap(), + [ + PageOp::Color(blue), + PageOp::Delete { object: old.id }, + PageOp::Add { + object: PageObject::Image(two.clone()), + before: Some(picture.id), + }, + PageOp::Add { + object: PageObject::Image(one.clone()), + before: Some(two.id), + }, + ] + ); + // The same art again, under fresh identities, changes nothing. + assert!(!editor.set_paper(blue, None, Some(art().into()))); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(ids(&editor), (vec![old.id, picture.id], None)); + assert_eq!(editor.take_ops().unwrap().len(), 4); + assert!(!editor.can_undo()); + assert!(editor.redo(&mut engine).unwrap()); + assert_eq!(ids(&editor), applied); + assert!(editor.set_paper(blue, None, Some(Vec::new()))); + assert_eq!(ids(&editor), (vec![picture.id], blue)); + } + #[test] fn arrows_leave_a_picture_for_the_neighbouring_outlines_in_page_order() { use onestore::page::{Image, Page, PageObject}; @@ -5858,6 +6314,94 @@ mod tests { ); } + /// OneNote 2010 attaches at the caret (`corpus/attachment-insert`): the text after it + /// follows the file and keeps the caret, and an empty first half gives way to the file. + #[test] + fn a_file_attaches_at_the_caret_as_onenote_splits_around_it() { + use onestore::page::{Attachment, Page, PageObject, ParagraphContent}; + let mut engine = TextEngine::default(); + let source = TextOutline::new( + &mut engine, + TextDocument::new(vec![ + Paragraph::new("Before".into(), Default::default()), + Paragraph::new(String::new(), Default::default()), + ]) + .unwrap(), + 240.0, + [36.0, 36.0], + ) + .unwrap() + .snapshot(); + let mut editor = CanvasEditor::from_page( + Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + color: None, + rule_lines: None, + definitions: BTreeMap::new(), + objects: vec![PageObject::Outline(source)], + }, + &mut engine, + ) + .unwrap(); + editor.take_ops().unwrap(); + let file = |name: &str| Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: name.into(), + source_path: None, + size: Some([24.0, 24.0]), + layout: Default::default(), + bytes: Some(std::sync::Arc::from(b"payload".as_slice())), + preview: None, + recording: None, + }; + let at = |paragraph, offset| Selection { + positions: [TextPosition { paragraph, offset }; 2], + affinities: [Affinity::Downstream; 2], + }; + let shown = |editor: &CanvasEditor| { + let page = editor.page().unwrap(); + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + outline + .paragraphs + .iter() + .map(|node| match &node.content { + ParagraphContent::Text(text) => text.text.text().to_owned(), + ParagraphContent::Attachment(file) => format!("[{}]", file.filename), + _ => panic!(), + }) + .collect::>() + }; + + editor.select(at(0, 3)).unwrap(); + let middle = file("middle.txt"); + editor.insert_attachment(&mut engine, middle.clone()).unwrap(); + assert_eq!(shown(&editor), ["Bef", "[middle.txt]", "ore", ""]); + // A file is no text position: the caret starts "ore", the second text. + assert_eq!(editor.selection().positions, at(1, 0).positions); + assert_eq!(editor.attachment(middle.id), Some(&middle)); + let ops = editor.take_ops().unwrap(); + assert!(ops.iter().any(|op| matches!(op, PageOp::Insert { paragraphs, .. } + if paragraphs.iter().any(|p| matches!(&p.content, ParagraphContent::Attachment(a) if *a == middle))))); + + editor.select(at(2, 0)).unwrap(); + editor.insert_attachment(&mut engine, file("empty.bin")).unwrap(); + assert_eq!( + shown(&editor), + ["Bef", "[middle.txt]", "ore", "[empty.bin]", ""] + ); + assert_eq!(editor.selection().positions[0].paragraph, 2); + + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(shown(&editor), ["Bef", "[middle.txt]", "ore", ""]); + editor.remove_image(&mut engine, middle.id).unwrap(); + assert_eq!(shown(&editor), ["Bef", "ore", ""]); + } + #[test] fn unknown_paragraphs_and_text_free_outlines_stay_on_the_page() { use onestore::page::{Image, Page, PageObject, ParagraphContent, Unsupported}; @@ -6405,17 +6949,6 @@ mod tests { assert_eq!(editor.active_outline().shaped.size, expected.shaped.size); assert_eq!(editor.active_outline().document.nodes().len(), 2); editor.undo(&mut engine).unwrap(); - assert_eq!( - editor - .active_outline() - .document - .paragraphs() - .last() - .unwrap() - .text(), - "a" - ); - editor.undo(&mut engine).unwrap(); assert_eq!(editor.outlines[0].document, source); assert_eq!(editor.active_outline().document.nodes().len(), 4); } @@ -7152,6 +7685,10 @@ mod tests { let selection = editor.selection(); editor.insert(&mut engine, "x").unwrap(); assert_eq!(editor.outlines.len(), 1); + // A caret move ends the typing run, so the backspace is its own step. + for movement in [Movement::Left, Movement::Right] { + editor.move_selection(&mut engine, movement, false).unwrap(); + } editor.delete(&mut engine, true).unwrap(); assert!(editor.outlines.is_empty()); assert_eq!(editor.active_outline().document, spaces); @@ -7368,6 +7905,9 @@ mod tests { ) .unwrap(); editor.insert(&mut engine, "One").unwrap(); + for movement in [Movement::Left, Movement::Right] { + editor.move_selection(&mut engine, movement, false).unwrap(); + } let source = editor.active_outline().snapshot(); let selection = editor.selection(); editor.insert(&mut engine, "!").unwrap(); diff --git a/crates/canvas/src/editor/evidence.rs b/crates/canvas/src/editor/evidence.rs index 1b66ae6dbbb26dd82603d1fbf17ff612e5a7a8b4..19e44c28c7f1132dafb9bad94664402c0f54223f 100644 --- a/crates/canvas/src/editor/evidence.rs +++ b/crates/canvas/src/editor/evidence.rs @@ -91,13 +91,9 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { Some(label) => (label, true), None => (tag, false), }; - let tag = NoteTag::ALL - .into_iter() - .find(|tag| { - matches!(&tag.definition().kind, Kind::TagDefinition { label: Some(name), .. } if name == label) - }) - .unwrap(); - let definition = define(label.into(), tag.definition()); + let defaults = NoteTag::defaults(); + let place = defaults.iter().position(|tag| tag.label == label).unwrap(); + let definition = define(label.into(), defaults[place].definition(place as u16)); node.text_mut().unwrap().tags.push(Tag { definition: Some(definition), action_type: None, @@ -146,9 +142,12 @@ fn render(editor: &CanvasEditor) -> String { }; let checked = if tag.status & 1 == 1 { "[x]" } else { "" }; ( - NoteTag::ALL - .iter() - .position(|known| known.definition().kind == *kind), + NoteTag::of(kind).and_then(|(tag, action_type)| { + NoteTag::defaults() + .iter() + .position(|known| *known == tag) + .filter(|place| *place == usize::from(action_type)) + }), format!("{}{checked}", label.as_deref().unwrap()), ) }) diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 222e2181b7edf55fea5428592cf22c015e157614..bd5849f029408c8ea71a5bbf31f258ae1ecb0f88 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -5,6 +5,7 @@ use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE}; use onestore::document::{Format, Kind, Tag}; use onestore::page::ParagraphContent; use onestore::page::text::new_id; +use serde::{Deserialize, Serialize}; /// The COLORREF OneNote stores for automatic colour, which overrides a style's colour. const AUTOMATIC: u32 = 0xff00_0000; @@ -32,7 +33,9 @@ pub enum Formatting { Indent, Outdent, Align(Alignment), - Tag(NoteTag), + /// A tag of the user's list and its place there, which the page stores as its action + /// type. + Tag(NoteTag, u16), /// Removes every note tag from the selected paragraphs. RemoveTags, /// Checks the selected paragraphs' check boxes, or clears them when all are checked. @@ -161,40 +164,102 @@ impl ListStyle { } } -/// OneNote 2010's default tags, the first nine on Ctrl+1 to Ctrl+9, as it stores their -/// definitions (`corpus/structural-probe/tag-gallery.one`); declared in gallery order, their -/// action types. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NoteTag { - ToDo, - Important, - Question, - RememberForLater, - Definition, - Highlight, - Contact, - Address, - PhoneNumber, - WebSiteToVisit, - Idea, - Password, - Critical, - ProjectA, - ProjectB, - MovieToSee, - BookToRead, - MusicToListenTo, - SourceForArticle, - RememberForBlog, - DiscussWithPersonA, - DiscussWithPersonB, - DiscussWithManager, - SendInEmail, - ScheduleMeeting, - CallBack, - ToDoPriority1, - ToDoPriority2, - ClientRequest, +/// A tag of the user's list as the Customize Tags dialog edits it: what its definition +/// stores besides its place in the list, which the page stores as its action type. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct NoteTag { + pub label: String, + /// MS-ONE's NoteTagShape; 0 has no symbol. + pub shape: u16, + /// COLORREFs; none leaves the text's own. + pub color: Option, + pub highlight: Option, +} + +/// OneNote 2010's tag list before it is customized, the first nine on Ctrl+1 to Ctrl+9 +/// (`corpus/structural-probe/tag-gallery.one`): names, symbols and highlights. +const DEFAULT_TAGS: [(&str, u16, Option); 29] = [ + ("To Do", CHECKBOX, None), + ("Important", 13, None), + ("Question", 15, None), + ("Remember for later", 0, Some(0x0000_ffff)), + ("Definition", 0, Some(0x0000_ff00)), + ("Highlight", 136, None), + ("Contact", 118, None), + ("Address", 23, None), + ("Phone number", 18, None), + ("Web site to visit", 125, None), + ("Idea", 21, None), + ("Password", 131, None), + ("Critical", 17, None), + ("Project A", 100, None), + ("Project B", 101, None), + ("Movie to see", 122, None), + ("Book to read", 132, None), + ("Music to listen to", 121, None), + ("Source for article", 125, None), + ("Remember for blog", 24, None), + ("Discuss with ", 94, None), + ("Discuss with ", 94, None), + ("Discuss with manager", 95, None), + ("Send in e-mail", 106, None), + ("Schedule meeting", 12, None), + ("Call back", 12, None), + ("To Do priority 1", 28, None), + ("To Do priority 2", 71, None), + ("Client request", 8, None), +]; + +impl NoteTag { + pub fn defaults() -> Vec { + DEFAULT_TAGS + .iter() + .map(|&(label, shape, highlight)| Self { + label: label.into(), + shape, + // The highlighting tags also set black text. + color: highlight.map(|_| 0), + highlight, + }) + .collect() + } + + /// The definition OneNote stores for the tag at place `action_type` in the list. + pub fn definition(&self, action_type: u16) -> Definition { + Definition { + kind: Kind::TagDefinition { + label: Some(self.label.clone()), + action_type: Some(action_type), + shape: Some(self.shape), + color: self.color, + highlight: self.highlight, + }, + format: Format::default(), + } + } + + /// The tag a stored definition describes, and its action type. + pub fn of(kind: &Kind) -> Option<(Self, u16)> { + let Kind::TagDefinition { + label, + action_type, + shape, + color, + highlight, + } = kind + else { + return None; + }; + Some(( + Self { + label: label.clone().unwrap_or_default(), + shape: shape.unwrap_or(0), + color: *color, + highlight: *highlight, + }, + action_type.unwrap_or(0), + )) + } } /// What the selection shows on the toolbar. @@ -212,8 +277,8 @@ pub struct FormatState { pub numbering: bool, /// The library style every selected paragraph's list shares. pub list: Option, - /// Tags every selected paragraph has. - pub tags: Vec, + /// Tags every selected paragraph has, with their action types. + pub tags: Vec<(NoteTag, u16)>, } impl Toggle { @@ -249,133 +314,6 @@ impl Toggle { } } -impl NoteTag { - pub const ALL: [Self; 29] = [ - Self::ToDo, - Self::Important, - Self::Question, - Self::RememberForLater, - Self::Definition, - Self::Highlight, - Self::Contact, - Self::Address, - Self::PhoneNumber, - Self::WebSiteToVisit, - Self::Idea, - Self::Password, - Self::Critical, - Self::ProjectA, - Self::ProjectB, - Self::MovieToSee, - Self::BookToRead, - Self::MusicToListenTo, - Self::SourceForArticle, - Self::RememberForBlog, - Self::DiscussWithPersonA, - Self::DiscussWithPersonB, - Self::DiscussWithManager, - Self::SendInEmail, - Self::ScheduleMeeting, - Self::CallBack, - Self::ToDoPriority1, - Self::ToDoPriority2, - Self::ClientRequest, - ]; - - /// The tag's name, as OneNote stores and lists it. - pub const fn label(self) -> &'static str { - match self { - Self::ToDo => "To Do", - Self::Important => "Important", - Self::Question => "Question", - Self::RememberForLater => "Remember for later", - Self::Definition => "Definition", - Self::Highlight => "Highlight", - Self::Contact => "Contact", - Self::Address => "Address", - Self::PhoneNumber => "Phone number", - Self::WebSiteToVisit => "Web site to visit", - Self::Idea => "Idea", - Self::Password => "Password", - Self::Critical => "Critical", - Self::ProjectA => "Project A", - Self::ProjectB => "Project B", - Self::MovieToSee => "Movie to see", - Self::BookToRead => "Book to read", - Self::MusicToListenTo => "Music to listen to", - Self::SourceForArticle => "Source for article", - Self::RememberForBlog => "Remember for blog", - Self::DiscussWithPersonA => "Discuss with ", - Self::DiscussWithPersonB => "Discuss with ", - Self::DiscussWithManager => "Discuss with manager", - Self::SendInEmail => "Send in e-mail", - Self::ScheduleMeeting => "Schedule meeting", - Self::CallBack => "Call back", - Self::ToDoPriority1 => "To Do priority 1", - Self::ToDoPriority2 => "To Do priority 2", - Self::ClientRequest => "Client request", - } - } - - /// The symbol OneNote draws for the tag; 0 draws none. - pub const fn shape(self) -> u16 { - match self { - Self::ToDo => CHECKBOX, - Self::Important => 13, - Self::Question => 15, - Self::RememberForLater | Self::Definition => 0, - Self::Highlight => 136, - Self::Contact => 118, - Self::Address => 23, - Self::PhoneNumber => 18, - Self::WebSiteToVisit | Self::SourceForArticle => 125, - Self::Idea => 21, - Self::Password => 131, - Self::Critical => 17, - Self::ProjectA => 100, - Self::ProjectB => 101, - Self::MovieToSee => 122, - Self::BookToRead => 132, - Self::MusicToListenTo => 121, - Self::RememberForBlog => 24, - Self::DiscussWithPersonA | Self::DiscussWithPersonB => 94, - Self::DiscussWithManager => 95, - Self::SendInEmail => 106, - Self::ScheduleMeeting | Self::CallBack => 12, - Self::ToDoPriority1 => 28, - Self::ToDoPriority2 => 71, - Self::ClientRequest => 8, - } - } - - pub(super) fn definition(self) -> Definition { - let highlight = match self { - Self::RememberForLater => Some(0x0000_ffff), - Self::Definition => Some(0x0000_ff00), - _ => None, - }; - Definition { - kind: Kind::TagDefinition { - label: Some(self.label().into()), - action_type: Some(self as u16), - shape: Some(self.shape()), - // The highlighting tags also set black text. - color: highlight.map(|_| 0), - highlight, - }, - format: Format::default(), - } - } -} - -/// Whether tag symbol `shape` is a check box, which OneNote stores unchecked when applied. -fn checkable(shape: Option) -> bool { - matches!( - shape.and_then(|shape| crate::outline::TagIcon::of(shape, false)), - Some(crate::outline::TagIcon::CheckBox { .. }) - ) -} - /// Where a selection starts and ends, as text leaf identity and byte offset. type Ends = [(ExGuid, usize); 2]; @@ -664,15 +602,19 @@ impl CanvasEditor { bullets: list(Formatting::Bullets), numbering: list(Formatting::Numbering), list: common(paragraphs.iter().map(|node| self.list_style(node))), - tags: NoteTag::ALL - .into_iter() - .filter(|tag| { - let kind = tag.definition().kind; - paragraphs - .iter() - .all(|node| tags(node).any(|tag| self.tag_kind(tag) == Some(&kind))) - }) - .collect(), + tags: paragraphs.first().map_or_else(Vec::new, |first| { + let mut shared: Vec<_> = tags(first) + .filter_map(|tag| NoteTag::of(self.tag_kind(tag)?)) + .filter(|(tag, action_type)| { + let kind = tag.definition(*action_type).kind; + paragraphs + .iter() + .all(|node| tags(node).any(|tag| self.tag_kind(tag) == Some(&kind))) + }) + .collect(); + shared.sort_by_key(|(_, action_type)| *action_type); + shared + }), }) } @@ -702,7 +644,7 @@ impl CanvasEditor { | Formatting::Bullets | Formatting::Numbering | Formatting::List(_) - | Formatting::Tag(_) + | Formatting::Tag(..) | Formatting::RemoveTags | Formatting::Check if title => @@ -783,14 +725,8 @@ impl CanvasEditor { } }); } - Formatting::Tag(tag) => { - let definition = tag.definition(); - let Kind::TagDefinition { - action_type, shape, .. - } = definition.kind - else { - unreachable!() - }; + Formatting::Tag(tag, action_type) => { + let definition = tag.definition(*action_type); let existing = self .definitions .iter() @@ -821,12 +757,12 @@ impl CanvasEditor { Some(kind) if remove => *kind != definition.kind, Some(Kind::TagDefinition { action_type: other, .. - }) => *other != action_type, + }) => *other != Some(*action_type), _ => true, }); } if !remove { - let checkable = checkable(shape); + let checkable = crate::outline::checkable(tag.shape); // Stored newest first. text.tags.insert( 0, @@ -967,7 +903,7 @@ impl CanvasEditor { Formatting::Toggle(toggle) => state.toggles.contains(toggle), Formatting::Bullets => state.bullets, Formatting::Numbering => state.numbering, - Formatting::Tag(tag) => state.tags.contains(tag), + Formatting::Tag(tag, action_type) => state.tags.contains(&(tag.clone(), *action_type)), _ => false, }; let mut outlines = Vec::new(); @@ -1038,7 +974,8 @@ impl CanvasEditor { let checkable = |tag: &Tag| { matches!( self.tag_kind(tag), - Some(Kind::TagDefinition { shape, .. }) if checkable(*shape) + Some(Kind::TagDefinition { shape: Some(shape), .. }) + if crate::outline::checkable(*shape) ) }; let checked = self::covered(replacement, ends) @@ -1823,7 +1760,10 @@ mod tests { let mut editor = plain(&mut engine, &["task", "other"]); editor.select([at(0, 0), at(1, 5)].into()).unwrap(); editor - .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + ) .unwrap(); editor.select([at(1, 2); 2].into()).unwrap(); let outline = editor.active_outline().id; @@ -1886,27 +1826,33 @@ mod tests { let original = editor.active_outline().document.clone(); editor.select([at(0, 1), at(1, 2)].into()).unwrap(); editor - .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + ) .unwrap(); assert_eq!(text_tags(&editor, 0), [("To Do".into(), 0, true)]); assert_eq!(text_tags(&editor, 1), [("To Do".into(), 0, true)]); - assert_eq!(editor.format_state().unwrap().tags, [NoteTag::ToDo]); + assert_eq!( + editor.format_state().unwrap().tags, + [(NoteTag::defaults()[0].clone(), 0)] + ); let icon = |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon; assert_eq!( icon(&editor), - crate::outline::TagIcon::CheckBox { - checked: false, - mark: None + crate::outline::TagIcon::Symbol { + shape: 3, + checked: false } ); editor.format(&mut engine, Formatting::Check).unwrap(); assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]); assert_eq!( icon(&editor), - crate::outline::TagIcon::CheckBox { - checked: true, - mark: None + crate::outline::TagIcon::Symbol { + shape: 3, + checked: true } ); editor.select([at(1, 0); 2].into()).unwrap(); @@ -1918,27 +1864,39 @@ mod tests { editor.select([at(1, 0), at(2, 5)].into()).unwrap(); editor - .format(&mut engine, Formatting::Tag(NoteTag::Question)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[2].clone(), 2), + ) .unwrap(); assert_eq!( text_tags(&editor, 1), [("Question".into(), 1, true), ("To Do".into(), 1, true)] ); assert_eq!(text_tags(&editor, 2), [("Question".into(), 1, true)]); - assert_eq!(editor.format_state().unwrap().tags, [NoteTag::Question]); + assert_eq!( + editor.format_state().unwrap().tags, + [(NoteTag::defaults()[2].clone(), 2)] + ); let questions = editor .definitions .values() - .filter(|definition| **definition == NoteTag::Question.definition()) + .filter(|definition| **definition == NoteTag::defaults()[2].definition(2)) .count(); assert_eq!(questions, 1); editor - .format(&mut engine, Formatting::Tag(NoteTag::Question)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[2].clone(), 2), + ) .unwrap(); assert_eq!(text_tags(&editor, 2), []); editor.select([at(0, 0); 2].into()).unwrap(); editor - .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + ) .unwrap(); assert_eq!(text_tags(&editor, 0), []); for _ in 0..7 { @@ -1961,44 +1919,35 @@ mod tests { .values() .filter(|definition| matches!(definition.kind, Kind::TagDefinition { .. })) .collect::>(); - assert_eq!(stored.len(), NoteTag::ALL.len()); - for tag in NoteTag::ALL { - assert!(stored.contains(&&tag.definition()), "{tag:?}"); + let defaults = NoteTag::defaults(); + assert_eq!(stored.len(), defaults.len()); + for (place, tag) in defaults.iter().enumerate() { + assert!(stored.contains(&&tag.definition(place as u16)), "{tag:?}"); } let mut engine = TextEngine::default(); let mut editor = plain(&mut engine, &["tagged"]); - for tag in NoteTag::ALL { - editor.format(&mut engine, Formatting::Tag(tag)).unwrap(); + for (place, tag) in defaults.iter().enumerate() { + editor + .format(&mut engine, Formatting::Tag(tag.clone(), place as u16)) + .unwrap(); } - assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL); + assert_eq!(editor.format_state().unwrap().tags.len(), defaults.len()); let paragraph = &editor.active_outline().shaped.paragraphs[0]; - use crate::outline::TagIcon; - let icons = paragraph + let shapes = paragraph .tags .iter() - .map(|tag| tag.icon) + .map(|tag| match tag.icon { + crate::outline::TagIcon::Symbol { shape, .. } => shape, + crate::outline::TagIcon::Task => unreachable!(), + }) .collect::>(); - assert_eq!(icons.len(), NoteTag::ALL.len() - 2); - assert_eq!( - icons[..7], - [ - TagIcon::CheckBox { - checked: false, - mark: None - }, - TagIcon::Star, - TagIcon::Question, - TagIcon::Highlight, - TagIcon::Contact, - TagIcon::Address, - TagIcon::Phone, - ] - ); + assert_eq!(shapes.len(), defaults.len() - 2); + assert_eq!(shapes[..7], [3, 13, 15, 136, 118, 23, 18]); // As OneNote stores them unchecked: To Do, Discuss with, Schedule meeting, Call back, // the priorities and Client request. - let checkable = NoteTag::ALL - .into_iter() - .filter(|tag| checkable(Some(tag.shape()))) + let checkable = defaults + .iter() + .filter(|tag| crate::outline::checkable(tag.shape)) .count(); assert_eq!(checkable, 9); // Remember for later and Definition draw no symbol; the newer one's green marks the text. @@ -2011,6 +1960,46 @@ mod tests { assert!(paragraph.text.backgrounds().next().is_some()); } + /// A customized tag stores its name, symbol and colours with its place in the list as + /// its action type; applied from another place it takes a definition of its own, as + /// OneNote 2010's does (`corpus/custom-tags`). + #[test] + fn custom_tags_store_their_look_and_place() { + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["one", "two"]); + let tag = NoteTag { + label: "Snow check".into(), + shape: 61, + color: Some(0x0000_0080), + highlight: Some(0x00ff_cc00), + }; + editor + .format(&mut engine, Formatting::Tag(tag.clone(), 0)) + .unwrap(); + assert_eq!(editor.format_state().unwrap().tags, [(tag.clone(), 0)]); + assert_eq!(text_tags(&editor, 0), [("Snow check".into(), 1, true)]); + editor.select([at(1, 0); 2].into()).unwrap(); + editor + .format(&mut engine, Formatting::Tag(tag.clone(), 1)) + .unwrap(); + assert_eq!(editor.format_state().unwrap().tags, [(tag.clone(), 1)]); + let mut stored: Vec<_> = editor + .definitions + .values() + .filter_map(|definition| NoteTag::of(&definition.kind)) + .collect(); + stored.sort_by_key(|(_, action_type)| *action_type); + assert_eq!(stored, [(tag.clone(), 0), (tag, 1)]); + let paragraph = &editor.active_outline().shaped.paragraphs[0]; + assert!( + paragraph + .text + .backgrounds() + .all(|(_, color)| color == 0x00ff_cc00) + ); + assert!(paragraph.text.backgrounds().next().is_some()); + } + #[test] fn commands_reach_every_cell_a_selection_crosses() { let mut engine = TextEngine::default(); @@ -2049,7 +2038,10 @@ mod tests { editor.format(&mut engine, Formatting::Numbering).unwrap(); assert_eq!(numbers(&mut engine, &editor), [None, Some(1)]); editor - .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + ) .unwrap(); assert_eq!(editor.active_outline().shaped.paragraphs[1].tags.len(), 1); assert_eq!(editor.selection(), Selection::from([at(1, 0); 2])); @@ -2110,8 +2102,8 @@ mod tests { Formatting::Highlight(Some(0x0000_ffff)), Formatting::Align(Alignment::Center), Formatting::Numbering, - Formatting::Tag(NoteTag::ToDo), - Formatting::Tag(NoteTag::Question), + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + Formatting::Tag(NoteTag::defaults()[2].clone(), 2), Formatting::Check, ] { editor.format(&mut engine, command).unwrap(); @@ -2136,7 +2128,10 @@ mod tests { bullets: false, numbering: true, list: Some(ListStyle::NUMBER), - tags: vec![NoteTag::ToDo, NoteTag::Question], + tags: vec![ + (NoteTag::defaults()[0].clone(), 0), + (NoteTag::defaults()[2].clone(), 2) + ], } ); // Format Painter, a gallery tag and an inserted table write as well. @@ -2155,7 +2150,10 @@ mod tests { ) ); editor - .format(&mut engine, Formatting::Tag(NoteTag::CallBack)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[25].clone(), 25), + ) .unwrap(); editor.select([at(0, 1); 2].into()).unwrap(); editor.insert_table(&mut engine, 2, 3).unwrap(); diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs index 3b12789442eaaf253b8183ca31427b8fcb319613..4bbd1fa473e2ba33845526379612b00b7fbb29c5 100644 --- a/crates/canvas/src/editor/ops.rs +++ b/crates/canvas/src/editor/ops.rs @@ -330,6 +330,9 @@ impl CanvasEditor { page::Content::Ink(ink) if ink.id == placement.id => { return layout_ops(ink.id, &old, &moved(&ink.layout)); } + page::Content::File { source, .. } if source.id == placement.id => { + return layout_ops(source.id, &old, &moved(&source.layout)); + } page::Content::Outline { source, .. } if source.id == placement.id => { return layout_ops(source.id, &old, &moved(&source.layout)); } @@ -434,6 +437,7 @@ impl CanvasEditor { page::Content::Outline { source, .. } => source.id, page::Content::Date { .. } => continue, page::Content::Image(image) => image.id, + page::Content::File { source, .. } => source.id, page::Content::Ink(ink) => ink.id, page::Content::ReadOnly(object) if matches!(object.source, PageObject::Title(_)) => diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index a90a91a003cc6ed0f9ec986a51272bdf818f231d..7b7def205020a6a3b7a45aa5e15e7c10c56a8502 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -5,7 +5,7 @@ use crate::{ outline::{Arrange, OutlineLayout}, }; use onestore::page::text::Paragraph; -use onestore::page::{Image, Ink, Outline, Page, PageObject}; +use onestore::page::{Attachment, Image, Ink, Outline, Page, PageObject}; use std::collections::BTreeMap; /// A title object's own state, plus the child origins `build` replaces with page coordinates. @@ -33,6 +33,11 @@ pub(crate) enum Content { below_title: Option, }, Image(Image), + /// A file on the page, laid out relative to its position. + File { + source: Attachment, + layout: Box, + }, Ink(Ink), Editable(onestore::ExGuid), ReadOnly(Box), @@ -60,22 +65,46 @@ impl Content { )?)) } + /// The page-level picture shown, drawn or as a placeholder. + pub(super) fn picture(&self) -> Option<&Image> { + match self { + Self::Image(image) => Some(image), + Self::ReadOnly(object) => match &object.source { + PageObject::Image(image) => Some(image), + _ => None, + }, + _ => None, + } + } + pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> { match self { Self::Outline { source, .. } => Some((source.id, &source.layout)), Self::Image(source) => Some((source.id, &source.layout)), + Self::File { source, .. } => Some((source.id, &source.layout)), Self::Ink(source) => Some((source.id, &source.layout)), Self::ReadOnly(object) => Some((object.source.id(), object.source.layout())), Self::Date { .. } | Self::Editable(_) => None, } } + /// A file on the page, where its column's `[x0, y0, x1, y1]` lie in page points. + pub(crate) fn file(&self) -> Option<(&Attachment, [f32; 4])> { + let Self::File { source, layout } = self else { + return None; + }; + let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); + let [x0, y0, x1, y1] = layout.bounds(); + Some((source, [x + x0, y + y0, x + x1, y + y1])) + } + pub(super) fn layout_mut( &mut self, ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> { match self { Self::Outline { source, .. } => Some((source.id, &mut source.layout)), Self::Image(source) => Some((source.id, &mut source.layout)), + Self::File { source, .. } => Some((source.id, &mut source.layout)), Self::Ink(source) => Some((source.id, &mut source.layout)), Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())), Self::Date { .. } | Self::Editable(_) => None, @@ -332,6 +361,19 @@ pub(crate) fn build( }; objects.push(Content::Image(source)); } + PageObject::Attachment(source) => { + if [source.layout.x, source.layout.y] + .iter() + .any(|v| !v.unwrap_or(0.0).is_finite()) + { + return Err(EditorError::InvalidGeometry); + } + let layout = Box::new(crate::outline::page_file(engine, source)?); + let PageObject::Attachment(source) = object else { + unreachable!() + }; + objects.push(Content::File { source, layout }); + } PageObject::Ink(ink) => { if ink_bounds(ink).is_some_and(|b| b.iter().any(|v| !v.is_finite())) { return Err(EditorError::InvalidGeometry); diff --git a/crates/canvas/src/editor/space.rs b/crates/canvas/src/editor/space.rs index 5e4a9c13aa74998baa34899594c8f3fba80b2d6c..0b5de50edeb65abfc58b5e5bf49e462728b9203a 100644 --- a/crates/canvas/src/editor/space.rs +++ b/crates/canvas/src/editor/space.rs @@ -5,7 +5,7 @@ use super::*; /// Something Insert Space moves. enum Piece { - /// A body outline, picture or ink drawing, placed anew. + /// A body outline, picture, file or ink drawing, placed anew. Object(ExGuid), /// The root nodes of a body outline from `at` on. Tail { outline: ExGuid, at: usize }, @@ -81,6 +81,7 @@ impl CanvasEditor { ]; (image.id, rect) } + page::Content::File { source, .. } => (source.id, content.file().unwrap().1), page::Content::Ink(ink) => match page::ink_bounds(ink) { Some(rect) => (ink.id, rect), None => continue, diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 34357767a6e35a2022fc831c57e1a7cc93af6743..11694f2fb8d7abeaefe92b7cc1255de7913e54c0 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -4,7 +4,7 @@ mod profile; use crate::{ layout::{TextBrush, TextLayout}, - outline::{BoxMark, TagIcon}, + outline::TagIcon, }; use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError}; use parley::PositionedLayoutItem; @@ -140,116 +140,199 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { .expect("Page text is a TextLayout") } -const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg"); const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); -const STAR: &str = include_str!("../../assets/tags/star.svg"); -const QUESTION: &str = include_str!("../../assets/tags/question.svg"); -const HIGHLIGHT: &str = include_str!("../../assets/tags/highlight.svg"); -const CONTACT: &str = include_str!("../../assets/tags/contact.svg"); -const ADDRESS: &str = include_str!("../../assets/tags/address.svg"); -const PHONE: &str = include_str!("../../assets/tags/phone.svg"); -const MUSIC: &str = include_str!("../../assets/tags/music.svg"); -const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg"); -const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg"); -const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg"); -const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg"); const FLAG: &str = include_str!("../../assets/tags/flag.svg"); -const IDEA: &str = include_str!("../../assets/tags/idea.svg"); -const PASSWORD: &str = include_str!("../../assets/tags/password.svg"); -const MOVIE: &str = include_str!("../../assets/tags/movie.svg"); -const BOOK: &str = include_str!("../../assets/tags/book.svg"); -const WEB: &str = include_str!("../../assets/tags/web.svg"); -const BLOG: &str = include_str!("../../assets/tags/blog.svg"); -const EMAIL: &str = include_str!("../../assets/tags/email.svg"); const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg"); const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg"); -const MARK_PERSON: &str = include_str!("../../assets/tags/mark-person.svg"); -const MARK_MANAGER: &str = include_str!("../../assets/tags/mark-manager.svg"); -const MARK_ARROW: &str = include_str!("../../assets/tags/mark-arrow.svg"); -const MARK_ONE: &str = include_str!("../../assets/tags/mark-one.svg"); -const MARK_TWO: &str = include_str!("../../assets/tags/mark-two.svg"); -const MARK_CLIENT: &str = include_str!("../../assets/tags/mark-client.svg"); const TAG: &str = include_str!("../../assets/tags/tag.svg"); +/// How a tag symbol's artwork takes a check: a large box under it, a small box with the +/// check between the box and its badge, or art that shows none. +enum Art { + Box(&'static str), + Badged(&'static str), + Plain(&'static str), +} + +/// The artwork of symbol `shape` of MS-ONE's NoteTagShape, as OneNote 2010 draws it on the +/// page: it draws 54, Green 8-Point Star, blue there. A shape MS-ONE does not list shows a +/// plain tag. +fn art(shape: u16) -> Art { + use Art::{Badged, Box, Plain}; + macro_rules! tag { + ($name:literal) => { + include_str!(concat!("../../assets/tags/", $name, ".svg")) + }; + } + match shape { + 1 => Box(tag!("checkbox-green")), + 2 => Box(tag!("checkbox-yellow")), + 3 => Box(tag!("checkbox")), + 4 => Badged(tag!("mark-star-green")), + 5 => Badged(tag!("mark-star-yellow")), + 6 => Badged(tag!("mark-star-blue")), + 7 => Badged(tag!("mark-exclamation-green")), + 8 => Badged(tag!("mark-exclamation-yellow")), + 9 => Badged(tag!("mark-exclamation-blue")), + 10 => Badged(tag!("mark-arrow-green")), + 11 => Badged(tag!("mark-arrow-yellow")), + 12 => Badged(tag!("mark-arrow-blue")), + 13 => Plain(tag!("star")), + 14 => Plain(tag!("follow-up")), + 15 => Plain(tag!("question")), + 16 => Plain(tag!("arrow-right-blue")), + 17 => Plain(tag!("exclamation")), + 18 => Plain(tag!("phone")), + 19 => Plain(tag!("calendar")), + 20 => Plain(tag!("clock")), + 21 => Plain(tag!("idea")), + 22 => Plain(tag!("pushpin")), + 23 => Plain(tag!("address")), + 24 => Plain(tag!("blog")), + 25 => Plain(tag!("smiley")), + 26 => Plain(tag!("ribbon")), + 27 => Plain(tag!("key")), + 28 => Badged(tag!("mark-one-blue")), + 29 => Plain(tag!("circle-1-blue")), + 30 => Badged(tag!("mark-two-blue")), + 31 => Plain(tag!("circle-2-blue")), + 32 => Badged(tag!("mark-three-blue")), + 33 => Plain(tag!("circle-3-blue")), + 34 => Plain(tag!("star8-blue")), + 35 => Plain(tag!("tick-blue")), + 36 => Plain(tag!("circle-blue")), + 37 => Plain(tag!("arrow-down-blue")), + 38 => Plain(tag!("arrow-left-blue")), + 39 => Plain(tag!("solid-target-blue")), + 40 => Plain(tag!("star-blue")), + 41 => Plain(tag!("sun-blue")), + 42 => Plain(tag!("target-blue")), + 43 => Plain(tag!("triangle-blue")), + 44 => Plain(tag!("umbrella-blue")), + 45 => Plain(tag!("arrow-up-blue")), + 46 => Plain(tag!("x-dots-blue")), + 47 => Plain(tag!("x-blue")), + 48 => Badged(tag!("mark-one-green")), + 49 => Plain(tag!("circle-1-green")), + 50 => Badged(tag!("mark-two-green")), + 51 => Plain(tag!("circle-2-green")), + 52 => Badged(tag!("mark-three-green")), + 53 => Plain(tag!("circle-3-green")), + 54 => Plain(tag!("star8-blue")), + 55 => Plain(tag!("tick-green")), + 56 => Plain(tag!("circle-green")), + 57 => Plain(tag!("arrow-down-green")), + 58 => Plain(tag!("arrow-left-green")), + 59 => Plain(tag!("arrow-right-green")), + 60 => Plain(tag!("solid-target-green")), + 61 => Plain(tag!("star-green")), + 62 => Plain(tag!("sun-green")), + 63 => Plain(tag!("target-green")), + 64 => Plain(tag!("triangle-green")), + 65 => Plain(tag!("umbrella-green")), + 66 => Plain(tag!("arrow-up-green")), + 67 => Plain(tag!("x-dots-green")), + 68 => Plain(tag!("x-green")), + 69 => Badged(tag!("mark-one-yellow")), + 70 => Plain(tag!("circle-1-yellow")), + 71 => Badged(tag!("mark-two-yellow")), + 72 => Plain(tag!("circle-2-yellow")), + 73 => Badged(tag!("mark-three-yellow")), + 74 => Plain(tag!("circle-3-yellow")), + 75 => Plain(tag!("star8-yellow")), + 76 => Plain(tag!("tick-yellow")), + 77 => Plain(tag!("circle-yellow")), + 78 => Plain(tag!("arrow-down-yellow")), + 79 => Plain(tag!("arrow-left-yellow")), + 80 => Plain(tag!("arrow-right-yellow")), + 81 => Plain(tag!("solid-target-yellow")), + 82 => Plain(tag!("sun-yellow")), + 83 => Plain(tag!("target-yellow")), + 84 => Plain(tag!("triangle-yellow")), + 85 => Plain(tag!("umbrella-yellow")), + 86 => Plain(tag!("arrow-up-yellow")), + 87 => Plain(tag!("x-dots-yellow")), + 88 => Plain(tag!("x-yellow")), + 89 => Plain(tag!("flag-today")), + 90 => Plain(tag!("flag-tomorrow")), + 91 => Plain(tag!("flag-this-week")), + 92 => Plain(tag!("flag-next-week")), + 93 => Plain(tag!("flag-no-date")), + 94 => Badged(tag!("mark-person-blue")), + 95 => Badged(tag!("mark-person-yellow")), + 96 => Badged(tag!("mark-person-green")), + 97 => Badged(tag!("mark-flag-blue")), + 98 => Badged(tag!("mark-flag-yellow")), + 99 => Badged(tag!("mark-flag-green")), + 100 => Plain(tag!("red-square")), + 101 => Plain(tag!("yellow-square")), + 102 => Plain(tag!("blue-square")), + 103 => Plain(tag!("green-square")), + 104 => Plain(tag!("orange-square")), + 105 => Plain(tag!("pink-square")), + 106 => Plain(tag!("email")), + 107 => Plain(tag!("envelope")), + 108 => Plain(tag!("envelope-open")), + 109 => Plain(tag!("mobile")), + 110 => Plain(tag!("phone-clock")), + 111 => Plain(tag!("question-balloon")), + 112 => Plain(tag!("paperclip")), + 113 => Plain(tag!("frown")), + 114 => Plain(tag!("im-contact")), + 115 => Plain(tag!("person")), + 116 => Plain(tag!("people")), + 117 => Plain(tag!("bell")), + 118 => Plain(tag!("contact")), + 119 => Plain(tag!("rose")), + 120 => Plain(tag!("date")), + 121 => Plain(tag!("music")), + 122 => Plain(tag!("movie")), + 123 => Plain(tag!("quote")), + 124 => Plain(tag!("globe")), + 125 => Plain(tag!("web")), + 126 => Plain(tag!("laptop")), + 127 => Plain(tag!("plane")), + 128 => Plain(tag!("car")), + 129 => Plain(tag!("binoculars")), + 130 => Plain(tag!("presentation")), + 131 => Plain(tag!("password")), + 132 => Plain(tag!("book")), + 133 => Plain(tag!("notebook")), + 134 => Plain(tag!("paper")), + 135 => Plain(tag!("research")), + 136 => Plain(tag!("highlight")), + 137 => Plain(tag!("dollar")), + 138 => Plain(tag!("coins")), + 139 => Plain(tag!("schedule")), + 140 => Plain(tag!("lightning")), + 141 => Plain(tag!("cloud")), + 142 => Plain(tag!("heart")), + 143 => Plain(tag!("sunflower")), + _ => Plain(TAG), + } +} + +/// Symbols MS-ONE lists. +const SYMBOLS: u16 = 143; + /// The tag's artwork, drawn in order. pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { - match icon { - TagIcon::CheckBox { - checked: false, - mark: None, - } => &[CHECKBOX], - TagIcon::CheckBox { - checked: true, - mark: None, - } => &[CHECKBOX, CHECKMARK], - TagIcon::CheckBox { - checked: false, - mark: Some(BoxMark::Person), - } => &[BOX_SMALL, MARK_PERSON], - TagIcon::CheckBox { - checked: true, - mark: Some(BoxMark::Person), - } => &[BOX_SMALL, CHECK_SMALL, MARK_PERSON], - TagIcon::CheckBox { - checked: false, - mark: Some(BoxMark::Manager), - } => &[BOX_SMALL, MARK_MANAGER], - TagIcon::CheckBox { - checked: true, - mark: Some(BoxMark::Manager), - } => &[BOX_SMALL, CHECK_SMALL, MARK_MANAGER], - TagIcon::CheckBox { - checked: false, - mark: Some(BoxMark::Arrow), - } => &[BOX_SMALL, MARK_ARROW], - TagIcon::CheckBox { - checked: true, - mark: Some(BoxMark::Arrow), - } => &[BOX_SMALL, CHECK_SMALL, MARK_ARROW], - TagIcon::CheckBox { - checked: false, - mark: Some(BoxMark::One), - } => &[BOX_SMALL, MARK_ONE], - TagIcon::CheckBox { - checked: true, - mark: Some(BoxMark::One), - } => &[BOX_SMALL, CHECK_SMALL, MARK_ONE], - TagIcon::CheckBox { - checked: false, - mark: Some(BoxMark::Two), - } => &[BOX_SMALL, MARK_TWO], - TagIcon::CheckBox { - checked: true, - mark: Some(BoxMark::Two), - } => &[BOX_SMALL, CHECK_SMALL, MARK_TWO], - TagIcon::CheckBox { - checked: false, - mark: Some(BoxMark::Client), - } => &[BOX_SMALL, MARK_CLIENT], - TagIcon::CheckBox { - checked: true, - mark: Some(BoxMark::Client), - } => &[BOX_SMALL, CHECK_SMALL, MARK_CLIENT], - TagIcon::Star => &[STAR], - TagIcon::Question => &[QUESTION], - TagIcon::Highlight => &[HIGHLIGHT], - TagIcon::Contact => &[CONTACT], - TagIcon::Address => &[ADDRESS], - TagIcon::Phone => &[PHONE], - TagIcon::Music => &[MUSIC], - TagIcon::Exclamation => &[EXCLAMATION], - TagIcon::RedSquare => &[RED_SQUARE], - TagIcon::YellowSquare => &[YELLOW_SQUARE], - TagIcon::BlueSquare => &[BLUE_SQUARE], - TagIcon::Flag => &[FLAG], - TagIcon::Idea => &[IDEA], - TagIcon::Password => &[PASSWORD], - TagIcon::Movie => &[MOVIE], - TagIcon::Book => &[BOOK], - TagIcon::Web => &[WEB], - TagIcon::Blog => &[BLOG], - TagIcon::Email => &[EMAIL], - TagIcon::Other => &[TAG], + static SOURCES: std::sync::OnceLock; 2]>> = std::sync::OnceLock::new(); + let TagIcon::Symbol { shape, checked } = icon else { + return &[FLAG]; + }; + let sources = SOURCES.get_or_init(|| { + (0..=SYMBOLS) + .map(|shape| match art(shape) { + Art::Box(art) => [vec![art], vec![art, CHECKMARK]], + Art::Badged(badge) => [vec![BOX_SMALL, badge], vec![BOX_SMALL, CHECK_SMALL, badge]], + Art::Plain(art) => [vec![art], vec![art]], + }) + .collect() + }); + match sources.get(usize::from(shape)) { + Some(sources) => &sources[usize::from(checked)], + None => &[TAG], } } @@ -364,29 +447,26 @@ mod tests { mapped_at_creation: false, }); let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); - let icons = [ - TagIcon::CheckBox { - checked: false, - mark: None, - }, - TagIcon::CheckBox { - checked: true, - mark: None, - }, - TagIcon::Star, - TagIcon::Question, - TagIcon::Highlight, - TagIcon::Contact, - TagIcon::Address, - TagIcon::Phone, - TagIcon::Music, - TagIcon::Exclamation, - TagIcon::RedSquare, - TagIcon::YellowSquare, - TagIcon::BlueSquare, - TagIcon::Flag, - TagIcon::Other, - ]; + let icons: Vec<_> = [ + (3, false), + (3, true), + (13, false), + (15, false), + (136, false), + (118, false), + (23, false), + (18, false), + (121, false), + (17, false), + (100, false), + (101, false), + (102, false), + (999, false), + ] + .into_iter() + .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap()) + .chain([TagIcon::Task]) + .collect(); let text = TextEngine::default() .layout( &Paragraph::from_runs([ diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 3d1f632f8db485567f5015efcf87e0a46aa20552..2c4cdd47853e46e6333419516c432bf8d8922d61 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -79,6 +79,7 @@ pub enum SceneHit { Date(DateField), ReadOnly(usize), Image(onestore::ExGuid), + File(onestore::ExGuid), } fn outline_origin( @@ -139,6 +140,14 @@ fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec< } } +const FILE_ICON: &str = include_str!("../../assets/file.svg"); + +/// The blank page for a file without an icon of its own, as the 32 pixel PNG OneNote 2010 +/// stores with every attachment; it draws a broken picture for one stored without. +pub fn file_icon() -> Vec { + background::png(FILE_ICON, 32) +} + fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec>) { primitives.push(Primitive::Rect { rect, @@ -230,10 +239,9 @@ impl PageScene { } } for (id, art) in &mut scene.backgrounds { - if let Some(shown) = self.backgrounds.remove(id) - && shown.same(art) - { - *art = shown; + match self.backgrounds.remove(id) { + Some(shown) if shown.same(art) => *art = shown, + _ => art.inline = true, } } scene.mark_unavailable(&mut editor.objects, engine)?; @@ -241,6 +249,28 @@ impl PageScene { Ok(()) } + /// Follows `editor` after an edit or undo brought page-level pictures the scene has not + /// seen, as `refresh` does. + pub fn follow( + &mut self, + editor: &mut CanvasEditor, + engine: &mut TextEngine, + ) -> Result<(), SceneError> { + let known = |object: &Content| match object { + Content::Image(source) => { + self.pictures.contains_key(&source.id) || self.backgrounds.contains_key(&source.id) + } + Content::File { source, .. } => { + source.preview.is_none() || self.pictures.contains_key(&source.id) + } + _ => true, + }; + if editor.objects.iter().all(known) { + return Ok(()); + } + self.refresh(editor, engine) + } + /// Turns page pictures the renderer cannot decode into placeholders; their stored data /// is kept. fn mark_unavailable( @@ -275,7 +305,6 @@ impl PageScene { onestore::page::ParagraphContent::Image(image) => { payloads.push((image.id, image.bytes.as_ref())) } - // A file without the icon OneNote rendered for it keeps an empty slot. onestore::page::ParagraphContent::Attachment(file) => { if let Some(icon) = file.preview.as_ref() { payloads.push((file.id, Some(icon))) @@ -303,6 +332,11 @@ impl PageScene { payloads.push((source.id, source.bytes.as_ref())) } } + Content::File { source, .. } => { + if let Some(icon) = source.preview.as_ref() { + payloads.push((source.id, Some(icon))) + } + } Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), Content::Editable(id) => { if let Some(outline) = @@ -396,6 +430,11 @@ impl PageScene { rects.push((source.id, [x, y, x + width, y + height])); } } + Content::File { source, layout } => { + let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); + let [x0, y0, x1, y1] = layout.rect; + rects.push((source.id, [x + x0, y + y0, x + x1, y + y1])); + } Content::Outline { source, layout, @@ -433,40 +472,61 @@ impl PageScene { primitives: &mut Vec>, ) { for object in &outline.objects { - let [x0, y0, x1, y1] = object.rect; - let rect = match moving { - Some((id, rect)) if id == object.id => rect, - _ => [ - x0 + origin[0], - y0 + origin[1], - x1 + origin[0], - y1 + origin[1], - ], - }; - let picture = self.pictures.get(&object.id); - if let Some(image) = picture.and_then(Picture::image) { - primitives.push(Primitive::Image { image, rect }); + self.append_object(object, origin, moving, paper, primitives); + } + } + + /// One picture, file, drawing or placeholder laid out from `origin`; `moving` as for + /// `append_outline_objects`. + fn append_object<'a>( + &'a self, + object: &'a crate::outline::ObjectLayout, + origin: [f32; 2], + moving: Option<(onestore::ExGuid, [f32; 4])>, + paper: Paper, + primitives: &mut Vec>, + ) { + let [x0, y0, x1, y1] = object.rect; + let rect = match moving { + Some((id, rect)) if id == object.id => rect, + _ => [ + x0 + origin[0], + y0 + origin[1], + x1 + origin[0], + y1 + origin[1], + ], + }; + let picture = self.pictures.get(&object.id); + if let Some(image) = picture.and_then(Picture::image) { + primitives.push(Primitive::Image { image, rect }); + } + match &object.kind { + crate::outline::ObjectKind::Ink(ink) => append_ink(ink, [rect[0], rect[1]], primitives), + crate::outline::ObjectKind::Unsupported(_) => { + append_placeholder(rect, paper, primitives) } - match &object.kind { - crate::outline::ObjectKind::Ink(ink) => { - append_ink(ink, [rect[0], rect[1]], primitives) - } - crate::outline::ObjectKind::Unsupported(_) => { - append_placeholder(rect, paper, primitives) - } - crate::outline::ObjectKind::Picture if picture.is_none_or(Picture::failed) => { - append_placeholder(rect, paper, primitives) - } - crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} + crate::outline::ObjectKind::Picture if picture.is_none_or(Picture::failed) => { + append_placeholder(rect, paper, primitives) } - if let Some(label) = object.label() { - primitives.push(Primitive::Text { - text: &label.text, - origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], - clip: None, - ink: paper.ink, - }); + // A file stored without the icon OneNote rendered for it shows a blank page. + crate::outline::ObjectKind::File(_) if picture.is_none() => { + primitives.push(Primitive::Icon { + sources: &[FILE_ICON], + origin: [rect[0], rect[1]], + size: rect[2] - rect[0], + tint: [1.0; 4], + palette: draw::Palette::default(), + }) } + crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} + } + if let Some(label) = object.label() { + primitives.push(Primitive::Text { + text: &label.text, + origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], + clip: None, + ink: paper.ink, + }); } } @@ -558,6 +618,7 @@ impl PageScene { ], false, )), + Content::File { .. } => Some((object.file()?.1, true)), Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)), Content::ReadOnly(object) => Some((object.rect(), true)), Content::Editable(_) => None, @@ -652,6 +713,12 @@ impl PageScene { return Some(SceneHit::Image(source.id)); } } + Content::File { .. } => { + let (file, [x0, y0, x1, y1]) = object.file()?; + if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) { + return Some(SceneHit::File(file.id)); + } + } Content::Outline { .. } | Content::Image(_) | Content::Ink(_) => {} } } @@ -737,13 +804,43 @@ impl PageScene { append_ink(ink, [offset[0] + x, offset[1] + y], primitives); continue; } + Content::File { source, layout } => { + // A dragged file draws where its column would land. + let [x, y] = match moving { + Some((id, [x0, y0, ..])) if id == source.id => [x0, y0], + _ => [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)), + }; + self.append_object( + layout, + [x + offset[0], y + offset[1]], + None, + paper, + primitives, + ); + continue; + } }; let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; match content { Content::Image(source) => { - // The paper shows until the worker's raster lands. let (image, [width, height]) = match self.backgrounds.get(&source.id) { - Some(art) => (art.image(paper), art.size), + Some(art) => { + // OneNote's art is opaque, over white, and hides the rule lines. + primitives.push(Primitive::Rect { + rect: [ + object_origin[0], + object_origin[1], + object_origin[0] + art.size[0], + object_origin[1] + art.size[1], + ], + color: if background::dark(paper) { + paper.color + } else { + Paper::WHITE.color + }, + }); + (art.image(paper), art.size) + } None => { let picture = self .pictures @@ -816,7 +913,10 @@ impl PageScene { ); } } - Content::Editable(_) | Content::ReadOnly(_) | Content::Ink(_) => unreachable!(), + Content::Editable(_) + | Content::ReadOnly(_) + | Content::Ink(_) + | Content::File { .. } => unreachable!(), } } Ok(()) diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index 31c77ad6e93123ce1bcf8bd1ed9386f053b7fc5d..a6456df86cd5ace5be35b78c4eb0d7c65b29daac 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -402,6 +402,10 @@ const MARKER: &str = "Snowbound template"; /// device pixels per point. const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0; +/// Art an edit brings draws in its first frame in at most this many pixels, a few +/// milliseconds' work, then sharpens. +const INLINE_PIXELS: f32 = 1024.0 * 1024.0; + /// Template art standing in for a page's background picture. pub(super) struct Background { svg: &'static str, @@ -409,14 +413,19 @@ pub(super) struct Background { pub size: [f32; 2], /// For light and for dark paper. variants: [Variant; 2], + /// Whether the first raster is made on the frame's thread rather than awaited. + pub inline: bool, } +/// A raster's device pixels per point, and the dark paper it was recoloured for. +type Made = (f32, Option<[f32; 4]>); + #[derive(Default)] struct Variant { - /// The raster painted and its device pixels per point. - shown: Option<(RasterImage, f32)>, - /// The one raster being made and its device pixels per point; replacing it abandons it. - pending: Option<(Slot, f32)>, + /// The raster painted and what it was made for. + shown: Option<(RasterImage, Made)>, + /// The one raster being made and what for; replacing it abandons it. + pending: Option<(Slot, Made)>, } impl Background { @@ -451,6 +460,7 @@ impl Background { svg, size, variants: Default::default(), + inline: false, }) } @@ -469,38 +479,44 @@ impl Background { let density = density(scale) .min((MAX_RASTER_BYTES / (4.0 * width * height)).sqrt()) .min(4096.0 / width.max(height)); + let made = (density, dark.then_some(paper.color)); + let svg = self.svg; + let paper = dark.then_some(paper); + let raster = move |density: f32| { + let size = [width, height].map(|side| (side * density).round().max(1.0) as u32); + move || rasterize(svg, paper, size) + }; let variant = &mut self.variants[usize::from(dark)]; let landed = variant .pending .as_ref() .and_then(|(slot, made)| Some((slot.lock().unwrap().take()?, *made))); - if let Some((image, made)) = landed { - if made == density || variant.shown.is_none() { - variant.shown = Some((image, made)); + if let Some((image, landed)) = landed { + if landed == made || variant.shown.is_none() { + variant.shown = Some((image, landed)); } variant.pending = None; } + if variant.shown.is_none() && std::mem::take(&mut self.inline) { + let quick = density.min((INLINE_PIXELS / (width * height)).sqrt()); + variant.shown = Some((raster(quick)(), (quick, made.1))); + } let shown = variant.shown.as_ref().map(|(_, made)| *made); - if shown != Some(density) - && variant.pending.as_ref().map(|(_, made)| *made) != Some(density) - { - let svg = self.svg; - let paper = dark.then_some(paper); - let size = [width, height].map(|side| (side * density).round().max(1.0) as u32); + if shown != Some(made) && variant.pending.as_ref().map(|(_, made)| *made) != Some(made) { let start = Instant::now() + if shown.is_some() { SETTLE } else { Duration::ZERO }; - let slot = queue(start, waker, move || rasterize(svg, paper, size)); - variant.pending = Some((slot, density)); + let slot = queue(start, waker, raster(density)); + variant.pending = Some((slot, made)); } - shown == Some(density) + shown == Some(made) } } -fn dark(paper: Paper) -> bool { +pub(super) fn dark(paper: Paper) -> bool { let [lightness, ..] = draw::oklab(paper.color); let [ink, ..] = draw::oklab(paper.ink); ink > lightness @@ -514,6 +530,19 @@ fn rasterize(svg: &str, dark: Option, size: [u32; 2]) -> RasterImage { RasterImage::new(size, pixels).expect("Art is within the image budget") } +/// `svg` as a PNG `side` pixels square. +pub(super) fn png(svg: &str, side: u32) -> Vec { + let mut encoded = Vec::new(); + let mut encoder = png::Encoder::new(&mut encoded, side, side); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .and_then(|mut writer| writer.write_image_data(&render(svg, [side; 2]))) + .expect("A PNG encodes into memory"); + encoded +} + /// Straight-alpha RGBA rows of `svg` stretched to `width` by `height` pixels. fn render(svg: &str, [width, height]: [u32; 2]) -> Vec { let tree = parse(svg); @@ -747,6 +776,7 @@ mod tests { svg: TEMPLATES.iter().find(|t| t.name == "ivy").unwrap().svg, size: [64.0, 48.0], variants: Default::default(), + inline: false, } } @@ -805,6 +835,13 @@ mod tests { } } + #[test] + fn every_template_s_art_is_recreated() { + for art in crate::template::TEMPLATES.iter().flat_map(|t| t.art) { + assert!(TEMPLATES.iter().any(|t| t.name == art.art), "{}", art.art); + } + } + #[test] fn our_template_pictures_are_recognised_as_the_template() { for (name, magic, size) in [ diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index 985ce87b8ad0437c98feec39ce977577cc8136f1..c4b2d33ab987b057760be50f3af52345c5923c28 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -594,38 +594,15 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result .tags .iter() .map(|tag| { - use crate::outline::TagIcon; - let fallback = match tag.icon { - TagIcon::CheckBox { .. } => "To do", - TagIcon::Star => "Important", - TagIcon::Question => "Question", - TagIcon::Highlight => "Highlight", - TagIcon::Contact => "Contact", - TagIcon::Address => "Address", - TagIcon::Phone => "Phone number", - TagIcon::Music => "Music", - TagIcon::Exclamation => "Critical", - TagIcon::RedSquare => "Project A", - TagIcon::YellowSquare => "Project B", - TagIcon::BlueSquare => "Project C", - TagIcon::Flag => "Outlook task", - TagIcon::Idea => "Idea", - TagIcon::Password => "Password", - TagIcon::Movie => "Movie to see", - TagIcon::Book => "Book to read", - TagIcon::Web => "Web site to visit", - TagIcon::Blog => "Remember for blog", - TagIcon::Email => "Send in email", - TagIcon::Other => "Tag", - }; - let label = if tag.label.is_empty() { - fallback - } else { - &tag.label + use crate::outline::{TagIcon, symbol_name}; + let label = match tag.icon { + _ if !tag.label.is_empty() => &tag.label, + TagIcon::Symbol { shape, .. } => symbol_name(shape).unwrap_or("Tag"), + TagIcon::Task => "Outlook task", }; let state = match tag.icon { - TagIcon::CheckBox { checked: true, .. } => ", completed", - TagIcon::CheckBox { checked: false, .. } => ", incomplete", + TagIcon::Symbol { checked: true, .. } => ", completed", + icon if icon.checkable() => ", incomplete", _ => "", }; format!( @@ -1026,7 +1003,7 @@ pub(super) mod tests { "Rehearsal, incomplete", "Rehearsal, completed", "Question", - "Music, disabled" + "Music Note, disabled" ] ); let text = state.focus().unwrap().document_range().text(); diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index cd5c7b6c9629a60c9b377173e75c9280b3253083..3710fef7a6b230d0376a9a22f97a5ed71799f09d 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -60,6 +60,8 @@ pub enum Request { Paste, /// Open a link's address, as a click on a link does. OpenLink(String), + /// Open a copy of a file with the system's application for it, as a double click does. + OpenAttachment(onestore::page::Attachment), } /// What a secondary press lands on, which its context menu offers commands for. @@ -72,6 +74,8 @@ pub struct Context { pub selected: bool, /// The paragraph at the caret, which Copy Link to Paragraph names. pub paragraph: Option, + /// The file the press selected, which Open and Save As take. + pub attachment: Option, } /// What an event did: `changed` means the page or selection changed (the host saves, @@ -175,13 +179,15 @@ enum PointerFeedback<'a> { pub enum ObjectFocus { ReadOnly(usize), Image(onestore::ExGuid), + /// A file in an outline's flow or on the page. + File(onestore::ExGuid), } impl ObjectFocus { pub fn read_only(self) -> Option { match self { Self::ReadOnly(index) => Some(index), - Self::Image(_) => None, + Self::Image(_) | Self::File(_) => None, } } } @@ -519,6 +525,7 @@ impl PageView { let (origin, size) = self.editor.image_placement(id).unwrap(); image_rect(origin, size) } + ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(), }; [ x0 + offset[0], @@ -861,7 +868,7 @@ impl PageView { { Cursor::Pointer } - (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default, + (None, Some(Hit::ReadOnly(_) | Hit::Check { .. } | Hit::File(_))) => Cursor::Default, _ => Cursor::Text, } } @@ -998,6 +1005,24 @@ impl PageView { .click_check(&mut self.engine, outline, paragraph)?; return self.changed(); } + Some(Hit::File(id)) => { + self.set_object_focus(Some(ObjectFocus::File(id))); + self.drag = None; + if unit != SelectionUnit::Grapheme + && let Some(file) = self.editor.attachment(id) + { + return Ok(Response::request(Request::OpenAttachment(file.clone()))); + } + // A file on the page drags as a picture does. + if !self.editor.image_in_outline(id) { + self.drag = Some(Drag::Image { + id, + handle: [0, 0], + press: point, + pending_press: Some(self.pointer), + }); + } + } Some(Hit::Image { id, handle }) => { self.set_object_focus(Some(ObjectFocus::Image(id))); self.drag = Some(Drag::Image { @@ -1062,15 +1087,7 @@ impl PageView { }); } None => { - // A fresh click places text 7 px above the pointer, on the grid. - let position = [point[0], point[1] - 7.0 * self.pixel()]; - let position = if self.modifiers.option { - position - } else { - snap_to_grid(position, self.editor.margin_origin()) - }; - self.editor - .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?; + self.place_caret(point)?; self.set_object_focus(None); self.drag = Some(Drag::Text { anchor: self.editor.selection(), @@ -1081,6 +1098,19 @@ impl PageView { self.changed() } + /// A fresh click places text 7 px above the pointer, on the grid. + fn place_caret(&mut self, point: [f32; 2]) -> Result<()> { + let position = [point[0], point[1] - 7.0 * self.pixel()]; + let position = if self.modifiers.option { + position + } else { + snap_to_grid(position, self.editor.margin_origin()) + }; + self.editor + .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?; + Ok(()) + } + pub fn pointer_released(&mut self) -> Result { if let Some(Drag::Space { axis, line }) = self.drag { self.drag = None; @@ -1261,11 +1291,22 @@ impl PageView { } /// A secondary press at the pointer: text there takes the caret unless it lies in the - /// selection, as OneNote's context menu acts where it opens. `None` off text. + /// selection, and a file is selected, as OneNote's context menu acts where it opens. + /// `None` off text and files. pub fn context(&mut self) -> Result> { let point = self.viewport.document_point(self.pointer); - let Some(Hit::Text { id, point }) = self.hit_test(point) else { - return Ok(None); + let (id, point) = match self.hit_test(point) { + Some(Hit::Text { id, point }) => (id, point), + Some(Hit::File(id)) => { + let attachment = self.editor.attachment(id).cloned(); + self.set_object_focus(Some(ObjectFocus::File(id))); + let context = Context { + attachment, + ..Context::default() + }; + return Ok(Some((self.changed()?, context))); + } + _ => return Ok(None), }; if self.object_focus.is_some() || !self.accepts_text() { return Ok(None); @@ -1296,6 +1337,7 @@ impl PageView { .document() .leaf(anchor.min(focus).paragraph) .map(|(_, _, node)| node.id), + attachment: None, }; Ok(Some((self.changed()?, context))) } @@ -1363,6 +1405,44 @@ impl PageView { self.edited() } + /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon + /// takes the page's blank one. + pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result { + if !self.accepts_text() || self.editor.marked_range().is_some() { + return Ok(Response::default()); + } + file.preview + .get_or_insert_with(|| crate::gpu::page::file_icon().into()); + self.editor.insert_attachment(&mut self.engine, file)?; + self.follow_pictures()?; + self.edited() + } + + /// A file dropped at view point `position`, in device pixels: text or blank page there + /// takes the caret as a click would, then the file is attached at the caret, on the page + /// where the caret is on blank page. + pub fn drop_attachment( + &mut self, + position: [f32; 2], + file: onestore::page::Attachment, + ) -> Result { + let point = self.viewport.document_point(position); + self.set_object_focus(None); + match self.hit_test(point) { + Some(Hit::Text { id, point }) => { + self.editor.focus_outline(id)?; + self.editor.select_below(&mut self.engine, id, point)?; + let at = self + .editor + .selection_at(point[0], point[1], SelectionUnit::Grapheme)?; + self.editor.select(at)?; + } + None => self.place_caret(point)?, + _ => {} + } + self.insert_attachment(file) + } + /// Professional, or Linear with `linear`, on the equation at the caret. pub fn switch_equation(&mut self, linear: bool) -> Result { if linear { @@ -1380,7 +1460,8 @@ impl PageView { } else { self.editor.undo(&mut self.engine)?; } - if let Some(ObjectFocus::Image(id)) = self.object_focus + self.follow_pictures()?; + if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus && self.editor.image_placement(id).is_none() { self.set_object_focus(None); @@ -1404,18 +1485,29 @@ impl PageView { self.changed() } - /// Gives the page a colour (COLORREF) and rule lines, as one undo step. + /// Gives the page a colour (COLORREF), rule lines and, when given, `art` in place of its + /// background pictures, as one undo step: see [`CanvasEditor::set_paper`]. pub fn set_paper( &mut self, color: Option, rule_lines: Option, + art: Option>, ) -> Result { - if !self.editor.set_paper(color, rule_lines) { + if !self.editor.set_paper(color, rule_lines, art) { return Ok(Response::default()); } + self.follow_pictures()?; self.changed() } + /// Draws page-level pictures an edit or undo brought to the page. + fn follow_pictures(&mut self) -> Result<()> { + if let Some((scene, _)) = &mut self.scene { + scene.follow(&mut self.editor, &mut self.engine)?; + } + Ok(()) + } + /// Gives keyboard focus to a read-only object, as an assistive technology asks. pub fn focus_read_only(&mut self, index: usize) -> Result { self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); @@ -1439,14 +1531,14 @@ impl PageView { if key == &Key::Named(NamedKey::Modifier) { return Ok(Response::default()); } - if let Some(ObjectFocus::Image(id)) = self.object_focus + if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) { self.editor.remove_image(&mut self.engine, id)?; self.set_object_focus(None); return self.changed(); } - if let Some(ObjectFocus::Image(id)) = self.object_focus + if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus && !(shift || command || option || control) && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key && self.editor.step_from_image( @@ -1671,6 +1763,8 @@ pub enum Hit { id: onestore::ExGuid, handle: [i8; 2], }, + /// A file in an outline's flow or on the page, its icon or name. + File(onestore::ExGuid), } /// What a document point lands on; `pixel` is document points per device pixel. @@ -1762,7 +1856,7 @@ fn page_hit( paragraph.tags.iter().any(|tag| { let tag_x = left + shaped.tag_column_offset() + tag.origin[0]; let tag_y = top + paragraph.origin[1] + tag.origin[1]; - matches!(tag.icon, crate::outline::TagIcon::CheckBox { .. }) + tag.icon.checkable() && (tag_x..=tag_x + tag.size).contains(&x) && (tag_y..=tag_y + tag.size).contains(&y) }) @@ -1791,6 +1885,14 @@ fn page_hit( handle: [0, 0], }); } + if let Some(file) = outline.shaped().objects.iter().find(|object| { + let [x0, y0, x1, y1] = object.bounds(); + matches!(object.kind, crate::outline::ObjectKind::File(_)) + && (x0..=x1).contains(&inner[0]) + && (y0..=y1).contains(&inner[1]) + }) { + return Some(Hit::File(file.id)); + } if x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3] { return Some(Hit::Text { id: outline.id, @@ -1826,6 +1928,7 @@ fn page_hit( crate::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)), crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), + crate::gpu::page::SceneHit::File(id) => Some(Hit::File(id)), } }; [Layer::Grips, Layer::Body, Layer::Below] @@ -2010,6 +2113,29 @@ fn page_primitives<'a>( }; append_image_chrome(image_rect(origin, size), paint.pixel, &mut primitives); } + // OneNote shades a selected file's column and frames it with dashes. + if let Some(ObjectFocus::File(id)) = object_focus { + let rect = match preview { + Some(PointerFeedback::Image(moving, origin, size)) if moving == id => { + image_rect(origin, size) + } + _ => editor + .attachment_rect(id) + .ok_or("The selected file is missing.")?, + }; + primitives.extend([ + Primitive::Rect { + rect, + color: [0.0, 0.0, 0.0, 0.08], + }, + Primitive::RoundedRect { + rect, + radius: [0.0; 2], + stroke: Some(Stroke::Dashed(paint.pixel)), + color: [0.25, 0.45, 0.7, 1.0], + }, + ]); + } if let Some(ObjectFocus::ReadOnly(index)) = object_focus { let (scene, offset) = scene.unwrap(); let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 57f461e852be7c992b982e25a044dd97e19eab62..c6993eb6170129ba4863c6a924edf79523186833 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -1805,7 +1805,7 @@ fn outline_chrome_grows_left_to_contain_its_tag_column() { assert!(frame[1] < y && y + size < frame[3], "{frame:?} {y}"); // OneNote 2010 shows an arrow over a check box, which a click toggles. let hit = page_hit_test(editor, None, [x + 1.0, y + 1.0], pixel); - if matches!(tag.icon, crate::outline::TagIcon::CheckBox { .. }) { + if tag.icon.checkable() { assert_eq!( hit, Some(Hit::Check { @@ -1821,18 +1821,30 @@ fn outline_chrome_grows_left_to_contain_its_tag_column() { }; check(&editor, 0.0); editor - .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + ) .unwrap(); check(&editor, 1.0); editor - .format(&mut engine, Formatting::Tag(NoteTag::Question)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[2].clone(), 2), + ) .unwrap(); check(&editor, 2.0); editor - .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + ) .unwrap(); editor - .format(&mut engine, Formatting::Tag(NoteTag::Question)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[2].clone(), 2), + ) .unwrap(); check(&editor, 0.0); } @@ -1851,7 +1863,10 @@ fn a_click_on_a_check_box_toggles_it_under_an_arrow() { let mut editor = CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); editor - .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + ) .unwrap(); let outline = editor.active_outline(); let (_, paragraph) = outline.layouts().next().unwrap(); @@ -1923,7 +1938,10 @@ fn a_page_opens_where_onenote_places_the_view() { CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); if tag { editor - .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .format( + &mut engine, + Formatting::Tag(NoteTag::defaults()[0].clone(), 0), + ) .unwrap(); } let reached = reach(editor.active_outline()); @@ -2625,3 +2643,242 @@ fn rule_lines_start_at_the_margin_origin() { [0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 96.0] ); } + +/// Template art the Page Color menu gives draws in the frame after, over the rule lines as +/// OneNote's opaque pictures hide them, and follows undo and redo without a reload. +#[test] +fn art_draws_at_once_over_the_rule_lines_and_follows_undo() { + let mut engine = TextEngine::default(); + let outline = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Notes".into(), Format::default())]).unwrap(), + 300.0, + [300.0, 100.0], + ) + .unwrap() + .snapshot(); + let page = Page { + identity: None, + title: String::new(), + created: None, + margin_origin: [36.0, 14.4], + color: None, + rule_lines: Some(crate::template::RULE_LINES[2].1), + definitions: Default::default(), + objects: vec![onestore::page::PageObject::Outline(outline)], + }; + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let mut view = PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + [800, 600], + 1.0, + Duration::from_millis(500), + ); + let ivy = crate::template::find("Ivy").unwrap(); + let art = ivy.pictures().unwrap(); + let [x, y] = ivy.art[0].position; + let [width, height] = ivy.art[0].size.unwrap(); + let drawn = |view: &mut PageView, paper: Paper| { + view.update_pictures(paper, std::task::Waker::noop()); + let primitives = view.primitives(TextColors { paper, ..COLORS }).unwrap(); + let backing = primitives.iter().position(|primitive| { + matches!(primitive, Primitive::Rect { rect, color } + if *rect == [x, y, x + width, y + height] && *color == paper.color) + }); + let image = primitives + .iter() + .position(|primitive| matches!(primitive, Primitive::Image { .. })); + let lines = primitives.iter().rposition( + |primitive| matches!(primitive, Primitive::Rect { rect, .. } if rect[0] < x), + ); + (backing, image, lines) + }; + let _ = view + .set_paper(None, view.editor.rule_lines(), Some(art)) + .unwrap(); + let (backing, image, lines) = drawn(&mut view, Paper::WHITE); + assert!( + lines < backing && backing < image, + "{lines:?} {backing:?} {image:?}" + ); + let dark = Paper { + color: [0.0137, 0.0144, 0.0159, 1.0], + ink: [0.791, 0.791, 0.791, 1.0], + }; + // Dark paper hides the lines in its own colour while its recolouring is made. + assert!(matches!(drawn(&mut view, dark), (Some(_), None, _))); + let _ = view.undo(false).unwrap(); + assert!(matches!(drawn(&mut view, Paper::WHITE), (None, None, _))); + let _ = view.undo(true).unwrap(); + assert!(matches!( + drawn(&mut view, Paper::WHITE), + (Some(_), Some(_), _) + )); +} + +#[test] +fn an_attached_file_selects_opens_on_a_double_click_and_deletes() { + let (mut view, _) = picture_view(); + let now = Instant::now(); + let file = onestore::page::Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: "notes.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + layout: Default::default(), + bytes: Some(Arc::from(b"notes".as_slice())), + preview: None, + recording: None, + }; + view.editor + .move_selection(&mut view.engine, Movement::DocumentEnd, false) + .unwrap(); + assert!(view.insert_attachment(file.clone()).unwrap().changed); + let stored = view.editor.attachment(file.id).unwrap().clone(); + // OneNote draws a broken picture for a file stored without its icon. + assert!(stored.preview.as_ref().unwrap().starts_with(b"\x89PNG")); + view.primitives(COLORS).unwrap(); + + let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap(); + let center = [(x0 + x1) / 2.0, (y0 + y1) / 2.0]; + click(&mut view, center, now); + assert_eq!(view.object_focus(), Some(ObjectFocus::File(file.id))); + assert!(!view.accepts_text()); + view.primitives(COLORS).unwrap(); + let _ = view.pointer_moved(center).unwrap(); + assert_eq!( + view.pointer_pressed(now + Duration::from_millis(100)) + .unwrap() + .request, + Some(Request::OpenAttachment(stored.clone())) + ); + let _ = view.pointer_released().unwrap(); + let (_, context) = view.context().unwrap().unwrap(); + assert_eq!(context.attachment, Some(stored)); + + assert!( + view.key(&Key::Named(NamedKey::Delete), None) + .unwrap() + .changed + ); + assert!(view.editor.attachment(file.id).is_none()); + assert!(view.accepts_text()); +} + +/// OneNote 2010 puts a file attached or dropped on blank page on the page there, where it +/// selects, drags on the grid, opens and deletes as one (`corpus/attachment-floating`). +#[test] +fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() { + use onestore::op::PageOp; + let mut engine = TextEngine::default(); + let document = + TextDocument::new(vec![Paragraph::new("Text".into(), Default::default())]).unwrap(); + let outline = TextOutline::new(&mut engine, document, 240.0, [36.0, 36.0]).unwrap(); + let page = Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + color: None, + rule_lines: None, + definitions: Default::default(), + objects: vec![onestore::page::PageObject::Outline(outline.snapshot())], + }; + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let mut view = PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + [800, 600], + 1.0, + Duration::from_millis(500), + ); + view.viewport.scale = 1.0; + view.viewport.origin = [0.0; 2]; + let _ = view.editor.take_ops().unwrap(); + let file = onestore::page::Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: "float.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + layout: Default::default(), + bytes: Some(Arc::from(b"float".as_slice())), + preview: None, + recording: None, + }; + let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap(); + assert_eq!(view.editor.outlines().len(), 1); + let placed = |view: &PageView| { + view.editor + .page() + .unwrap() + .objects + .into_iter() + .find_map(|object| match object { + onestore::page::PageObject::Attachment(file) => Some(file), + _ => None, + }) + }; + // At the caret a click there places, on the 18 pt grid from the margin origin. + let stored = placed(&view).unwrap(); + assert_eq!( + [stored.layout.x, stored.layout.y], + [Some(396.0), Some(392.4)] + ); + assert_eq!(stored.layout.max_width, Some(54.0)); + assert!(matches!( + &view.editor.take_ops().unwrap()[..], + [PageOp::Add { object: onestore::page::PageObject::Attachment(added), before: None }] + if added.id == file.id + )); + assert!( + view.primitives(COLORS) + .unwrap() + .iter() + .any(|primitive| matches!(primitive, Primitive::Text { .. })) + ); + + let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap(); + let center = [(x0 + x1) / 2.0, (y0 + y1) / 2.0]; + let now = Instant::now(); + let _ = view.pointer_moved(center).unwrap(); + let _ = view.pointer_pressed(now).unwrap(); + assert_eq!(view.object_focus(), Some(ObjectFocus::File(file.id))); + let _ = view + .pointer_moved([center[0] + 100.0, center[1] + 50.0]) + .unwrap(); + view.primitives(COLORS).unwrap(); + let _ = view.pointer_released().unwrap(); + let moved = placed(&view).unwrap(); + assert_eq!([moved.layout.x, moved.layout.y], [Some(504.0), Some(446.4)]); + assert!(matches!( + &view.editor.take_ops().unwrap()[..], + [PageOp::Outline { object, .. }] if *object == file.id + )); + let (_, context) = view.context().unwrap().unwrap(); + assert_eq!(context.attachment.map(|found| found.id), Some(file.id)); + + let _ = view.undo(false).unwrap(); + assert_eq!(placed(&view).unwrap().layout.x, Some(396.0)); + let _ = view.undo(true).unwrap(); + assert_eq!(placed(&view).unwrap().layout.x, Some(504.0)); + let _ = view.editor.take_ops().unwrap(); + + let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap(); + click( + &mut view, + [(x0 + x1) / 2.0, (y0 + y1) / 2.0], + now + Duration::from_secs(1), + ); + let _ = view.key(&Key::Named(NamedKey::Delete), None).unwrap(); + assert!(placed(&view).is_none()); + assert!(matches!( + &view.editor.take_ops().unwrap()[..], + [PageOp::Delete { object }] if *object == file.id + )); + let _ = view.undo(false).unwrap(); + assert_eq!(placed(&view).unwrap().layout.x, Some(504.0)); + view.primitives(COLORS).unwrap(); +} diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index bd618c4f7e39e7696886f475f06d0eee6ec9237d..eb80186eabebc1c959cce5a20d6043a1347cc699 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -87,6 +87,19 @@ impl ObjectLayout { } } + /// The whole object, a file's column with its name included. + pub fn bounds(&self) -> [f32; 4] { + match &self.kind { + ObjectKind::File(label) => [ + label.origin[0], + self.rect[1] - 6.0, + label.origin[0] + ATTACHMENT_WIDTH, + self.bottom, + ], + _ => [self.rect[0], self.rect[1], self.rect[2], self.bottom], + } + } + /// The rect's top and bottom, the object's bottom and its label's top once the object /// starts at `y`, given its own height, and the height it takes in the flow. fn at(&self, y: f32, height: f32) -> ([f32; 4], f32) { @@ -209,93 +222,192 @@ pub struct ParagraphLayout { pub band: Option, } +/// How the page draws a note tag. #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum TagIcon { - CheckBox { - checked: bool, - /// The badge on the small box of OneNote's check box variants. - mark: Option, - }, - Star, - Question, - Highlight, - Contact, - Address, - Phone, - Music, - Exclamation, - RedSquare, - YellowSquare, - BlueSquare, - /// An Outlook task flag. - Flag, - Idea, - Password, - Movie, - Book, - Web, - Blog, - Email, - /// A tag symbol without artwork of its own. - Other, -} - -/// What OneNote draws on a check box variant's small box. -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -pub enum BoxMark { - Person, - Manager, - Arrow, - One, - Two, - Client, + /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box. + Symbol { shape: u16, checked: bool }, + /// An Outlook task, which OneNote 2010 flags. + Task, } impl TagIcon { /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for /// shape 0, which marks the text instead. pub fn of(shape: u16, checked: bool) -> Option { - Some(match shape { - 0 => return None, - // OneNote's check boxes, plain and with a person, arrow, number or bulb. - 3 => Self::CheckBox { - checked, - mark: None, - }, - 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox { - checked, - mark: Some(match shape { - 8 => BoxMark::Client, - 12 => BoxMark::Arrow, - 28 => BoxMark::One, - 71 => BoxMark::Two, - 94 => BoxMark::Person, - _ => BoxMark::Manager, - }), - }, - 13 => Self::Star, - 15 => Self::Question, - 17 => Self::Exclamation, - 18 => Self::Phone, - 23 => Self::Address, - 118 => Self::Contact, - 136 => Self::Highlight, - 100 => Self::RedSquare, - 101 => Self::YellowSquare, - 102 => Self::BlueSquare, - 121 => Self::Music, - 21 => Self::Idea, - 131 => Self::Password, - 122 => Self::Movie, - 132 => Self::Book, - 125 => Self::Web, - 24 => Self::Blog, - 106 => Self::Email, - _ => Self::Other, + (shape != 0).then_some(Self::Symbol { + shape, + checked: checked && checkable(shape), }) } + + pub fn checkable(self) -> bool { + matches!(self, Self::Symbol { shape, .. } if checkable(shape)) + } } +/// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them. +pub fn checkable(shape: u16) -> bool { + matches!( + shape, + 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99 + ) +} + +/// OneNote 2010's name for tag symbol `shape`, as its symbol gallery's tooltips give it; the +/// follow-up flags it keeps for Outlook tasks take MS-ONE's descriptions. +pub fn symbol_name(shape: u16) -> Option<&'static str> { + SYMBOL_NAMES + .get(usize::from(shape).checked_sub(1)?) + .copied() +} + +const SYMBOL_NAMES: [&str; 143] = [ + "Green Check Box", + "Yellow Check Box", + "Blue Check Box", + "Green Star Check Box", + "Yellow Star Check Box", + "Blue Star Check Box", + "Green Exclamation Check Box", + "Yellow Exclamation Check Box", + "Blue Exclamation Check Box", + "Green Arrow Check Box", + "Yellow Arrow Check Box", + "Blue Arrow Check Box", + "Yellow Star", + "Follow-up Flag", + "Question", + "Blue Right Arrow", + "High Priority", + "Telephone", + "Calendar", + "Clock", + "Light Bulb", + "Pushpin", + "Home", + "Comment", + "Smiley", + "Award Ribbon", + "Key", + "Blue Check Box 1", + "Blue Circle 1", + "Blue Check Box 2", + "Blue Circle 2", + "Blue Check Box 3", + "Blue Circle 3", + "Blue 8-Point Star", + "Blue Check Mark", + "Blue Circle", + "Blue Down Arrow", + "Blue Left Arrow", + "Blue Solid Target", + "Blue Star", + "Blue Sun", + "Blue Target", + "Blue Triangle", + "Blue Umbrella", + "Blue Up Arrow", + "Blue X with Dots", + "Blue X", + "Green Check Box 1", + "Green Circle 1", + "Green Check Box 2", + "Green Circle 2", + "Green Check Box 3", + "Green Circle 3", + "Green 8-Point Star", + "Green Check Mark", + "Green Circle", + "Green Down Arrow", + "Green Left Arrow", + "Green Right Arrow", + "Green Solid Target", + "Green Star", + "Green Sun", + "Green Target", + "Green Triangle", + "Green Umbrella", + "Green Up Arrow", + "Green X with Dots", + "Green X", + "Yellow Check Box 1", + "Yellow Circle 1", + "Yellow Check Box 2", + "Yellow Circle 2", + "Yellow Check Box 3", + "Yellow Circle 3", + "Yellow 8-Point Star", + "Yellow Check Mark", + "Yellow Circle", + "Yellow Down Arrow", + "Yellow Left Arrow", + "Yellow Right Arrow", + "Yellow Solid Target", + "Yellow Sun", + "Yellow Target", + "Yellow Triangle", + "Yellow Umbrella", + "Yellow Up Arrow", + "Yellow X with Dots", + "Yellow X", + "Follow Up Today Flag", + "Follow Up Tomorrow Flag", + "Follow Up This Week Flag", + "Follow Up Next Week Flag", + "No Follow Up Date Flag", + "To Do Blue Person", + "To Do Yellow Person", + "To Do Green Person", + "To Do Blue Flag", + "To Do Yellow Flag", + "To Do Green Flag", + "Red Square (Project A)", + "Yellow Square (Project B)", + "Blue Square (Project C)", + "Green Square", + "Orange Square", + "Pink Square", + "E-mail", + "Envelope (Closed)", + "Envelope (Opened)", + "Mobile phone", + "Telephone with Clock", + "Question Balloon", + "Paperclip", + "Frowning", + "IM contact", + "Person", + "Two People", + "Reminder Bell", + "Contact (like Outlook's)", + "Flowers Bouquet", + "Date", + "Music Note", + "Movie Clip", + "Quote Mark", + "Globe", + "Link Globe", + "Laptop", + "Plane", + "Car", + "Binoculars", + "Presentation", + "Padlock", + "Book (Open)", + "Notebook Icon", + "Paper (blank with lines)", + "Research Icon", + "Marker", + "Dollar sign $", + "Coins with window behind it", + "Schedule Task", + "Lighting Bolt", + "Cloud", + "Heart", + "Flower", +]; + #[derive(Clone, Debug, PartialEq)] pub struct ParagraphTag { pub icon: TagIcon, @@ -367,6 +479,72 @@ fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParag } } +/// A file's column at `x`, before it is placed: its icon centred over its name, which OneNote +/// shows without the extension; and the icon's height. +fn file_column( + file: &onestore::page::Attachment, + paragraph: ExGuid, + format: &Format, + x: f32, + shape: &mut impl FnMut( + &PageParagraph, + Option<&Count>, + f32, + &[f32], + ) -> Result, +) -> Result<(ObjectLayout, f32), LayoutError> { + let [w, h] = file.size.unwrap_or([24.0, 24.0]); + let left = x + (ATTACHMENT_WIDTH - w) / 2.0; + let name = std::path::Path::new(&file.filename) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(&file.filename); + let format = Format { + alignment: Some(1), + ..format.clone() + }; + let mut label = shape( + &caption(paragraph, file.id, name, format), + None, + ATTACHMENT_WIDTH, + &[0.0, 0.0], + )?; + label.reset_origin(x); + let object = ObjectLayout { + id: file.id, + rect: [left, 0.0, left + w, 0.0], + kind: ObjectKind::File(label), + bottom: 0.0, + }; + Ok((object, h)) +} + +/// A file on the page as OneNote draws one, relative to its position: the column a +/// paragraph's file takes, with its text in the page's default format. +pub(crate) fn page_file( + engine: &mut TextEngine, + file: &onestore::page::Attachment, +) -> Result { + let (mut object, height) = file_column( + file, + file.id, + &Format::default(), + 0.0, + &mut |paragraph, previous, width, indents| { + ParagraphLayout::shape( + engine, + paragraph, + previous, + width, + indents, + &BTreeMap::new(), + ) + }, + )?; + object.place(0.0, height); + Ok(object) +} + /// A picture's displayed size: the user-set layout size, else its intrinsic size. pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> { let size = [ @@ -887,7 +1065,7 @@ impl ParagraphLayout { for tag in &tag_definitions { let (tag, shape, label) = (tag.0, tag.1, tag.2); let icon = match shape { - None => TagIcon::Flag, + None => TagIcon::Task, Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) { Some(icon) => icon, None => continue, @@ -1149,30 +1327,8 @@ impl OutlineLayout { (object, h, x + w) } ParagraphContent::Attachment(file) => { - let [w, h] = file.size.unwrap_or([24.0, 24.0]); - let left = x + (ATTACHMENT_WIDTH - w) / 2.0; - // OneNote shows the name without its extension. - let name = std::path::Path::new(&file.filename) - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or(&file.filename); - let format = Format { - alignment: Some(1), - ..node.format.clone() - }; - let mut label = shape( - &caption(node.id, file.id, name, format), - None, - ATTACHMENT_WIDTH, - &[0.0, 0.0], - )?; - label.reset_origin(x); - let object = ObjectLayout { - id: file.id, - rect: [left, 0.0, left + w, 0.0], - kind: ObjectKind::File(label), - bottom: 0.0, - }; + let (object, h) = + file_column(file, node.id, &node.format, x, shape)?; (object, h, x + ATTACHMENT_WIDTH) } ParagraphContent::Ink(ink) => { @@ -2092,26 +2248,7 @@ mod tests { task_id: None, extra_set: 0, }); - for (shape, expected) in [ - ( - 3, - TagIcon::CheckBox { - checked: true, - mark: None, - }, - ), - (13, TagIcon::Star), - (15, TagIcon::Question), - (17, TagIcon::Exclamation), - (18, TagIcon::Phone), - (23, TagIcon::Address), - (118, TagIcon::Contact), - (136, TagIcon::Highlight), - (100, TagIcon::RedSquare), - (101, TagIcon::YellowSquare), - (102, TagIcon::BlueSquare), - (121, TagIcon::Music), - ] { + for shape in [3, 13, 15, 17, 18, 23, 118, 136, 100, 101, 102, 121] { definitions.insert( id, Definition { @@ -2134,7 +2271,13 @@ mod tests { &definitions, ) .unwrap(); - assert_eq!(tagged.tags[0].icon, expected); + assert_eq!( + tagged.tags[0].icon, + TagIcon::Symbol { + shape, + checked: shape == 3 + } + ); let baseline = tagged.text.lines().next().unwrap().1.baseline; assert_eq!( tagged.tags[0].origin, @@ -2203,12 +2346,18 @@ mod tests { ) .unwrap() }; - // A symbol without artwork shows as a plain tag, an Outlook task as its flag, and a - // tag whose definition is missing as nothing. - assert_eq!(shaped(&node, &definitions).tags[0].icon, TagIcon::Other); + // A symbol MS-ONE does not list keeps its number, an Outlook task shows its flag, and + // a tag whose definition is missing shows nothing. + assert_eq!( + shaped(&node, &definitions).tags[0].icon, + TagIcon::Symbol { + shape: 999, + checked: false + } + ); let mut task = node.clone(); task.text_mut().unwrap().tags[0].status |= 4; - assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Flag); + assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Task); let mut untagged = definitions.clone(); untagged.remove(&id); assert!(shaped(&node, &untagged).tags.is_empty()); @@ -2431,6 +2580,7 @@ mod tests { filename: "notes 🦀.txt".into(), source_path: None, size: Some([24.0, 24.0]), + layout: Default::default(), bytes: None, preview: None, recording: None, @@ -2542,7 +2692,10 @@ mod tests { .iter() .map(|tag| (tag.icon, tag.origin[0])) .collect::>(), - [(TagIcon::RedSquare, -20.25), (TagIcon::Question, -8.25)] + [ + (TagIcon::of(100, false).unwrap(), -20.25), + (TagIcon::of(15, false).unwrap(), -8.25) + ] ); let colors: Vec<_> = shaped .text diff --git a/crates/canvas/src/template.rs b/crates/canvas/src/template.rs index 51a071667f2245783b7a9c62c09351fa56c69540..606d505950712fd2d0ccf4b3b576aec621ca97d7 100644 --- a/crates/canvas/src/template.rs +++ b/crates/canvas/src/template.rs @@ -21,6 +21,36 @@ pub struct Template { pub art: &'static [Art], } +impl Template { + /// The background pictures a page stores for this template: OneNote's places, our + /// recreations' bytes. + #[cfg(feature = "gpu")] + pub fn pictures(&self) -> Result, onestore::page::text::EditError> { + self.art + .iter() + .map(|art| { + let bytes = crate::gpu::page::template_picture(art.art) + .expect("Every template's art is recreated"); + Ok(onestore::page::Image { + id: onestore::page::text::new_id()?, + layout: onestore::document::Layout { + x: Some(art.position[0]), + y: Some(art.position[1]), + max_width: art.size.map(|size| size[0]), + max_height: art.size.map(|size| size[1]), + width_set_by_user: art.size.map(|_| true), + ..Default::default() + }, + size: art.size, + bytes: Some(bytes.into()), + alt: None, + background: true, + }) + }) + .collect() + } +} + /// OneNote 2010's Decorative templates and Informal Meeting Notes (resources/onenote-templates.md), /// in the order the gallery lists them. pub const TEMPLATES: &[Template] = &[ diff --git a/crates/canvas/tests/attachment_insert.rs b/crates/canvas/tests/attachment_insert.rs new file mode 100644 index 0000000000000000000000000000000000000000..8151120e78c001d61e3e6d91ca078e58b0a03faf --- /dev/null +++ b/crates/canvas/tests/attachment_insert.rs @@ -0,0 +1,256 @@ +//! Files attached through the editor, as Snowbound's Attach File and drops store them: the +//! ops the editor records seal into a section that reads back with the exact bytes. +//! `CANVAS_ATTACHMENT_EXPORT` names a directory receiving that section as a notebook, for +//! a cold reopen in OneNote 2010 (`corpus/attachment-insert`), and `CANVAS_FLOATING_EXPORT` +//! the section of files attached on blank page (`corpus/attachment-floating`). + +use canvas::{editor::CanvasEditor, layout::TextEngine}; +use draw::edit::Movement; +use onestore::{ + Arena, Section, Store, + op::{Edit, Op}, + page::{Attachment, PageObject, ParagraphContent}, +}; +use std::sync::Arc; + +/// The icon OneNote 2010 stored with a text file (`corpus/native/cold-05-06-attachment`). +fn native_icon() -> Arc<[u8]> { + let native = + include_bytes!("../../../corpus/native/cold-05-06-attachment/notebook/synthetic.one"); + let arena = Arena::default(); + let mut section = Section::open(&arena, native.to_vec()).unwrap(); + let (space, ..) = section.pages().unwrap()[0].clone(); + let page = section.page(space).unwrap(); + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(&outline.paragraphs), + _ => None, + }) + .flatten() + .find_map(|paragraph| match ¶graph.content { + ParagraphContent::Attachment(file) => file.preview.clone(), + _ => None, + }) + .unwrap() +} + +/// Two mebibytes no compressor shortens, larger than any buffer the writer fills at once. +fn large() -> Vec { + let mut state = 0x2545_f491_4f6c_dd1d_u64; + (0..2 << 20) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state as u8 + }) + .collect() +} + +fn file(name: &str, bytes: Vec, preview: Option>) -> Attachment { + Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: name.into(), + source_path: Some(format!("/Users/snowbound/Documents/{name}")), + size: Some([24.0, 24.0]), + layout: Default::default(), + bytes: Some(bytes.into()), + preview, + recording: None, + } +} + +#[test] +fn attached_files_store_their_bytes_between_the_text_around_them() { + let source = onestore::create_section("files.one", "Before the file", "Author").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.clone()).unwrap(); + let (space, ..) = section.pages().unwrap()[0].clone(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + editor + .move_selection(&mut engine, Movement::DocumentEnd, false) + .unwrap(); + let small = file( + "notes 🦀.txt", + b"Snowbound attached this file.\n".to_vec(), + Some(native_icon()), + ); + // The icon a host without the system's draws, which `PageView::insert_attachment` gives. + let large = file( + "large.bin", + large(), + Some(canvas::gpu::page::file_icon().into()), + ); + let mut at = 133_000_000_000_000_000; + let mut store = |editor: &mut CanvasEditor| { + let ops = editor.take_ops().unwrap(); + assert!(!ops.is_empty()); + at += 10_000_000; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Author", &Edit { at, ops }).unwrap(); + }; + editor + .insert_attachment(&mut engine, small.clone()) + .unwrap(); + store(&mut editor); + editor.insert(&mut engine, "After the file").unwrap(); + store(&mut editor); + editor + .insert_attachment(&mut engine, large.clone()) + .unwrap(); + store(&mut editor); + let mut image = source; + section.seal().unwrap().unwrap().apply(&mut image).unwrap(); + + let arena = Arena::default(); + let reopened = Section::open(&arena, image.clone()).unwrap(); + let page = reopened.page(space).unwrap(); + let body = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .unwrap(); + let shown: Vec = body + .paragraphs + .iter() + .map(|paragraph| match ¶graph.content { + ParagraphContent::Text(text) => text.text.text().to_owned(), + ParagraphContent::Attachment(file) => { + let expected = [&small, &large] + .into_iter() + .find(|expected| expected.id == file.id) + .unwrap(); + assert_eq!(file, expected); + assert_eq!(file.bytes, expected.bytes); + assert_eq!(file.preview, expected.preview); + format!("[{}]", file.filename) + } + _ => panic!(), + }) + .collect(); + assert_eq!( + shown, + [ + "Before the file", + "[notes 🦀.txt]", + "After the file", + "[large.bin]", + "" + ] + ); + + if let Some(directory) = std::env::var_os("CANVAS_ATTACHMENT_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("files.one"), &image).unwrap(); + let file_id = Store::parse(&image).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } +} + +/// Files attached where a click on blank page left the caret lie on the page there, as OneNote +/// 2010 places them, and move as one does. +#[test] +fn files_attached_on_blank_page_store_their_bytes_where_they_were_placed_and_moved() { + let source = onestore::create_section("files.one", "Floating files", "Author").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.clone()).unwrap(); + let (space, ..) = section.pages().unwrap()[0].clone(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); + let small = file( + "float 🦀.txt", + b"Snowbound put this file on the page.\n".to_vec(), + Some(native_icon()), + ); + let large = file( + "large.bin", + large(), + Some(canvas::gpu::page::file_icon().into()), + ); + let mut at = 133_000_000_000_000_000; + let mut store = |editor: &mut CanvasEditor| { + let ops = editor.take_ops().unwrap(); + assert_eq!(ops.len(), 1, "{ops:?}"); + at += 10_000_000; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Author", &Edit { at, ops }).unwrap(); + }; + editor + .place_caret(&mut engine, [342.0, 284.4], 240.0) + .unwrap(); + editor + .insert_attachment(&mut engine, small.clone()) + .unwrap(); + store(&mut editor); + editor + .place_caret(&mut engine, [126.0, 410.4], 240.0) + .unwrap(); + editor + .insert_attachment(&mut engine, large.clone()) + .unwrap(); + store(&mut editor); + let (_, size) = editor.image_placement(large.id).unwrap(); + editor + .place_image(&mut engine, large.id, [288.0, 140.4], size) + .unwrap(); + store(&mut editor); + let mut image = source; + section.seal().unwrap().unwrap().apply(&mut image).unwrap(); + + let arena = Arena::default(); + let reopened = Section::open(&arena, image.clone()).unwrap(); + let page = reopened.page(space).unwrap(); + let files: Vec<&Attachment> = page + .objects + .iter() + .filter_map(|object| match object { + PageObject::Attachment(file) => Some(file), + _ => None, + }) + .collect(); + let [first, second] = files.as_slice() else { + panic!("{page:?}") + }; + for (stored, expected, at) in [ + (first, &small, [342.0, 284.4]), + (second, &large, [288.0, 140.4]), + ] { + assert_eq!(stored.id, expected.id); + assert_eq!([stored.layout.x, stored.layout.y], at.map(Some)); + assert_eq!(stored.layout.max_width, Some(54.0)); + assert_eq!(stored.bytes, expected.bytes); + assert_eq!(stored.preview, expected.preview); + } + assert_eq!(editor.page().unwrap(), page); + + if let Some(directory) = std::env::var_os("CANVAS_FLOATING_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("files.one"), &image).unwrap(); + let file_id = Store::parse(&image).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/canvas/tests/custom_tags.rs b/crates/canvas/tests/custom_tags.rs new file mode 100644 index 0000000000000000000000000000000000000000..497eca80577736ead41d66dad5fdfa57ff2b61f4 --- /dev/null +++ b/crates/canvas/tests/custom_tags.rs @@ -0,0 +1,207 @@ +//! Tags of a customized list, applied through the editor, store the definitions OneNote +//! 2010's Customize Tags stores (`corpus/custom-tags`). + +use canvas::{ + document::TextPosition, + editor::{CanvasEditor, Formatting, NoteTag}, + layout::TextEngine, +}; +use onestore::{RevisionIndex, Store, document::Document, op, page::Page}; + +fn page(bytes: &[u8]) -> (onestore::ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document.pages().unwrap()[0]; + (space, Page::from_space(&document, space).unwrap()) +} + +/// The list's tags: a symbol with both colours, a check box, a symbol alone and colours +/// alone, at the places the definitions store as action types. +fn list() -> Vec { + let tag = |label: &str, shape, color, highlight| NoteTag { + label: label.into(), + shape, + color, + highlight, + }; + vec![ + tag("Snow check", 61, Some(0x0000_0080), Some(0x00ff_cc00)), + tag("Snow task", 48, None, None), + tag("Snow circle", 31, None, None), + tag("Snow ink", 0, Some(0x00ff_0000), None), + ] +} + +/// Each paragraph takes the tags at these places in `list`; the third's box is checked. +const TAGGED: [(&str, &[u16]); 5] = [ + ("Custom symbol and colours", &[0]), + ("Custom check box", &[1]), + ("Custom check box, checked", &[1]), + ("Two custom tags", &[2, 3]), + // Typed before the new page's own "Untagged". + ("", &[]), +]; + +/// `ONESTORE_CUSTOM_TAGS_EXPORT` names a new directory receiving the candidate for a cold +/// reopen. +#[test] +fn custom_tags_store_what_customize_tags_stores() { + let source = onestore::create_section("custom-tags.one", "Untagged", "Author").unwrap(); + let (space, before) = page(&source); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap(); + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + let at = |paragraph| { + [TextPosition { + paragraph, + offset: 0, + }; 2] + .into() + }; + let list = list(); + for (paragraph, (text, places)) in TAGGED.iter().enumerate() { + if paragraph > 0 { + editor.enter(&mut engine, false).unwrap(); + } + editor.insert(&mut engine, text).unwrap(); + editor.select(at(paragraph)).unwrap(); + for &place in *places { + let tag = list[usize::from(place)].clone(); + editor + .format(&mut engine, Formatting::Tag(tag, place)) + .unwrap(); + } + if paragraph == 2 { + editor.format(&mut engine, Formatting::Check).unwrap(); + } + let offset = text.len() as u32; + editor + .select([TextPosition { paragraph, offset }; 2].into()) + .unwrap(); + } + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| op::Op::Page { space, op }) + .collect(); + section + .apply( + "Author", + &op::Edit { + at: 134_000_000_000_000_000, + ops, + }, + ) + .unwrap(); + section.seal().unwrap(); + let written = section.image(); + let (_, stored) = page(&written); + let mut definitions: Vec<_> = stored + .definitions + .values() + .filter_map(|definition| NoteTag::of(&definition.kind)) + .collect(); + definitions.sort_by_key(|(_, action_type)| *action_type); + let expected: Vec<_> = list.into_iter().zip(0..).collect(); + assert_eq!(definitions, expected); + let mut reread = CanvasEditor::from_page(stored, &mut engine).unwrap(); + reread.focus_outline(body).unwrap(); + for (paragraph, (_, places)) in TAGGED.iter().enumerate() { + reread.select(at(paragraph)).unwrap(); + let tags: Vec = reread + .format_state() + .unwrap() + .tags + .into_iter() + .map(|(_, action_type)| action_type) + .collect(); + assert_eq!(tags, *places, "paragraph {paragraph}"); + } + if let Some(directory) = std::env::var_os("ONESTORE_CUSTOM_TAGS_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("custom-tags.one"), &written).unwrap(); + let file_id = Store::parse(&written).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents( + "Open Notebook.onetoc2", + &[("custom-tags.one", file_id)], + ) + .unwrap(), + ) + .unwrap(); + } +} + +/// A tag OneNote 2010's New Tag made, applied from two places in its list, reads back with +/// its name, symbol and colours (`corpus/custom-tags/native`). +#[test] +fn onenotes_custom_tags_read_back() { + let bytes = include_bytes!("../../../corpus/custom-tags/native/shapes.one"); + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let page = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| Page::from_space(&document, space).unwrap()) + .find(|page| page.title.starts_with("Custom tags")) + .unwrap(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + let snow = NoteTag { + label: "Snow check".into(), + shape: 61, + color: Some(0x0000_0080), + highlight: Some(0x00ff_cc00), + }; + let to_do = NoteTag::defaults()[0].clone(); + // Snow check went in at the top of the list, then moved below To Do. + for (paragraph, expected) in [ + (0, (snow.clone(), 0)), + (1, (to_do.clone(), 1)), + (2, (snow, 1)), + (3, (to_do, 0)), + ] { + editor + .select( + [TextPosition { + paragraph, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + assert_eq!(editor.format_state().unwrap().tags, [expected]); + } + let shaped = &editor.active_outline().shaped().paragraphs[0]; + assert_eq!( + shaped.tags[0].icon, + canvas::outline::TagIcon::of(61, false).unwrap() + ); + assert!( + shaped + .text + .backgrounds() + .all(|(_, color)| color == 0x00ff_cc00) + ); +} diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs index c84e8a72c1082d1cc81ea015cfde0138d81ef782..e25cac75067cfc3f83ebde0892c1d9be4c10cb50 100644 --- a/crates/canvas/tests/ops_differential.rs +++ b/crates/canvas/tests/ops_differential.rs @@ -13,12 +13,21 @@ use canvas::{ use draw::edit::Movement; use onestore::{ Arena, ExGuid, RevisionIndex, Section, Store, - document::{Document, Kind as Node}, + document::{Document, Kind as Node, Layout}, op::{Edit, Op, PageOp}, - page::Page, + page::{Image, Page}, }; use std::{collections::BTreeMap, path::Path}; +/// A one-pixel PNG standing in for template art's bytes. +const ART: [u8; 67] = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +]; + #[path = "../../onestore/tests/support/sweep.rs"] mod sweep; @@ -98,11 +107,22 @@ enum Kind { InsertSpace, RemoveSpace, InsertSpaceRight, + Paper, + Art, + Attach, + /// A click on blank page, then Attach File: a file on the page. + AttachPage, + MoveFile, + RemoveFile, + /// Typing, backspaces and a word backspace at one caret: one undo step. + TypeRun, + /// More typing where the caret is, joining the run before it. + TypeOn, Undo, Redo, } -const KINDS: [Kind; 55] = [ +const KINDS: [Kind; 57] = [ Kind::Type, Kind::TypeEnd, Kind::Compose, @@ -156,10 +176,61 @@ const KINDS: [Kind; 55] = [ Kind::InsertSpace, Kind::RemoveSpace, Kind::InsertSpaceRight, + Kind::Paper, + Kind::Art, Kind::Undo, Kind::Redo, ]; +/// Typing runs, in plans of their own so the random edits' sequences stay as they were. +const TYPING_PLANS: [&[Kind]; 3] = [ + &[Kind::TypeRun, Kind::Undo, Kind::Redo], + &[ + Kind::TypeRun, + Kind::TypeOn, + Kind::Undo, + Kind::Redo, + Kind::Undo, + ], + &[Kind::PageType, Kind::TypeOn, Kind::Undo, Kind::Redo], +]; + +/// Files attached at a random caret, in plans of their own so the random edits' sequences +/// stay as they were. +const ATTACH_PLANS: [&[Kind]; 2] = [ + &[Kind::Attach, Kind::Undo, Kind::Redo], + &[Kind::Attach, Kind::Type, Kind::Attach, Kind::Undo], +]; + +/// Files attached on blank page, moved, spaced and removed, in plans of their own so the +/// random edits' sequences stay as they were. +const FLOATING_PLANS: [&[Kind]; 4] = [ + &[Kind::AttachPage, Kind::Undo, Kind::Redo], + &[ + Kind::AttachPage, + Kind::MoveFile, + Kind::Undo, + Kind::Redo, + Kind::RemoveFile, + Kind::Undo, + ], + &[Kind::AttachPage, Kind::InsertSpace, Kind::Undo, Kind::Redo], + &[ + Kind::AttachPage, + Kind::AttachPage, + Kind::MoveFile, + Kind::Undo, + Kind::Undo, + ], +]; + +/// Page colour, rule lines and art undone and redone across each other. +const PAPER_PLANS: [&[Kind]; 3] = [ + &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo], + &[Kind::Art, Kind::Paper, Kind::Art, Kind::Undo, Kind::Undo], + &[Kind::Paper, Kind::Art, Kind::Undo, Kind::Paper, Kind::Redo], +]; + struct Random(u64); impl Random { @@ -212,10 +283,13 @@ fn perform( kind, Kind::Undo | Kind::Redo + | Kind::TypeOn | Kind::PlaceImage | Kind::RemoveImage | Kind::CreateOutline | Kind::Date + | Kind::Paper + | Kind::Art ) { if outlines.is_empty() { return false; @@ -328,18 +402,9 @@ fn perform( Kind::Bullets => format(editor, engine, random, Formatting::Bullets), Kind::Numbering => format(editor, engine, random, Formatting::Numbering), Kind::Tag => { - let tag = [ - NoteTag::ToDo, - NoteTag::Important, - NoteTag::Question, - NoteTag::RememberForLater, - NoteTag::Definition, - NoteTag::Highlight, - NoteTag::Contact, - NoteTag::Address, - NoteTag::PhoneNumber, - ][random.below(9)]; - format(editor, engine, random, Formatting::Tag(tag)) + let place = random.below(9); + let tag = NoteTag::defaults().swap_remove(place); + format(editor, engine, random, Formatting::Tag(tag, place as u16)) } Kind::Check => format(editor, engine, random, Formatting::Check), Kind::ClickCheck => { @@ -462,6 +527,106 @@ fn perform( let line = editor.active_outline().origin()[0] - random.below(40) as f32; editor.insert_space(engine, 0, line, 45.0).unwrap_or(false) } + // Lemon paper with college rules, or none. + Kind::Paper => { + let (color, lines) = match editor.page_color() { + None => (Some(0x00dd_fdfd), Some(canvas::template::RULE_LINES[1].1)), + Some(_) => (None, None), + }; + editor.set_paper(color, lines, None) + } + // Template art in two pieces, as the Page Color menu gives it, or none after art. + Kind::Art => { + let piece = |x: f32| Image { + id: onestore::page::text::new_id().unwrap(), + layout: Layout { + x: Some(x), + y: Some(-3.6), + max_width: Some(174.5), + max_height: Some(640.0), + width_set_by_user: Some(true), + ..Default::default() + }, + size: Some([174.5, 640.0]), + bytes: Some(std::sync::Arc::from(ART.as_slice())), + alt: None, + background: true, + }; + let shown = editor.page().unwrap().objects.iter().any(|object| { + matches!(object, onestore::page::PageObject::Image(image) if image.background) + }); + let art = match shown { + true => Vec::new(), + false => vec![piece(-27.0), piece(400.0)], + }; + editor.set_paper(editor.page_color(), editor.rule_lines(), Some(art)) + } + Kind::Attach => { + let file = onestore::page::Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: "notes.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + layout: Default::default(), + bytes: Some(std::sync::Arc::from(b"notes".as_slice())), + preview: Some(std::sync::Arc::from(ART.as_slice())), + recording: None, + }; + place(editor, middle) && editor.insert_attachment(engine, file).is_ok() + } + Kind::AttachPage => { + let file = onestore::page::Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: "float.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + layout: Default::default(), + bytes: Some(std::sync::Arc::from(b"float".as_slice())), + preview: Some(std::sync::Arc::from(ART.as_slice())), + recording: None, + }; + let x = 36.0 + 18.0 * random.below(20) as f32; + editor.place_caret(engine, [x, 1440.0], 240.0).is_ok() + && editor.insert_attachment(engine, file).is_ok() + } + Kind::MoveFile | Kind::RemoveFile => { + let Some(file) = editor + .page() + .unwrap() + .objects + .into_iter() + .find_map(|object| match object { + onestore::page::PageObject::Attachment(file) => Some(file), + _ => None, + }) + else { + return false; + }; + if kind == Kind::RemoveFile { + return editor.remove_image(engine, file.id).is_ok(); + } + let (origin, size) = editor.image_placement(file.id).unwrap(); + let delta = [18.0, -36.0].map(|step| step * (1 + random.below(3)) as f32); + editor + .place_image( + engine, + file.id, + [origin[0] + delta[0], origin[1] + delta[1]], + size, + ) + .is_ok() + } + Kind::TypeRun => { + place(editor, middle) + && editor.insert(engine, "ab").is_ok() + && editor.delete(engine, true).is_ok() + && editor.insert(engine, " cd").is_ok() + && editor.delete_to(engine, Movement::WordLeft).is_ok() + && editor.commit_text(engine, "ef".into()).is_ok() + } + Kind::TypeOn => { + editor.insert(engine, "gh").is_ok() && editor.delete(engine, true).is_ok() + } Kind::Undo => editor.undo(engine).unwrap_or(false), Kind::Redo => editor.redo(engine).unwrap_or(false), } @@ -749,6 +914,10 @@ fn sweep( .map(|_| KINDS[random.below(KINDS.len())]) .collect() })) + .chain(PAPER_PLANS.map(<[Kind]>::to_vec)) + .chain(ATTACH_PLANS.map(<[Kind]>::to_vec)) + .chain(FLOATING_PLANS.map(<[Kind]>::to_vec)) + .chain(TYPING_PLANS.map(<[Kind]>::to_vec)) .collect::>>(); for plan in plans { let arena = Arena::default(); diff --git a/crates/canvas/tests/search.rs b/crates/canvas/tests/search.rs index 3e0a7d500e02680d3524a2cba651c323c5d69b91..50fc681d4303a44f1f40108bbd4d0195e1a5b115 100644 --- a/crates/canvas/tests/search.rs +++ b/crates/canvas/tests/search.rs @@ -235,7 +235,8 @@ fn tags_summary_lists_each_tagged_paragraph() { // The probe's T24 and T25 also carry Call back, one entry under each tag. assert_eq!(tagged.len(), names.len()); names.dedup(); - let mut labels = canvas::editor::NoteTag::ALL.map(|tag| tag.label()); + let defaults = canvas::editor::NoteTag::defaults(); + let mut labels: Vec<&str> = defaults.iter().map(|tag| tag.label.as_str()).collect(); labels.sort_unstable(); assert_eq!(names, labels); assert!(tagged.iter().all(|tagged| tagged.space == space)); diff --git a/crates/draw/src/draw.glsl b/crates/draw/src/draw.glsl index 7a3c78e353a45165032e858645b3fe254107e77f..d4c6f0848512fe15ec48251a62dcab894747b818 100644 --- a/crates/draw/src/draw.glsl +++ b/crates/draw/src/draw.glsl @@ -10,6 +10,7 @@ attribute vec4 shape; attribute float stroke; attribute vec2 clip_local; attribute vec3 clip; +attribute float blur; #endif varying vec2 v_uv; varying vec4 v_color; @@ -18,6 +19,7 @@ varying vec4 v_shape; varying float v_stroke; varying vec2 v_clip_local; varying vec3 v_clip; +varying float v_blur; #ifdef VERTEX void main() { @@ -28,6 +30,7 @@ void main() { v_stroke = stroke; v_clip_local = clip_local; v_clip = clip; + v_blur = blur; gl_Position = vec4(position, 0.0, 1.0); } #else @@ -54,10 +57,37 @@ float ellipse_arc(float angle, vec2 radius) { return half_angle * sum * scale; } +vec2 erf(vec2 x) { + vec2 s = sign(x); + vec2 a = abs(x); + vec2 y = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a; + y *= y; + return s - s / (y * y); +} + +float shadow(vec2 p, vec2 half_size, float corner, float sigma) { + float start = clamp(-3.0 * sigma, p.y - half_size.y, p.y + half_size.y); + float end = clamp(3.0 * sigma, p.y - half_size.y, p.y + half_size.y); + float stride = (end - start) / 4.0; + float y = start + stride * 0.5; + float value = 0.0; + for (int i = 0; i < 4; i++) { + float delta = min(half_size.y - corner - abs(p.y - y), 0.0); + float curved = half_size.x - corner + sqrt(max(0.0, corner * corner - delta * delta)); + vec2 integral = 0.5 + 0.5 * erf((p.x + vec2(-curved, curved)) * (0.70710678 / sigma)); + float weight = exp(-y * y / (2.0 * sigma * sigma)) / (2.50662827 * sigma); + value += (integral.y - integral.x) * weight * stride; + y += stride; + } + return value; +} + void main() { vec4 color = texture2D(atlas, v_uv) * v_color; vec4 shape = v_shape; - if (shape.x > 0.0 && shape.y > 0.0) { + if (v_blur > 1e-6) { + color *= shadow(v_local, shape.xy, shape.z, v_blur); + } else if (shape.x > 0.0 && shape.y > 0.0) { vec2 q = abs(v_local) - shape.xy + shape.zw; float distance; if (same(shape.z, shape.w)) { diff --git a/crates/draw/src/draw.wgsl b/crates/draw/src/draw.wgsl index 2704536ae512dc0196d9d79b68b75f8cadd2aab9..489e6ed81004e6ba6fd06bcc56346e6b1c5549f2 100644 --- a/crates/draw/src/draw.wgsl +++ b/crates/draw/src/draw.wgsl @@ -7,6 +7,7 @@ struct Vertex { @location(4) @interpolate(flat) stroke: f32, @location(5) clip_local: vec2, @location(6) @interpolate(flat) clip: vec3, + @location(7) @interpolate(flat) blur: f32, } @group(0) @binding(0) var atlas: texture_2d; @@ -16,8 +17,9 @@ struct Vertex { fn vertex(@location(0) position: vec2, @location(1) uv: vec2, @location(2) color: vec4, @location(3) local: vec2, @location(4) shape: vec4, @location(5) stroke: f32, - @location(6) clip_local: vec2, @location(7) clip: vec3) -> Vertex { - return Vertex(vec4(position, 0.0, 1.0), uv, color, local, shape, stroke, clip_local, clip); + @location(6) clip_local: vec2, @location(7) clip: vec3, + @location(8) blur: f32) -> Vertex { + return Vertex(vec4(position, 0.0, 1.0), uv, color, local, shape, stroke, clip_local, clip, blur); } fn ellipse_arc(angle: f32, radius: vec2) -> f32 { @@ -37,10 +39,40 @@ fn ellipse_arc(angle: f32, radius: vec2) -> f32 { return half * sum * scale; } +fn erf(x: vec2) -> vec2 { + let s = sign(x); + let a = abs(x); + var y = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a; + y *= y; + return s - s / (y * y); +} + +// Coverage at `p` of a rectangle `half` about the origin with round corners `corner`, +// blurred by a Gaussian of deviation `sigma`: exact across, sampled four times along +// (Evan Wallace, "Fast Rounded Rectangle Shadows"). +fn shadow(p: vec2, half: vec2, corner: f32, sigma: f32) -> f32 { + let start = clamp(-3.0 * sigma, p.y - half.y, p.y + half.y); + let end = clamp(3.0 * sigma, p.y - half.y, p.y + half.y); + let stride = (end - start) / 4.0; + var y = start + stride * 0.5; + var value = 0.0; + for (var i = 0; i < 4; i += 1) { + let delta = min(half.y - corner - abs(p.y - y), 0.0); + let curved = half.x - corner + sqrt(max(0.0, corner * corner - delta * delta)); + let integral = 0.5 + 0.5 * erf((p.x + vec2(-curved, curved)) * (0.70710678 / sigma)); + let weight = exp(-y * y / (2.0 * sigma * sigma)) / (2.50662827 * sigma); + value += (integral.y - integral.x) * weight * stride; + y += stride; + } + return value; +} + @fragment fn fragment(input: Vertex) -> @location(0) vec4 { var color = textureSample(atlas, atlas_sampler, input.uv) * input.color; - if input.shape.x > 0.0 && input.shape.y > 0.0 { + if input.blur > 0.0 { + color *= shadow(input.local, input.shape.xy, input.shape.z, input.blur); + } else if input.shape.x > 0.0 && input.shape.y > 0.0 { let q = abs(input.local) - input.shape.xy + input.shape.zw; var distance: f32; if input.shape.z == input.shape.w { diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 3c0d0e85049c41f957dbce7c0a0235d801b2e41a..48cca150a12f8ad15a514a659e2c42f0448e1684 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -32,7 +32,11 @@ use swash::{ zeno::{Angle, Cap, Format, Join, Mask, Transform, Vector}, }; +/// The atlas's side at first; it doubles, up to `MAX_ATLAS_SIZE`, when one frame needs +/// more than half of it. const ATLAS_SIZE: u32 = 2048; +const MAX_ATLAS_SIZE: u32 = 8192; +/// Atlas entries per `ATLAS_SIZE` square of atlas. const MAX_GLYPHS: usize = 8192; const MAX_VERTICES: usize = 65_536; const VERTEX_BUFFER_BYTES: u64 = (MAX_VERTICES * size_of::()) as u64; @@ -199,6 +203,8 @@ struct Vertex { /// that rectangle's half size and corner radius; a zero half size paints everywhere. clip_local: [f32; 2], clip: [f32; 3], + /// The standard deviation, in device pixels, of the blur that makes `shape` a shadow. + blur: f32, } #[derive(Hash, PartialEq, Eq)] @@ -401,6 +407,14 @@ pub enum Primitive<'a> { radius: [f32; 2], colors: [[f32; 4]; 2], }, + /// The soft shadow a rounded rectangle casts, blurred `blur` units wide as CSS's + /// `box-shadow` takes it. + Shadow { + rect: [f32; 4], + radius: f32, + blur: f32, + color: [f32; 4], + }, Image { image: &'a RasterImage, rect: [f32; 4], @@ -490,11 +504,36 @@ impl Renderer { pen: [1, 0], row_height: 1, }; - // The atlas's first texel is the white that solid fills sample. renderer.write_atlas([0, 0], [1, 1], &[255; 4]); renderer } + /// Replaces the atlas with an empty one twice as wide and tall. + fn grow_atlas(&mut self) { + self.new_atlas(self.atlas_side() * 2); + self.clear_glyph_cache(); + self.write_atlas([0, 0], [1, 1], &[255; 4]); + } + + fn glyph_limit(&self) -> usize { + MAX_GLYPHS * (self.atlas_side() / ATLAS_SIZE).pow(2) as usize + } + + fn uv(&self, glyph: AtlasGlyph) -> [f32; 4] { + let side = self.atlas_side() as f32; + [ + glyph.x as f32 / side, + glyph.y as f32 / side, + (glyph.x + glyph.width) as f32 / side, + (glyph.y + glyph.height) as f32 / side, + ] + } + + /// The atlas's first texel, the white that solid fills sample. + fn white(&self) -> [f32; 4] { + [0.5 / self.atlas_side() as f32; 4] + } + pub fn clear_glyph_cache(&mut self) { self.glyphs.clear(); self.pen = [1, 0]; @@ -524,9 +563,9 @@ impl Renderer { batch_capacity_bytes: self.batches.capacity() * size_of::(), glyph_capacity: self.glyphs.capacity(), image_capacity: self.images.capacity(), - atlas_bytes: u64::from(ATLAS_SIZE) * u64::from(ATLAS_SIZE) * 4, + atlas_bytes: u64::from(self.atlas_side()).pow(2) * 4, vertex_buffer_bytes: VERTEX_BUFFER_BYTES, - within_budget: self.glyphs.len() <= MAX_GLYPHS + within_budget: self.glyphs.len() <= self.glyph_limit() && self.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES && self.vertices.len() <= MAX_VERTICES, @@ -578,17 +617,35 @@ impl Renderer { } } } - for attempt in 0..2 { + let limit = self.max_texture_dimension().min(MAX_ATLAS_SIZE); + let mut cleared = false; + loop { self.vertices.clear(); self.batches.clear(); self.groups.clear(); self.barrier = 0; - match self.prepare(size, layers, &active_images) { - Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), + let full = match self.prepare(size, layers, &active_images) { + Err(RenderError::AtlasFull) => true, result => { result?; - break; + false } + }; + // Once cleared, the atlas holds only this frame's entries; past half full, the + // next frames would clear it again and again. + let crowded = full || (cleared && 2 * self.pen[1] > self.atlas_side()); + if !crowded { + break; + } + if !cleared { + self.clear_glyph_cache(); + cleared = true; + } else if self.atlas_side() * 2 <= limit { + self.grow_atlas(); + } else if full { + return Err(RenderError::AtlasFull); + } else { + break; } } self.submit(target, size, clear); @@ -677,6 +734,7 @@ impl Renderer { stroke: 0.0, clip_local: [0.0; 2], clip: [0.0; 3], + blur: 0.0, } }; for strip in 0..strips { @@ -766,7 +824,7 @@ impl Renderer { rect[2] * space.scale + space.origin[0], rect[3] * space.scale + space.origin[1], ], - [0.5 / ATLAS_SIZE as f32; 4], + self.white(), *color, )?; } @@ -781,6 +839,12 @@ impl Renderer { radius, colors, } => self.rounded_rect(space, *rect, *radius, None, *colors)?, + Primitive::Shadow { + rect, + radius, + blur, + color, + } => self.shadow(space, *rect, *radius, *blur, *color)?, Primitive::Segment { from, to, @@ -871,7 +935,7 @@ impl Renderer { return Ok(()); } let size = run.size * scale; - if !size.is_finite() || size > ATLAS_SIZE as f32 { + if !size.is_finite() || size > self.atlas_side() as f32 { return Err(RenderError::AtlasFull); } let color = run @@ -905,7 +969,7 @@ impl Renderer { let cached = if let Some(cached) = self.glyphs.get(&key) { *cached } else { - if self.glyphs.len() >= MAX_GLYPHS { + if self.glyphs.len() >= self.glyph_limit() { return Err(RenderError::AtlasFull); } let font = FontRef::from_index(run.font.as_ref(), run.index as usize) @@ -967,16 +1031,10 @@ impl Renderer { height *= ratio; top = top.clamp(line_top, (line_top + line_height - height).max(line_top)); } - let atlas_size = ATLAS_SIZE as f32; self.quad( space, [left, top, left + width, top + height], - [ - glyph.x as f32 / atlas_size, - glyph.y as f32 / atlas_size, - (glyph.x + glyph.width) as f32 / atlas_size, - (glyph.y + glyph.height) as f32 / atlas_size, - ], + self.uv(glyph), if glyph.color { [1.0; 4] } else { color }, )?; } @@ -992,7 +1050,7 @@ impl Renderer { x + decoration.width * scale, y + (decoration.thickness * scale).max(1.0), ], - [0.5 / ATLAS_SIZE as f32; 4], + self.white(), decoration .color .map_or(ink, |color| legible(color, space.backdrop, run.backdrop)), @@ -1003,15 +1061,16 @@ impl Renderer { fn upload(&mut self, image: swash::scale::image::Image) -> Result { let p = image.placement; - if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { + let side = self.atlas_side(); + if p.width + 2 > side || p.height + 2 > side { return Err(RenderError::AtlasFull); } - if self.pen[0] + p.width + 1 > ATLAS_SIZE { + if self.pen[0] + p.width + 1 > side { self.pen[0] = 0; self.pen[1] += self.row_height + 1; self.row_height = 0; } - if self.pen[1] + p.height + 1 > ATLAS_SIZE { + if self.pen[1] + p.height + 1 > side { return Err(RenderError::AtlasFull); } let cached = AtlasGlyph { @@ -1057,7 +1116,7 @@ impl Renderer { { return Ok(()); } - if size + 3.0 > ATLAS_SIZE as f32 { + if size + 3.0 > self.atlas_side() as f32 { return Err(RenderError::AtlasFull); } let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; @@ -1072,7 +1131,7 @@ impl Renderer { let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { *glyph } else { - if self.glyphs.len() >= MAX_GLYPHS { + if self.glyphs.len() >= self.glyph_limit() { return Err(RenderError::AtlasFull); } let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?; @@ -1081,7 +1140,6 @@ impl Renderer { }; let left = x.floor(); let top = y.floor(); - let atlas = ATLAS_SIZE as f32; self.quad( space, [ @@ -1090,12 +1148,7 @@ impl Renderer { left + glyph.width as f32, top + glyph.height as f32, ], - [ - glyph.x as f32 / atlas, - glyph.y as f32 / atlas, - (glyph.x + glyph.width) as f32 / atlas, - (glyph.y + glyph.height) as f32 / atlas, - ], + self.uv(glyph), [1.0, 1.0, 1.0, tint[3]], ) } @@ -1133,7 +1186,7 @@ impl Renderer { let start = self.vertices.len(); // A pixel of margin holds the antialiased fringe outside the edge. let fringe = [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0]; - self.quad(space, fringe, [0.5 / ATLAS_SIZE as f32; 4], colors[0])?; + self.quad(space, fringe, self.white(), colors[0])?; let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; if !(4.0 * (half[0] + half[1])).is_finite() || !colors[1].iter().all(|v| v.is_finite()) { return Err(RenderError::InvalidPrimitive); @@ -1160,6 +1213,41 @@ impl Renderer { Ok(()) } + fn shadow( + &mut self, + space: Space, + rect: [f32; 4], + radius: f32, + blur: f32, + color: [f32; 4], + ) -> Result<(), RenderError> { + if !radius.is_finite() || radius < 0.0 || !blur.is_finite() || blur <= 0.0 { + return Err(RenderError::InvalidPrimitive); + } + let rect = [0, 1, 2, 3].map(|i| rect[i] * space.scale + space.origin[i % 2]); + let sigma = blur * space.scale / 2.0; + let reach = 3.0 * sigma; + let start = self.vertices.len(); + self.quad( + space, + [ + rect[0] - reach, + rect[1] - reach, + rect[2] + reach, + rect[3] + reach, + ], + self.white(), + color, + )?; + let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + let radius = (radius * space.scale).min(half[0]).min(half[1]).max(0.0); + for vertex in &mut self.vertices[start..] { + vertex.shape = [half[0], half[1], radius, radius]; + vertex.blur = sigma; + } + Ok(()) + } + fn path( &mut self, space: Space, @@ -1195,7 +1283,7 @@ impl Renderer { let glyph = match self.glyphs.get(&key) { Some(glyph) => *glyph, None => { - if self.glyphs.len() >= MAX_GLYPHS { + if self.glyphs.len() >= self.glyph_limit() { return Err(RenderError::AtlasFull); } let mut mask = Mask::new(data); @@ -1232,7 +1320,6 @@ impl Renderer { }; let left = x.floor() + glyph.left as f32; let top = y.floor() + glyph.top as f32; - let atlas = ATLAS_SIZE as f32; let start = self.vertices.len(); self.quad( space, @@ -1242,12 +1329,7 @@ impl Renderer { left + glyph.width as f32, top + glyph.height as f32, ], - [ - glyph.x as f32 / atlas, - glyph.y as f32 / atlas, - (glyph.x + glyph.width) as f32 / atlas, - (glyph.y + glyph.height) as f32 / atlas, - ], + self.uv(glyph), colors[0], )?; for vertex in &mut self.vertices[start..] { @@ -1318,13 +1400,14 @@ impl Renderer { x * 2.0 / space.size[0] as f32 - 1.0, 1.0 - y * 2.0 / space.size[1] as f32, ], - uv: [0.5 / ATLAS_SIZE as f32; 2], + uv: [0.5 / self.atlas_side() as f32; 2], color, local, shape: [half[0], half[1], corner, corner], stroke: 0.0, clip_local: [0.0; 2], clip: [0.0; 3], + blur: 0.0, }); } Ok(()) @@ -1378,6 +1461,7 @@ impl Renderer { stroke: 0.0, clip_local: [0.0; 2], clip: [0.0; 3], + blur: 0.0, }); } Ok(()) @@ -2031,7 +2115,7 @@ mod tests { ); } assert!(!renderer.glyphs.is_empty()); - assert!(renderer.glyphs.len() <= MAX_GLYPHS); + assert!(renderer.glyphs.len() <= renderer.glyph_limit()); assert_eq!( renderer .glyphs @@ -2255,6 +2339,12 @@ mod tests { style: PathStyle::Shadow(6.0), colors: [[0.0, 0.0, 0.0, 1.0]; 2], }, + Primitive::Shadow { + rect: [170.0, 90.0, 190.0, 110.0], + radius: 4.0, + blur: 6.0, + color: [0.0, 0.0, 0.0, 1.0], + }, Primitive::Gradient { rect: [100.0, 10.0, 120.0, 110.0], radius: [0.0; 2], @@ -2305,6 +2395,59 @@ mod tests { "and fades at its edge: {fading:?}" ); assert_eq!(beyond, [255; 4]); + let [inside, fading, beyond] = [pixel(360, 200), pixel(340, 200), pixel(310, 200)]; + assert!( + inside[0] < 40, + "a rectangle's shadow is dark inside: {inside:?}" + ); + assert!( + fading[0] > 40 && fading[0] < 230, + "and fades at its edge: {fading:?}" + ); + assert_eq!(beyond, [255; 4]); + assert_eq!(renderer.glyphs.len(), 3, "and takes no room in the atlas"); + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn icons_past_one_atlas_never_fail_a_frame() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let target = Target::new(&device); + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let mut renderer = Renderer::new(device, queue, format); + // Each tint is an entry of its own, 64 device pixels square. + let icons = |tints: Range| -> Vec> { + tints + .map(|tint| Primitive::Icon { + sources: &[CHECKBOX], + origin: [0.0; 2], + size: 32.0, + tint: [tint as f32 / 65_536.0, 0.0, 0.0, 1.0], + palette: Palette::default(), + }) + .collect() + }; + let one_atlas = (ATLAS_SIZE as usize / 65).pow(2); + for frame in 0..8 { + let start = frame * one_atlas / 3; + let primitives = icons(start..start + one_atlas / 3); + target.draw(&mut renderer, &page(&primitives)).unwrap(); + } + assert_eq!( + renderer.atlas_side(), + ATLAS_SIZE, + "frames each needing a third of the atlas evict, never grow it" + ); + let primitives = icons(0..3 * one_atlas); + target.draw(&mut renderer, &page(&primitives)).unwrap(); + assert!(renderer.atlas_side() > ATLAS_SIZE); + assert!(renderer.glyphs.len() >= 3 * one_atlas); + let capture = target.capture(&renderer); + let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_ne!(pixel(24 + 12, 24 + 32), [255; 4], "the icons paint"); } #[test] diff --git a/crates/draw/src/render/gl.rs b/crates/draw/src/render/gl.rs index 7cbb7046e637f40affcd391c0ed40c3740406847..3334b66721b2fda7f80301c60d39fb4618ab2a93 100644 --- a/crates/draw/src/render/gl.rs +++ b/crates/draw/src/render/gl.rs @@ -305,7 +305,7 @@ pub(super) struct Gpu { } /// Each attribute's name, component count and offset, bound to its index in the shader. -const ATTRIBUTES: [(&CStr, GLint, usize); 8] = [ +const ATTRIBUTES: [(&CStr, GLint, usize); 9] = [ (c"position", 2, offset_of!(Vertex, position)), (c"uv", 2, offset_of!(Vertex, uv)), (c"color", 4, offset_of!(Vertex, color)), @@ -314,6 +314,7 @@ const ATTRIBUTES: [(&CStr, GLint, usize); 8] = [ (c"stroke", 1, offset_of!(Vertex, stroke)), (c"clip_local", 2, offset_of!(Vertex, clip_local)), (c"clip", 3, offset_of!(Vertex, clip)), + (c"blur", 1, offset_of!(Vertex, blur)), ]; impl Renderer { @@ -384,6 +385,14 @@ impl Renderer { self.gpu.max_texture } + pub(super) fn atlas_side(&self) -> u32 { + self.gpu.atlas.size[0] + } + + pub(super) fn new_atlas(&mut self, side: u32) { + self.gpu.atlas = Image::new([side; 2], NEAREST, None); + } + pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { unsafe { glBindTexture(TEXTURE_2D, self.gpu.atlas.name); diff --git a/crates/draw/src/render/webgpu.rs b/crates/draw/src/render/webgpu.rs index 3ca92bf521955e457b57b910a93c7b590e3913a2..991bd78786e819277446498dc7d719b7cd8fcebe 100644 --- a/crates/draw/src/render/webgpu.rs +++ b/crates/draw/src/render/webgpu.rs @@ -63,7 +63,7 @@ impl Renderer { buffers: &[Some(wgpu::VertexBufferLayout { array_stride: size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3], + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3, 8 => Float32], })], }, fragment: Some(wgpu::FragmentState { @@ -98,40 +98,7 @@ impl Renderer { color: erase, alpha: erase, }); - let atlas = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph atlas"), - size: wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - let view = atlas.create_view(&Default::default()); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - mag_filter: wgpu::FilterMode::Nearest, - min_filter: wgpu::FilterMode::Nearest, - ..Default::default() - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Glyph atlas"), - layout: &pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&sampler), - }, - ], - }); + let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE); let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Draw vertices"), size: VERTEX_BUFFER_BYTES, @@ -167,6 +134,14 @@ impl Renderer { self.device.limits().max_texture_dimension_2d } + pub(super) fn atlas_side(&self) -> u32 { + self.gpu.atlas.width() + } + + pub(super) fn new_atlas(&mut self, side: u32) { + (self.gpu.atlas, self.gpu.bind_group) = atlas(&self.device, &self.gpu.pipeline, side); + } + pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { self.queue.write_texture( wgpu::TexelCopyTextureInfo { @@ -336,3 +311,46 @@ impl Renderer { self.queue.submit([encoder.finish()]); } } + +/// An empty atlas `side` texels square, and how the pipeline samples it. +fn atlas( + device: &wgpu::Device, + pipeline: &wgpu::RenderPipeline, + side: u32, +) -> (wgpu::Texture, wgpu::BindGroup) { + let atlas = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph atlas"), + size: wgpu::Extent3d { + width: side, + height: side, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = atlas.create_view(&Default::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph atlas"), + layout: &pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + (atlas, bind_group) +} diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index 7343a704e5c6be351751db17753626ff59994cea..17bbb9869db37d1ab4c1d238b5f9dc82a8234b78 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -16,6 +16,7 @@ onestore = { path = "../onestore" } wgpu.workspace = true parley.workspace = true pollster = "0.4" +resvg = { version = "0.48.1", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index cf388528e8125dd2199307ec2b261d4139e3c277..3bf70c985477ac154c09f319ac20f9ea951a6879 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -14,6 +14,9 @@ void sb_string_free(char *text); typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context); void sb_set_coordinator(sb_coordinator coordinator); +void sb_set_sync_wake(void (*wake)(void)); +char *sb_tags(void); +bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); const Library *sb_library_open(const char *path, const char *cache, char **error); const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password, @@ -22,6 +25,10 @@ char *sb_library_sections(const Library *library); char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, const char *date, const char *time); char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); +char *sb_library_tagged(const Library *library, const Section *open, const char *path); +char *sb_library_sync_status(const Library *library); +void sb_library_set_offline(const Library *library, bool offline); +void sb_library_sync_now(const Library *library); void sb_library_free(const Library *library); Share *sb_share_connect(const char *address, const char *share, const char *user, const char *password, @@ -58,11 +65,17 @@ bool sb_view_drag(View *view, float x, float y); bool sb_view_release(View *view); bool sb_view_undo(View *view, bool redo); bool sb_view_can_undo(const View *view, bool redo); -uint32_t sb_view_format(const View *view); +uint64_t sb_view_format(const View *view); bool sb_view_apply(View *view, uint8_t command); char *sb_view_title(const View *view); bool sb_view_focus_title(View *view); bool sb_view_find(View *view, const char *query); +bool sb_view_select_paragraph(View *view, const char *id); +char *sb_view_copy(View *view, bool cut); +void sb_view_insert_space(View *view); +char *sb_view_paper(const View *view); +bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); +bool sb_view_set_art(View *view, const char *name); char *sb_view_page_text(const View *view); int8_t sb_view_date_request(View *view, int64_t *seconds); bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 4297a65306eeb020f9fefc5a29b84395a954cb41..84be8f404154d1f5225ef5c0d4df5a8cfcd0640f 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -78,9 +78,9 @@ enum Target { Grip = 3, } -/// Formatting the toolbar shows and applies, by bit in `sb_view_format` and by number in -/// `sb_view_apply`: toggles, lists, indentation, then OneNote's nine default tags from 16. -const FORMATS: [Formatting; 8] = [ +/// Formatting the host shows and applies, by bit in `sb_view_format` and by number in +/// `sb_view_apply`; OneNote's default tags follow from 16, in their list's order. +const FORMATS: [Formatting; 11] = [ Formatting::Toggle(Toggle::Bold), Formatting::Toggle(Toggle::Italic), Formatting::Toggle(Toggle::Underline), @@ -89,17 +89,10 @@ const FORMATS: [Formatting; 8] = [ Formatting::Numbering, Formatting::Indent, Formatting::Outdent, -]; -const TAGS: [NoteTag; 9] = [ - NoteTag::ToDo, - NoteTag::Important, - NoteTag::Question, - NoteTag::RememberForLater, - NoteTag::Definition, - NoteTag::Highlight, - NoteTag::Contact, - NoteTag::Address, - NoteTag::PhoneNumber, + Formatting::RemoveTags, + Formatting::Clear, + // OneNote's first highlighter colour, yellow. + Formatting::Highlight(Some(0x0000_ffff)), ]; /// The page and its text model, apart from the surface it is drawn on. @@ -217,6 +210,9 @@ impl Canvas { } fn target(&self, point: [f32; 2]) -> Target { + if self.page.inserting_space() { + return Target::Grip; + } match self.page.hit(self.device(point)) { Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, Some(Hit::Image { id, .. }) @@ -363,8 +359,8 @@ impl Canvas { })) } - /// The formatting the selection has, as `FORMATS` and `TAGS` bits. - fn format_bits(&self) -> Result { + /// The formatting the selection has, as `FORMATS` and default tag bits. + fn format_bits(&self) -> Result { let state = self.page.editor.format_state()?; let mut bits = 0; for (bit, format) in FORMATS.iter().enumerate() { @@ -374,17 +370,25 @@ impl Canvas { Formatting::Numbering => state.numbering, _ => false, }; - bits |= u32::from(on) << bit; + bits |= u64::from(on) << bit; } - for (bit, tag) in TAGS.iter().enumerate() { - bits |= u32::from(state.tags.contains(tag)) << (16 + bit); + for (place, tag) in NoteTag::defaults().into_iter().enumerate() { + let tagged = state.tags.contains(&(tag, place as u16)); + bits |= u64::from(tagged) << (16 + place); } Ok(bits) } fn format(&mut self, command: u8) -> Result { let command = match command { - 16.. => Formatting::Tag(*TAGS.get(usize::from(command - 16)).ok_or("No such tag")?), + 16.. => { + let place = command - 16; + let tag = NoteTag::defaults() + .into_iter() + .nth(usize::from(place)) + .ok_or("No such tag")?; + Formatting::Tag(tag, place.into()) + } _ => FORMATS .get(usize::from(command)) .ok_or("No such formatting")? @@ -426,6 +430,17 @@ impl Canvas { Ok(true) } + /// Selects paragraph `id`, as a Tags Summary entry shows it. + fn select_paragraph(&mut self, id: ExGuid) -> Result { + let Some((outline, selection)) = canvas::search::paragraph_match(&self.page.editor, id) + else { + return Ok(false); + }; + self.page.editor.focus_outline(outline)?; + self.page.editor.select(selection)?; + Ok(true) + } + /// The page's text, outline by outline, as the desktop puts a refused edit's on the /// clipboard. fn page_text(&self) -> String { @@ -446,6 +461,27 @@ impl Canvas { .join("\n\n") } + /// Gives the page `color`, a COLORREF, and rule lines `ruled` of `RULE_LINES`, or none. + fn set_paper(&mut self, color: Option, ruled: Option) -> Result { + let rules = ruled + .and_then(|index| canvas::template::RULE_LINES.get(index)) + .map(|(_, lines)| *lines); + Ok(moved(self.page.set_paper(color, rules, None)?)) + } + + /// Puts template `name`'s art behind the page in place of any it had, or none. + fn set_art(&mut self, name: Option<&str>) -> Result { + let art = match name { + Some(name) => canvas::template::find(name) + .ok_or("No such template")? + .pictures()?, + None => Vec::new(), + }; + let editor = &self.page.editor; + let (color, rules) = (editor.page_color(), editor.rule_lines()); + Ok(moved(self.page.set_paper(color, rules, Some(art))?)) + } + /// Puts a picture of `size` points after the caret's paragraph. fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result { let image = Image { @@ -855,15 +891,15 @@ pub extern "C" fn sb_view_can_undo(view: &View, redo: bool) -> bool { } /// The selection's formatting as bits: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4 -/// bulleted, 5 numbered, and from 16 OneNote's default tags in order, To Do first. +/// bulleted, 5 numbered, and from 16 OneNote's tags in `sb_tags` order, To Do first. #[unsafe(no_mangle)] -pub extern "C" fn sb_view_format(view: &View) -> u32 { +pub extern "C" fn sb_view_format(view: &View) -> u64 { report(view.canvas.format_bits()).unwrap_or(0) } /// Applies formatting to the selection: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4 -/// bullets, 5 numbering, 6 indent, 7 outdent, each toggling where it can; from 16 toggles -/// OneNote's default tag `command - 16`, To Do first. +/// bullets, 5 numbering, 6 indent, 7 outdent, 8 remove tags, 9 clear formatting, 10 yellow +/// highlight, each toggling where it can; from 16 toggles tag `command - 16` of `sb_tags`. #[unsafe(no_mangle)] pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { let result = view.canvas.format(command); @@ -893,6 +929,156 @@ pub unsafe extern "C" fn sb_view_find(view: &mut View, query: *const c_char) -> report(view.canvas.find(&string(query))).unwrap_or(false) } +/// Selects paragraph `id` of the page; false where the page has none. +/// +/// # Safety +/// `id` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_char) -> bool { + let result = string(id) + .parse() + .map_err(Into::into) + .and_then(|id| view.canvas.select_paragraph(id)); + report(result).unwrap_or(false) +} + +/// Copy, or Cut with `cut`: the selected text for the clipboard, a page selection's +/// outlines with a blank line between; null with nothing selected. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char { + let mut copied = None; + let result = view.canvas.page.copy(cut).map(|response| { + if let Some(Request::Copy(text)) = &response.request { + copied = Some(text.clone()); + } + moved(response) + }); + view.stored(result); + copied.map_or(std::ptr::null_mut(), owned) +} + +/// Insert Space: the next drag on the page moves what lies below or right of where it +/// starts, as far as it goes. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_insert_space(view: &mut View) { + let _ = view.canvas.page.insert_space(); +} + +#[derive(serde::Serialize)] +struct Choices { + /// The page colour as sRGB, or null. + color: Option<[u8; 3]>, + /// Index into `rules` of the page's rule lines, or null for none or others. + ruled: Option, + /// OneNote's page colours, rule lines and templates, by name; colours with their sRGB. + colors: Vec<(&'static str, [u8; 3])>, + rules: Vec<&'static str>, + templates: Vec<&'static str>, +} + +fn srgb(colorref: u32) -> [u8; 3] { + let [red, green, blue, _] = colorref.to_le_bytes(); + [red, green, blue] +} + +/// The page's paper and what it can take, as JSON: `color`, `ruled`, and the choices +/// `colors` (each `[name, [r, g, b]]`), `rules` and `templates`, by name. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_paper(view: &View) -> *mut c_char { + use canvas::template::{PAGE_COLORS, RULE_LINES, TEMPLATES}; + let editor = &view.canvas.page.editor; + let rules = editor.rule_lines(); + library::json(Ok(Choices { + color: editor.page_color().map(srgb), + ruled: RULE_LINES + .iter() + .position(|(_, lines)| Some(*lines) == rules), + colors: PAGE_COLORS + .iter() + .map(|(name, color)| (*name, srgb(*color))) + .collect(), + rules: RULE_LINES.iter().map(|(name, _)| *name).collect(), + templates: TEMPLATES.iter().map(|template| template.name).collect(), + })) +} + +/// Gives the page the colour `red`, `green`, `blue`, or none when `red` is negative, and +/// rule lines `ruled` of `sb_view_paper`'s `rules`, or none when negative, as one undo step. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_set_paper( + view: &mut View, + red: i16, + green: u8, + blue: u8, + ruled: i8, +) -> bool { + let color = u8::try_from(red) + .ok() + .map(|red| u32::from_le_bytes([red, green, blue, 0])); + let result = view.canvas.set_paper(color, usize::try_from(ruled).ok()); + view.stored(result) +} + +/// Puts template `name`'s art behind the page in place of any it had, or none when `name` +/// is null, as one undo step; its colour and rule lines stay. +/// +/// # Safety +/// A non-null `name` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_view_set_art(view: &mut View, name: *const c_char) -> bool { + let result = view.canvas.set_art(library::optional(name).as_deref()); + view.stored(result) +} + +/// OneNote's default tags as JSON, in `sb_view_apply`'s order from 16: each `[name, shape, +/// highlight]`, the shape being the symbol `sb_tag_icon` draws and the highlight sRGB or +/// null. +#[unsafe(no_mangle)] +pub extern "C" fn sb_tags() -> *mut c_char { + library::json(Ok(NoteTag::defaults() + .into_iter() + .map(|tag| (tag.label, tag.shape, tag.highlight.map(srgb))) + .collect::>())) +} + +/// Draws tag symbol `shape`, a check box `checked`, into `rgba`, `pixels` square of +/// premultiplied RGBA; false for shape 0, a highlighting tag, which has none. +/// +/// # Safety +/// `rgba` has room for `pixels * pixels * 4` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_tag_icon( + shape: u16, + checked: bool, + pixels: u32, + rgba: *mut u8, +) -> bool { + use resvg::{tiny_skia, usvg}; + let Some(icon) = canvas::outline::TagIcon::of(shape, checked) else { + return false; + }; + let Some(mut pixmap) = tiny_skia::Pixmap::new(pixels, pixels) else { + return false; + }; + for source in canvas::gpu::tag_sources(icon) { + let Some(tree) = + report(usvg::Tree::from_str(source, &usvg::Options::default()).map_err(Into::into)) + else { + return false; + }; + let size = tree.size(); + let scale = pixels as f32 / size.width().max(size.height()); + resvg::render( + &tree, + tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + } + // SAFETY: the caller's contract. + unsafe { std::ptr::copy_nonoverlapping(pixmap.data().as_ptr(), rgba, pixmap.data().len()) }; + true +} + /// The page's text, for the clipboard when an edit is refused; freed with `sb_string_free`. #[unsafe(no_mangle)] pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index aef196ab69b5b8f0464ecd0b43efe391f8a9ac28..e38232478794070acad6ca71e0db2c6be1a8df50 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -3,11 +3,11 @@ //! cache, which stores each edit at once and publishes it in the background. use crate::{Result, owned, report, string}; -use canvas::search::{Entry, Index, Query}; +use canvas::search::{Entry, Index, Query, Tagged}; use notebook::{ Remote, Replica, discover::{Folder, SectionState}, - session::{self, Event, Notebook}, + session::{self, Background, Event, Notebook, SyncStatus}, smb::{Client, Credentials}, }; use onestore::{ @@ -21,8 +21,8 @@ use std::{ io, path::{Path, PathBuf}, sync::{ - Arc, Mutex, OnceLock, - atomic::{AtomicU8, Ordering}, + Arc, Mutex, OnceLock, Weak, + atomic::{AtomicBool, AtomicU8, Ordering}, }, time::{Duration, Instant}, }; @@ -33,6 +33,24 @@ const TIMEOUT: Duration = Duration::from_secs(10); const LIMIT: usize = 256 * 1024 * 1024; /// The tab colour OneNote gives a section that stores none, as the desktop shows it. const SECTION_COLOR: u32 = 0x00e4_a88a; +/// How often sections no page shows are polled, as the desktop polls them. +const BACKGROUND: Duration = Duration::from_secs(15); + +/// Called on a background thread when a notebook's sync status may have changed. +static SYNC_WAKE: OnceLock = OnceLock::new(); + +/// Has the host call `wake` whenever a notebook's closed sections report; set once, before +/// any library opens. +#[unsafe(no_mangle)] +pub extern "C" fn sb_set_sync_wake(wake: extern "C" fn()) { + let _ = SYNC_WAKE.set(wake); +} + +fn sync_woken() { + if let Some(wake) = SYNC_WAKE.get() { + wake(); + } +} /// Runs `body(context)` within the host's coordinated reading or writing of `path`, as /// `NSFileCoordinator` has other processes (file providers) wait for it, or not at all when @@ -183,6 +201,12 @@ pub struct Library { place: Place, cache: PathBuf, index: Mutex<(HashMap, Index)>, + /// Syncs the sections no session holds; none for a lone section. + background: Option, + /// The sections open for editing, by catalog path, whose sessions sync them. + open: Mutex)>>, + /// Work Offline, which sections opened later follow too. + offline: AtomicBool, } #[derive(serde::Serialize, serde::Deserialize)] @@ -244,12 +268,20 @@ fn tabs(folder: &Folder, tabs: &mut Vec) { impl Library { pub(crate) fn open(path: &Path, cache: &Path) -> Result { - let (notebook, place) = if path.is_file() { - (None, Place::File(path.to_owned())) + let (notebook, place, background) = if path.is_file() { + (None, Place::File(path.to_owned()), None) } else { + let notebook = Notebook::open(path, cache)?; + let background = notebook.background_with( + BACKGROUND, + |file| Coordinated(file.to_owned()), + sync_woken, + )?; + background.watch(notebook.replicas()); ( - Some(Notebook::open(path, cache)?), + Some(notebook), Place::Folder(path.to_owned()), + Some(background), ) }; Ok(Self { @@ -257,30 +289,61 @@ impl Library { place, cache: cache.to_owned(), index: Mutex::default(), + background, + open: Mutex::default(), + offline: AtomicBool::new(false), }) } /// The notebook folder `root` on `server`; while the server cannot be reached, its sections /// as last listed, if it was listed before. pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result { + let server = Arc::new(server); + let connect = Arc::clone(&server); + let background = Background::smb( + root, + LIMIT, + BACKGROUND, + move || connect.connect(), + sync_woken, + )?; let library = Self { notebook: Mutex::default(), place: Place::Share { - server: Arc::new(server), + server, client: Mutex::default(), root: root.to_owned(), }, cache: cache.to_owned(), index: Mutex::default(), + background: Some(background), + open: Mutex::default(), + offline: AtomicBool::new(false), }; - if let Err(error) = library.with_notebook(false, |_| Ok(())) - && library.listed().is_none() - { - return Err(error); + let reached = library.with_notebook(false, |_| Ok(())); + match library.listed() { + Some(listed) => library.watch(&listed.files), + None => reached?, } Ok(library) } + /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them. + fn watch(&self, files: &BTreeMap) { + let Some(background) = &self.background else { + return; + }; + let smb = self.cache.join("smb"); + background.watch( + files + .iter() + .map(|(path, identity)| { + (path.clone(), Some(smb.join(format!("{identity}.sqlite")))) + }) + .collect(), + ); + } + /// Where a share notebook's last listing is kept. fn listing(&self) -> Option { let Place::Share { server, root, .. } = &self.place else { @@ -366,6 +429,9 @@ impl Library { }; tabs(notebook.catalog(), &mut listed.tabs); files(notebook.catalog(), &mut listed.files); + if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) { + background.watch(notebook.replicas()); + } Ok(listed) }); let Some(listing) = self.listing() else { @@ -375,14 +441,42 @@ impl Library { Ok(listed) => { std::fs::create_dir_all(listing.parent().ok_or("No cache")?)?; std::fs::write(&listing, serde_json::to_vec(&listed)?)?; + self.watch(&listed.files); Ok(listed.tabs) } Err(error) => Ok(self.listed().ok_or(error)?.tabs), } } - /// The section at catalog `path`, through a replica in the cache. + /// The section at catalog `path`, through a replica in the cache, following Work + /// Offline. The background may hold the replica for a step, which takes a network round + /// trip, so a busy replica is tried again for a while. pub(crate) fn section( + &self, + path: &str, + notify: impl Fn() + Send + Sync + 'static, + ) -> Result { + let deadline = Instant::now() + TIMEOUT * 3; + let notify = Arc::new(notify); + let section = loop { + let notify = Arc::clone(¬ify); + match self.section_once(path, move || notify()) { + Err(error) + if Instant::now() < deadline + && error + .downcast_ref::() + .is_some_and(notebook::Error::busy) => + { + std::thread::sleep(Duration::from_millis(50)); + } + opened => break opened?, + } + }; + section.set_offline(self.offline.load(Ordering::Relaxed)); + Ok(section) + } + + fn section_once( &self, path: &str, notify: impl Fn() + Send + 'static, @@ -518,6 +612,27 @@ fn dated(author: &str, date: &str, time: &str) -> Result { } impl Section { + /// The section at catalog `path` of `library`, its edits naming `author`. + pub(crate) fn open( + library: Arc, + path: &str, + author: String, + notify: impl Fn() + Send + Sync + 'static, + ) -> Result { + let shared = Arc::new(Shared { + section: library.section(path, notify)?, + author, + status: AtomicU8::new(0), + }); + let mut open = library + .open + .lock() + .unwrap_or_else(|error| error.into_inner()); + open.push((path.to_owned(), Arc::downgrade(&shared))); + drop(open); + Ok(Self { shared, library }) + } + pub(crate) fn rows(&self) -> Result> { let section = &self.shared.section; let conflicts = section.conflicts()?; @@ -684,9 +799,50 @@ pub(crate) struct Found { impl Library { /// Pages of the notebook holding every word of `query` in their title or text, as the - /// desktop searches: the open section as its edits leave it, others as stored, read - /// again when their file changed. + /// desktop searches. pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result> { + self.indexed(open, |index| { + index + .search(&Query::new(query), |_| true) + .into_iter() + .map(|found| Found { + section: found.section, + page: found.space.to_string(), + title: found.title.replace(|char: char| char.is_control(), " "), + snippet: found.snippet, + }) + .collect() + }) + } + + /// The notebook's tagged paragraphs in page order, as OneNote's Tags Summary lists + /// them: once for each of their tags. + pub(crate) fn tagged(&self, open: Option<(&str, &Section)>) -> Result> { + self.indexed(open, |index| { + index + .tagged(|_| true) + .into_iter() + .map(|tagged: Tagged| Tag { + section: tagged.section, + page: tagged.space.to_string(), + title: tagged.title.replace(|char: char| char.is_control(), " "), + paragraph: tagged.paragraph.to_string(), + name: tagged.name, + shape: tagged.shape, + checked: tagged.checked, + text: tagged.text, + }) + .collect() + }) + } + + /// `read` over the notebook's pages: the open section as its edits leave it, others as + /// stored, read again when their file changed. + fn indexed( + &self, + open: Option<(&str, &Section)>, + read: impl FnOnce(&Index) -> T, + ) -> Result { let tabs: Vec = self .tabs()? .into_iter() @@ -714,18 +870,73 @@ impl Library { } } index.retain(|entry| tabs.contains(&entry.section)); - Ok(index - .search(&Query::new(query), |_| true) + Ok(read(index)) + } + + /// Each section's sync status in the notebook's order: an open section's from its + /// session, the others' from the background. + pub(crate) fn sync_status(&self) -> Vec { + let mut sections = self + .background + .as_ref() + .map(Background::status) + .unwrap_or_default(); + for (path, shared) in self.open_sections() { + let Some(status) = report(shared.section.sync_status().map_err(Into::into)) else { + continue; + }; + match sections.iter_mut().find(|(listed, _)| *listed == path) { + Some((_, listed)) => *listed = status, + None => sections.push((path, status)), + } + } + sections .into_iter() - .map(|found| Found { - section: found.section, - page: found.space.to_string(), - title: found.title.replace(|char: char| char.is_control(), " "), - snippet: found.snippet, + .map(|(path, status): (String, SyncStatus)| Synced { + state: status.state() as u8, + synced: status.synced.map(unix), + queued: status.queued, + error: status.error.map(|error| error.to_string()), + path, }) - .collect()) + .collect() } + fn open_sections(&self) -> Vec<(String, Arc)> { + let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner()); + open.retain(|(_, shared)| shared.strong_count() > 0); + open.iter() + .filter_map(|(path, shared)| Some((path.clone(), shared.upgrade()?))) + .collect() + } +} + +#[derive(serde::Serialize)] +pub(crate) struct Synced { + pub(crate) path: String, + /// `SyncState` by its place in the enum, best first. + pub(crate) state: u8, + /// When the section file was last reached, in seconds since 1970. + pub(crate) synced: Option, + /// Changes the file does not hold yet. + pub(crate) queued: u64, + pub(crate) error: Option, +} + +/// A tagged paragraph, from `sb_library_tagged`. +#[derive(serde::Serialize)] +pub(crate) struct Tag { + pub(crate) section: String, + pub(crate) page: String, + pub(crate) title: String, + pub(crate) paragraph: String, + pub(crate) name: String, + pub(crate) shape: u16, + pub(crate) checked: bool, + pub(crate) text: String, +} + +impl Library { /// Reads the section at catalog `path` into `index` again when its file changed, and /// checks at most every 30 seconds. fn reindex( @@ -814,12 +1025,12 @@ fn boxed(result: Result, error: *mut *mut c_char) -> *mut T { } } -fn json(value: Result) -> *mut c_char { +pub(crate) fn json(value: Result) -> *mut c_char { report(value.and_then(|value| Ok(serde_json::to_string(&value)?))) .map_or(std::ptr::null_mut(), owned) } -fn optional(text: *const c_char) -> Option { +pub(crate) fn optional(text: *const c_char) -> Option { (!text.is_null()).then(|| string(text)) } @@ -925,6 +1136,55 @@ pub unsafe extern "C" fn sb_library_search( json(library.search(open.map(|section| (path.as_str(), section)), &string(query))) } +/// The notebook's tagged paragraphs in page order as JSON, once for each of their tags: +/// each with `section`, `page`, `title`, `paragraph`, `name`, `shape` (the tag's symbol, 0 +/// for a highlighting tag), `checked` and `text`. `open` and `path` as `sb_library_search` +/// takes them; reads the notebook's sections, so call it off the main thread. +/// +/// # Safety +/// A non-null `path` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_tagged( + library: &Library, + open: Option<&Section>, + path: *const c_char, +) -> *mut c_char { + let path = optional(path).unwrap_or_default(); + json(library.tagged(open.map(|section| (path.as_str(), section)))) +} + +/// Each section's sync status as JSON, in the notebook's order: `path`, `state` (0 up to +/// date, 1 syncing, 2 in use elsewhere, 3 not connected, 4 read-only, 5 failed), `synced` +/// (seconds since 1970, or null before the file was reached), `queued` changes and `error`. +#[unsafe(no_mangle)] +pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char { + json(Ok(library.sync_status())) +} + +/// Works offline, or online again: edits wait in the cache until then or until +/// `sb_library_sync_now`. Sections opened later follow. +#[unsafe(no_mangle)] +pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) { + library.offline.store(offline, Ordering::Relaxed); + if let Some(background) = &library.background { + background.set_offline(offline); + } + for (_, shared) in library.open_sections() { + shared.section.set_offline(offline); + } +} + +/// Syncs every section of the notebook now, working offline included. +#[unsafe(no_mangle)] +pub extern "C" fn sb_library_sync_now(library: &Library) { + if let Some(background) = &library.background { + background.wake(); + } + for (_, shared) in library.open_sections() { + shared.section.wake(); + } +} + /// # Safety /// `library` came from `sb_library_open` or `sb_library_server` and is not used again. #[unsafe(no_mangle)] @@ -1050,16 +1310,7 @@ pub unsafe extern "C" fn sb_section_open( Arc::from_raw(library as *const Library) }; boxed( - library - .section(&string(path), move || wake(token)) - .map(|section| Section { - shared: Arc::new(Shared { - section, - author: string(author), - status: AtomicU8::new(0), - }), - library, - }), + Section::open(library, &string(path), string(author), move || wake(token)), error, ) } diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 4f092f2a0a99c61076f42b6af266be5878e544b8..db815de628544e01838e38c9bb2940010eec29b3 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -1,4 +1,6 @@ use super::*; +use library::{sb_library_set_offline, sb_library_sync_now}; +use notebook::session::SyncState; use std::path::{Path, PathBuf}; const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus"); @@ -24,15 +26,7 @@ fn copy(path: &str) -> (tempfile::TempDir, PathBuf) { } fn open(library: &Arc, path: &str) -> Section { - let section = library.section(path, || {}).unwrap(); - Section { - shared: Arc::new(Shared { - section, - author: "Clover Test".into(), - status: Default::default(), - }), - library: Arc::clone(library), - } + Section::open(Arc::clone(library), path, "Clover Test".into(), || {}).unwrap() } fn space(section: &Section, title: &str) -> ExGuid { @@ -230,7 +224,20 @@ fn undo_restores_typing() { let mut canvas = canvas(§ion, "Paragraph controls"); focus(&mut canvas, "Collapsed parent"); let before = canvas.active().shown_text(); - canvas.insert("hi".into()).unwrap(); + // UIKit's keystrokes, one call each, with the selection set again where it is: one run. + for text in ["h", "i", "x"] { + canvas.insert(text.into()).unwrap(); + let selection = canvas.selection().unwrap(); + canvas.select(selection).unwrap(); + } + assert!( + canvas + .page + .key(&Key::Named(NamedKey::Backspace), None) + .unwrap() + .changed + ); + canvas.insert("!".into()).unwrap(); assert_ne!(canvas.active().shown_text(), before); assert!(moved(canvas.page.undo(false).unwrap())); assert_eq!(canvas.active().shown_text(), before); @@ -629,3 +636,192 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026"); assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before); } + +/// A notebook copy with its library, and a section of it open for editing. +fn notebook_open(path: &str) -> (tempfile::TempDir, PathBuf, Arc, Section) { + let (directory, root) = copy("m6/native-features-01/notebook"); + let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); + let section = open(&library, path); + (directory, root, library, section) +} + +/// Waits up to ten seconds for `ready`. +fn eventually(mut ready: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if ready() { + return true; + } + std::thread::sleep(Duration::from_millis(50)); + } + false +} + +#[test] +fn sync_status_lists_every_section_and_work_offline_holds_edits_until_sync_now() { + let (_directory, root, library, section) = notebook_open("Features.one"); + // Every readable section, the open one's from its session. + assert!(eventually(|| { + let status = library.sync_status(); + status.len() + == library + .tabs() + .unwrap() + .iter() + .filter(|tab| tab.readable) + .count() + && status + .iter() + .all(|sync| sync.state == SyncState::UpToDate as u8) + })); + sb_library_set_offline(&library, true); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); + canvas.insert("Offline ".into()).unwrap(); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + let file = root.join("Features.one"); + let queued = |library: &Library| { + library + .sync_status() + .into_iter() + .find(|sync| sync.path == "Features.one") + .unwrap() + .queued + }; + assert!(eventually(|| queued(&library) == 1)); + std::thread::sleep(Duration::from_millis(500)); + assert!(!stored_text(&file, canvas.space).contains("Offline ")); + sb_library_sync_now(&library); + assert!(eventually(|| queued(&library) == 0)); + assert!(stored_text(&file, canvas.space).contains("Offline ")); + sb_library_set_offline(&library, false); +} + +#[test] +fn sections_open_the_first_time_while_the_background_syncs() { + let (directory, root) = copy("m6/native-features-01/notebook"); + let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); + let rounds = { + let library = Arc::clone(&library); + std::thread::spawn(move || { + for _ in 0..200 { + sb_library_sync_now(&library); + std::thread::sleep(Duration::from_millis(2)); + } + }) + }; + for tab in library + .tabs() + .unwrap() + .into_iter() + .filter(|tab| tab.readable) + { + open(&library, &tab.path); + } + rounds.join().unwrap(); + for tab in library + .tabs() + .unwrap() + .into_iter() + .filter(|tab| tab.readable) + { + open(&library, &tab.path); + } +} + +#[test] +fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() { + let (directory, file) = copy("structural-probe/tag-gallery.one"); + let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap()); + let path = file.to_string_lossy().into_owned(); + let section = open(&library, &path); + let tagged = library.tagged(Some((&path, §ion))).unwrap(); + assert!(tagged.len() >= 9, "{}", tagged.len()); + assert!( + tagged + .iter() + .any(|tag| tag.name == "To Do" && tag.shape == 3) + ); + let first = &tagged[0]; + let space = first.page.parse().unwrap(); + let (page, _) = section.shared.page(space).unwrap(); + let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap(); + assert!( + canvas + .select_paragraph(first.paragraph.parse().unwrap()) + .unwrap() + ); + let [start, end] = canvas.selection().unwrap(); + assert!(canvas.text([start, end]).contains(first.text.trim())); +} + +#[test] +fn every_tag_applies_and_reports_and_remove_tag_clears_them() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); + for index in [0, 12, 28] { + assert!(canvas.format(16 + index).unwrap()); + assert_ne!(canvas.format_bits().unwrap() & 1 << (16 + index), 0); + } + assert!(canvas.format(8).unwrap()); + assert_eq!(canvas.format_bits().unwrap() >> 16, 0); + assert!(canvas.format(16 + NoteTag::defaults().len() as u8).is_err()); +} + +#[test] +fn tag_icons_draw_their_art_and_highlighting_tags_have_none() { + let mut rgba = vec![0; 32 * 32 * 4]; + for tag in NoteTag::defaults() { + let drawn = unsafe { sb_tag_icon(tag.shape, true, 32, rgba.as_mut_ptr()) }; + assert_eq!(drawn, tag.shape != 0, "{tag:?}"); + if drawn { + assert!(rgba.chunks_exact(4).any(|pixel| pixel[3] > 0), "{tag:?}"); + } + } +} + +#[test] +fn page_colour_rule_lines_and_art_are_stored_and_undo() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + assert!(canvas.set_paper(Some(0xfef5ed), Some(1)).unwrap()); + assert!(canvas.set_art(Some("Bamboo")).unwrap()); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + let art = |page: &Page| { + page.objects + .iter() + .filter(|object| { + matches!(object, onestore::page::PageObject::Image(image) if image.background) + }) + .count() + }; + let stored = section.shared.page(canvas.space).unwrap().0; + assert_eq!(stored.color, Some(0xfef5ed)); + assert_eq!(stored.rule_lines, Some(canvas::template::RULE_LINES[1].1)); + assert_eq!(art(&stored), 1); + assert!(canvas.set_art(None).unwrap()); + assert!(moved(canvas.page.undo(false).unwrap())); + assert!(moved(canvas.page.undo(false).unwrap())); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + let stored = section.shared.page(canvas.space).unwrap().0; + assert_eq!((stored.color, art(&stored)), (Some(0xfef5ed), 0)); +} + +#[test] +fn insert_space_turns_the_next_touch_into_a_drag() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + assert_eq!(canvas.target([300.0, 1500.0]), Target::Page); + let _ = canvas.page.insert_space(); + assert_eq!(canvas.target([300.0, 1500.0]), Target::Grip); +} diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 7c7a39fa46e31bc7f50dde9ac6e05557e25ae7a3..288a7e32ffcd8203755d8dff6f0bd7a669c10262 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -14,7 +14,7 @@ use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; use std::{ fs::OpenOptions, io, - path::Path, + path::{Path, PathBuf}, sync::{Arc, Mutex, MutexGuard, mpsc}, time::Duration, }; @@ -106,6 +106,25 @@ impl Replica { /// An initialization error preserves the created file for inspection. pub fn create(path: impl AsRef, source: &[u8]) -> Result { validate(source)?; + let path = path.as_ref(); + // Built under another name and linked into place whole, so that a cache that exists + // is complete: a background step or a failed creation never leaves one half made. + let mut building = path.as_os_str().to_owned(); + building.push(".creating"); + let building = PathBuf::from(building); + for stale in ["", "-wal", "-shm"] { + let mut file = building.as_os_str().to_owned(); + file.push(stale); + let _ = std::fs::remove_file(file); + } + let built = Self::build(&building, source); + let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?)); + let _ = std::fs::remove_file(&building); + linked?; + Self::start(cache_connection(path)?) + } + + fn build(path: &Path, source: &[u8]) -> Result<()> { let mut options = OpenOptions::new(); options.read(true).write(true).create_new(true); #[cfg(unix)] @@ -113,8 +132,8 @@ impl Replica { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } - drop(options.open(path.as_ref())?); - let mut connection = cache_connection(path.as_ref())?; + drop(options.open(path)?); + let mut connection = cache_connection(path)?; write_ahead(&connection)?; let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; let tables: i64 = @@ -133,7 +152,7 @@ impl Replica { schema::create(&transaction)?; base::write(&transaction, base::Image::Base, source)?; transaction.commit()?; - Self::start(connection) + Ok(connection.close().map_err(|(_, error)| error)?) } /// Reopens an existing cache and its durable pending edits without network access. diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 2d7d924db748fd6073d4faa8ab30195fab5f0ee9..de83c4e40c61e46fec5d67449a064e81a8192d23 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -696,7 +696,9 @@ impl Diff { let part = |index: &Index| { index.nodes.get(object).map(|node| match (&node.own, edit) { ( - Own::Outline { layout, .. } | Own::Ink(Ink { layout, .. }), + Own::Outline { layout, .. } + | Own::Ink(Ink { layout, .. }) + | Own::Attachment(Attachment { layout, .. }), OutlineEdit::Position { .. }, ) => (layout.x, layout.y, None, None), (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => ( @@ -1073,6 +1075,9 @@ impl Index { PageObject::Image(image) => { index.add(image.id, PAGE, Own::Image(image.clone()), Vec::new()) } + PageObject::Attachment(file) => { + index.add(file.id, PAGE, Own::Attachment(file.clone()), Vec::new()) + } PageObject::Ink(ink) => index.add(ink.id, PAGE, Own::Ink(ink.clone()), Vec::new()), PageObject::Unsupported(object) => index.add( object.id, diff --git a/crates/notebook/src/queue.rs b/crates/notebook/src/queue.rs index 6fbc98dbfe04b3875876b212145e127089135617..11f56b04603196243831b9decc7671ebb211221f 100644 --- a/crates/notebook/src/queue.rs +++ b/crates/notebook/src/queue.rs @@ -47,6 +47,10 @@ fn object(object: &mut PageObject, visit: &mut impl FnMut(&mut Payload)) { } } PageObject::Image(image) => visit(&mut image.bytes), + PageObject::Attachment(attachment) => { + visit(&mut attachment.bytes); + visit(&mut attachment.preview); + } PageObject::Ink(_) | PageObject::Unsupported(_) => {} } } diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index f1973f933a74a0703eb65d394e5c3ac75ef665eb..d76676e865ef5052deed1d2e9b499d8344429edd 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -919,6 +919,17 @@ impl Notebook { &self, interval: Duration, notify: impl Fn() + Send + 'static, + ) -> Result { + self.background_with(interval, |file| FileRemote(file.to_owned()), notify) + } + + /// `background`, reaching each section file through the remote `remote` makes for it + /// (`section_with`). + pub fn background_with( + &self, + interval: Duration, + remote: impl Fn(&Path) -> R + Clone + Send + 'static, + notify: impl Fn() + Send + 'static, ) -> Result { let Some(root) = self.root.clone() else { return Err(io::Error::new( @@ -930,8 +941,8 @@ impl Notebook { Background::start( interval, move || { - let root = root.clone(); - Ok(move |path: &str| FileRemote(root.join(path))) + let (root, remote) = (root.clone(), remote.clone()); + Ok(move |path: &str| remote(&root.join(path))) }, notify, ) @@ -1110,6 +1121,39 @@ pub struct SyncStatus { pub queued: u64, } +/// What a `SyncStatus` comes to for the reader, from the best to the worst. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum SyncState { + UpToDate, + /// Not reached yet, or edits wait. + Syncing, + /// Another client holds the section file. + InUse, + /// The file or its server cannot be reached; edits wait until it can. + NotConnected, + /// The file cannot be written where it is stored. + ReadOnly, + Failed, +} + +impl SyncStatus { + pub fn state(&self) -> SyncState { + use io::ErrorKind::*; + match self.error.as_ref().map(io::Error::kind) { + Some(PermissionDenied | ReadOnlyFilesystem) => SyncState::ReadOnly, + Some(WouldBlock | ResourceBusy) => SyncState::InUse, + Some( + NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected + | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe + | AddrNotAvailable, + ) => SyncState::NotConnected, + Some(_) => SyncState::Failed, + None if self.synced.is_none() || self.queued > 0 => SyncState::Syncing, + None => SyncState::UpToDate, + } + } +} + /// The last attempt's outcome; `None` before the first. type Observed = Option>; diff --git a/crates/notebook/tests/copy.rs b/crates/notebook/tests/copy.rs index 8961a6f3a073da4a3838b5404fcd6f07d6dde706..206c5fa9c3d8805e54203c35e3680613a740bccc 100644 --- a/crates/notebook/tests/copy.rs +++ b/crates/notebook/tests/copy.rs @@ -11,11 +11,12 @@ use std::{ time::{Duration, Instant}, }; -/// Pages the writer authored for other rows: text with an attachment and its icon, a -/// table, nested tables with cell subtrees, an inserted picture, a page-level ink drawing, tags, lists, equations -/// and paragraph formatting. +/// Pages the writer authored for other rows: text with an attachment and its icon, files on +/// the page, a table, nested tables with cell subtrees, an inserted picture, a page-level ink +/// drawing, tags, lists, equations and paragraph formatting. const SOURCES: &[&str] = &[ "attachment-edit/icon/candidate/files.one", + "attachment-floating/candidate/files.one", "table-edit/created/candidate/tables.one", "table-edit/nested/candidate/synthetic.one", "picture-edit/inserted/candidate/pictures.one", diff --git a/crates/notebook/tests/sync_outline.rs b/crates/notebook/tests/sync_outline.rs index 37da748666c860d770661363035f23462f681159..2cf48fe03ad7d38eb6aed80e48e876562bb6f122 100644 --- a/crates/notebook/tests/sync_outline.rs +++ b/crates/notebook/tests/sync_outline.rs @@ -614,3 +614,93 @@ fn a_new_native_wrap_reservation_keeps_the_local_width_on_a_conflict_page() { assert_eq!(layout.max_width, Some(144.0)); assert!(layout.reserved_width.is_none()); } + +/// `source` with a file on its page, and the file's identity. +fn with_file(source: &[u8], space: ExGuid) -> (Vec, ExGuid) { + let id = onestore::page::text::new_id().unwrap(); + let file = onestore::page::Attachment { + id, + filename: "float.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + layout: onestore::document::Layout { + x: Some(342.0), + y: Some(284.4), + max_width: Some(54.0), + max_height: Some(63.0), + width_set_by_user: Some(false), + reserved_width: None, + }, + bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])), + preview: None, + recording: None, + }; + let image = remote_with(source, space, |page| { + page.objects.push(PageObject::Attachment(file)) + }); + (image, id) +} + +fn file_mut(page: &mut Page, id: ExGuid) -> &mut onestore::page::Attachment { + page.objects + .iter_mut() + .find_map(|object| match object { + PageObject::Attachment(file) if file.id == id => Some(file), + _ => None, + }) + .unwrap() +} + +/// A file on the page merges as an outline's position does: a local move and a remote rename +/// both land, and competing moves keep the local one on a conflict page. +#[test] +fn files_on_the_page_move_through_sync_and_competing_moves_conflict() { + let (source, space, _, text_id) = fixture(); + let (source, file) = with_file(&source, space); + let place = |x, y| { + move |page: &mut Page| { + let layout = &mut file_mut(page, file).layout; + [layout.x, layout.y] = [Some(x), Some(y)]; + } + }; + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + let id = save(&cache, text_id, place(126.0, 410.4)).unwrap().unwrap(); + let mut server = Server::new(&remote_with(&source, space, |page| { + file_mut(page, file).filename = "renamed.txt".into(); + })); + published(&cache, &mut server, id); + let mut durable = page_of(&server.durable, space); + let merged = file_mut(&mut durable, file); + assert_eq!( + (merged.layout.x, merged.layout.y, merged.filename.as_str()), + (Some(126.0), Some(410.4), "renamed.txt") + ); + assert_eq!(merged.bytes.as_deref(), Some(&b"Floating bytes"[..])); + + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + save(&cache, text_id, place(126.0, 410.4)).unwrap().unwrap(); + let remote = remote_with(&source, space, place(36.0, 50.4)); + let mut server = Server::new(&remote); + assert!(conflicted(&cache, &mut server, space)); + let mut durable = page_of(&server.durable, space); + assert_eq!(file_mut(&mut durable, file).layout.x, Some(36.0)); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, server.durable.clone()).unwrap(); + let conflict = section.conflicts().unwrap()[0].1[0].space; + let kept = section.page(conflict).unwrap(); + let copies: Vec<_> = kept + .objects + .iter() + .filter_map(|object| match object { + PageObject::Attachment(file) => Some(file), + _ => None, + }) + .collect(); + let [copy] = copies.as_slice() else { + panic!("{kept:?}") + }; + assert_eq!((copy.layout.x, copy.layout.y), (Some(126.0), Some(410.4))); + assert_eq!(copy.bytes.as_deref(), Some(&b"Floating bytes"[..])); +} diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 926c384d1f5cfea1ae8fe2dd3c82b4b6f848e3e1..9a75ec2008fb088f1e23f6d8797bac28ac951186 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -46,7 +46,7 @@ harness also accepts `--client-profile release`. | `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse | | `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity | | `page::Recording`, `page::MediaIndex` | A recording attachment's identity and type, and a paragraph's link to a moment in recordings; read from native pages and kept through edits, never authored | -| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points, a moved drawing offset by its position) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | +| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments in paragraphs or on the page, ink drawings and handwriting decoded to stroke polylines in page points, a moved drawing offset by its position) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries | @@ -56,7 +56,7 @@ harness also accepts `--client-profile release`. | `TocEdit`, `edit_table_of_contents` | Add, rename, order and remove a table of contents' section and group entries, and colour the notebook, as one revision's `Transaction`; a section's colour is `SectionOp::Color` in its own metadata | | `place`, `place_file` | Name a file for its notebook as OneNote does on adoption (parent TOC identity and name CRC in the header), so OneNote keeps its identity, on any `CommitIo` or under the filesystem adapter | | `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | -| `OutlineEdit`, `PageOp::Outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | +| `OutlineEdit`, `PageOp::Outline` | Change ordinary outline position/width, the position of a file or ink drawing on the page, or a paragraph's saved expansion default, preserving identities and content | | `Transaction`, `Stamp` | The bytes a commit writes (appended data, in-place list-tail and log patches, header) and the header and length it requires unchanged; `commit` under caller-held exclusion, `commit_file` under the conservative filesystem adapter, `apply` to the base image in memory; serializable for queues | | `Arena`, `Section` | Keep a section parsed across edits: `open` validates an image once; `seal` appends one revision per changed space as a `Transaction` on `stamp`, checking only what it appends; `replay` applies a queued transaction; `image` and `page` read the result | | `op::{Edit, Op, PageOp, TableEdit, SectionOp}`, `Section::apply` | Object-level edits (text, formatting, links, equations, paragraph insertion/split/join/move/deletion/levels, outlines, lists, tags, styles, paragraph formatting, tables, pictures, attachments, ink, page creation/import/moves/removal, conflict pages) applied whole or not to a kept-open section with emitter-chosen identities, UTF-16 ranges and the edit's time; refusals name the target, identity or structure at fault | diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index 819ca1c0a0dff81edbd7678660725afa9bc76b10..791a4f2fd124c33336171c46af71f095f4a3e920 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -1090,25 +1090,7 @@ impl<'a> Writer<'_, 'a> { self.picture(image, Some(paragraph.id)) } ParagraphContent::Attachment(attachment) => { - let Some(bytes) = &attachment.bytes else { - return Err(OpError::Unsupported("A new attachment needs its payload").into()); - }; - let ids = AttachmentIds { - object: attachment.id, - file: fresh()?, - payload: crate::write::fresh_guid()?, - preview: match &attachment.preview { - Some(_) => Some((crate::write::fresh_guid()?, fresh()?)), - None => None, - }, - }; - let mut payloads: Vec<([u8; 16], &[u8])> = vec![(ids.payload, bytes)]; - if let (Some((payload, _)), Some(icon)) = (ids.preview, &attachment.preview) { - payloads.push((payload, icon)); - } - self.write_with(&payloads, |page| { - content::attachment_changes(page, attachment, &ids, paragraph.id) - }) + self.attachment(attachment, Some(paragraph.id)) } ParagraphContent::Ink(ink) => self.ink(ink, Some(paragraph.id)), ParagraphContent::Unsupported(_) => { @@ -1136,6 +1118,32 @@ impl<'a> Writer<'_, 'a> { }) } + fn attachment( + &mut self, + attachment: &Attachment, + holder: Option, + ) -> Result<(), Failure> { + let Some(bytes) = &attachment.bytes else { + return Err(OpError::Unsupported("A new attachment needs its payload").into()); + }; + let ids = AttachmentIds { + object: attachment.id, + file: fresh()?, + payload: crate::write::fresh_guid()?, + preview: match &attachment.preview { + Some(_) => Some((crate::write::fresh_guid()?, fresh()?)), + None => None, + }, + }; + let mut payloads: Vec<([u8; 16], &[u8])> = vec![(ids.payload, bytes)]; + if let (Some((payload, _)), Some(icon)) = (ids.preview, &attachment.preview) { + payloads.push((payload, icon)); + } + self.write_with(&payloads, |page| { + content::attachment_changes(page, attachment, &ids, holder) + }) + } + fn ink(&mut self, ink: &Ink, holder: Option) -> Result<(), Failure> { for stroke in &ink.strokes { self.free(stroke.id)?; @@ -1166,13 +1174,14 @@ impl<'a> Writer<'_, 'a> { self.free(id)?; } } - if first.parent.is_some() || first.level != 1 { - return Err(OpError::Unsupported("An outline starts at its first level").into()); + if first.parent.is_some() { + return Err(OpError::Unsupported("An outline's first paragraph has no parent").into()); } self.paragraph(outline.id, None, first, Some((outline.id, x, y)))?; - // Later paragraphs follow the first, their levels as the outline gives them. - if !rest.is_empty() { - let mut targets = vec![(first.id, 1)]; + // Later paragraphs follow the first, their levels as the outline gives them; a + // deeper first paragraph goes in a group, as OneNote stores a tabbed one. + if !rest.is_empty() || first.level != 1 { + let mut targets = vec![(first.id, first.level)]; for paragraph in rest { let parent = paragraph.parent.unwrap_or(outline.id); let parent_level = rest @@ -1226,6 +1235,7 @@ impl<'a> Writer<'_, 'a> { } self.picture(image, None)?; } + PageObject::Attachment(attachment) => self.attachment(attachment, None)?, PageObject::Ink(ink) => self.ink(ink, None)?, PageObject::Title(_) | PageObject::Unsupported(_) => { return Err( diff --git a/crates/onestore/src/op/content.rs b/crates/onestore/src/op/content.rs index 7cc5eac85331ef7f49ba0f50ba0dffd36742b002..b7b9ea71a6ca6750c9fae28f11f86acbfb7e7724 100644 --- a/crates/onestore/src/op/content.rs +++ b/crates/onestore/src/op/content.rs @@ -218,7 +218,9 @@ pub(crate) fn picture_changes( [0xff, 0xd8, 0xff, ..] => ".jpg", [b'G', b'I', b'F', b'8', ..] => ".gif", [b'B', b'M', ..] => ".bmp", - _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")), + // OneNote keeps an imported TIFF as it is (`corpus/m6/native-features-01`). + [b'I', b'I', 0x2a, 0x00, ..] | [b'M', b'M', 0x00, 0x2a, ..] => ".tif", + _ => return Err(invalid("Choose a PNG, JPEG, GIF, BMP or TIFF picture")), }; let modified = crate::create::current_timestamps()?.0.to_le_bytes(); let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; @@ -303,12 +305,13 @@ pub(crate) struct AttachmentIds { /// What OneNote stores for an inserted file: an embedded-file container declaring the /// payload and an attachment object naming the file, which paragraph `holder` holds as -/// content. The caller embeds the payloads. +/// content or the page lists as a child at the attachment's position +/// (`corpus/attachment-floating`). The caller embeds the payloads. pub(crate) fn attachment_changes( active: &ActivePage<'_>, attachment: &Attachment, ids: &AttachmentIds, - holder: ExGuid, + holder: Option, ) -> Result { let name = attachment.filename.as_str(); if name.is_empty() || name.contains(['\0', '/', '\\']) { @@ -351,13 +354,27 @@ pub(crate) fn attachment_changes( .flat_map(|v| v.to_le_bytes()) .collect(), )); - values.push((0x14001c3e, 1u32.to_le_bytes().to_vec())); - values.push((0x14001c84, 1u32.to_le_bytes().to_vec())); + let positioned = attachment.layout.x.is_some() || attachment.layout.y.is_some(); + if holder.is_some() == positioned { + return Err(invalid( + "A file on the page has a position and a paragraph's file has none", + )); + } + // LayoutAlignmentInParent and LayoutAlignmentSelf: a paragraph's file is left-aligned in + // its paragraph; one on the page is aligned by its left margin (9), as OneNote stores. + match holder { + Some(_) => { + values.push((0x14001c3e, 1u32.to_le_bytes().to_vec())); + values.push((0x14001c84, 1u32.to_le_bytes().to_vec())); + } + None => values.push((0x14001c84, 9u32.to_le_bytes().to_vec())), + } values.push((0x1c001c22, crate::create::string(name))); values.push((0x1c001d9c, crate::create::string(name))); if let Some(path) = &attachment.source_path { values.push((0x1c001d9d, crate::create::string(path))); } + values.extend(layout_values(&Default::default(), &attachment.layout)?.0); let mut changed = BTreeMap::new(); let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?; file.jcid = crate::write::EMBEDDED_FILE_JCID; @@ -379,7 +396,7 @@ pub(crate) fn attachment_changes( node.set(&[(0x20001c3f, &reference)])?; } changed.insert(ids.object, node); - hold(active, &mut changed, ids.object, Some(holder), &modified)?; + hold(active, &mut changed, ids.object, holder, &modified)?; Ok(changed) } diff --git a/crates/onestore/src/op/lower.rs b/crates/onestore/src/op/lower.rs index 8e451030e1e86ec086cb5234163deb2d348cf222..9b8c0f043e15eb97480707642a07ef84364a8d2c 100644 --- a/crates/onestore/src/op/lower.rs +++ b/crates/onestore/src/op/lower.rs @@ -103,7 +103,10 @@ impl<'a> View<'a> { view.outline(outline)?; } } - PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => {} + PageObject::Image(_) + | PageObject::Attachment(_) + | PageObject::Ink(_) + | PageObject::Unsupported(_) => {} } } Ok(view) @@ -191,9 +194,10 @@ impl<'a> View<'a> { let outlines: Vec<&Outline> = match object { PageObject::Outline(outline) => vec![outline], PageObject::Title(title) => title.outlines.iter().collect(), - PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => { - Vec::new() - } + PageObject::Image(_) + | PageObject::Attachment(_) + | PageObject::Ink(_) + | PageObject::Unsupported(_) => Vec::new(), }; for outline in outlines { containers.push(outline.id); @@ -262,7 +266,10 @@ pub(crate) fn validate( title.layout, title.outlines.iter().map(|o| o.id).collect::>() )), - PageObject::Outline(_) | PageObject::Image(_) | PageObject::Ink(_) => None, + PageObject::Outline(_) + | PageObject::Image(_) + | PageObject::Attachment(_) + | PageObject::Ink(_) => None, }) .collect(); fixed.sort(); @@ -667,7 +674,18 @@ pub(crate) fn paragraph_fields( space_before: field!(space_before), space_after: field!(space_after), line_spacing: field!(line_spacing), - language: field!(language), + // An unset language is the writer's default, not LCID 0, which OneNote never stores + // on a text object and which runs without their own language would then read. + language: first.language.filter(|value| { + target + .spans() + .iter() + .all(|span| span.format.language == Some(*value)) + && stored + .spans() + .iter() + .any(|span| span.format.language != Some(*value)) + }), }) } @@ -986,6 +1004,54 @@ impl Lowering { })?; } } + let files = |page: &Page| -> BTreeMap { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Attachment(file) => Some((file.id, file.clone())), + _ => None, + }) + .collect() + }; + let stored = files(old.page); + for (id, file) in files(new.page) { + let Some(previous) = stored.get(&id) else { + continue; + }; + let (from, to) = (&previous.layout, &file.layout); + if ( + from.max_width, + from.max_height, + from.width_set_by_user, + from.reserved_width, + ) != ( + to.max_width, + to.max_height, + to.width_set_by_user, + to.reserved_width, + ) { + return Err(invalid("A file on the page keeps its extent")); + } + if (from.x, from.y) != (to.x, to.y) { + let (Some(x), Some(y)) = (to.x, to.y) else { + return Err(invalid("A file position needs both coordinates")); + }; + self.emit(PageOp::Outline { + object: id, + edit: crate::OutlineEdit::Position { x, y }, + })?; + } + if (&previous.filename, &previous.source_path, previous.size) + != (&file.filename, &file.source_path, file.size) + { + self.emit(PageOp::Attachment { + attachment: id, + filename: file.filename.clone(), + source_path: file.source_path.clone(), + size: file.size, + })?; + } + } let inks = |page: &Page| -> BTreeMap { page.objects .iter() @@ -1051,7 +1117,9 @@ impl Lowering { ..(*outline).clone() }) } - PageObject::Image(_) | PageObject::Ink(_) => (*object).clone(), + PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Ink(_) => { + (*object).clone() + } PageObject::Title(_) | PageObject::Unsupported(_) => { return Err(invalid( "Titles and unsupported objects cannot be edited through the page model", @@ -1513,8 +1581,10 @@ impl Lowering { } let kept: BTreeSet = new.page.objects.iter().map(PageObject::id).collect(); for object in &old.page.objects { - if matches!(object, PageObject::Image(_) | PageObject::Ink(_)) - && !kept.contains(&object.id()) + if matches!( + object, + PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Ink(_) + ) && !kept.contains(&object.id()) { self.emit(PageOp::Delete { object: object.id(), diff --git a/crates/onestore/src/op/mod.rs b/crates/onestore/src/op/mod.rs index b5542666a19497bbc9d18b3b2cd8fc1505540ef5..3c91882a2766cb0a9ae75ee8ffd63979af2e3ac8 100644 --- a/crates/onestore/src/op/mod.rs +++ b/crates/onestore/src/op/mod.rs @@ -114,7 +114,7 @@ pub enum PageOp { right: ExGuid, }, /// Moves a subtree before a direct child of `parent`, or last; `None` names the page, - /// whose children are outlines, pictures and ink. A paragraph keeps its level where it + /// whose children are outlines, pictures, files and ink. A paragraph keeps its level where it /// lies deeper than its new parent. Move { object: ExGuid, @@ -163,8 +163,8 @@ pub enum PageOp { tags: Vec, definitions: Vec<(ExGuid, Definition)>, }, - /// Adds an outline with its paragraphs, a picture with its payload, or ink, before a - /// page child or on top. + /// Adds an outline with its paragraphs, a picture or file with its payloads, or ink, + /// before a page child or on top. Add { object: PageObject, before: Option, diff --git a/crates/onestore/src/op/model.rs b/crates/onestore/src/op/model.rs index 3948e5213b25a11b472450b12bbaa8f2e26c7100..7bc0907bfa16490b3ff3be3bcea08965f4bec8fa 100644 --- a/crates/onestore/src/op/model.rs +++ b/crates/onestore/src/op/model.rs @@ -194,7 +194,9 @@ pub(crate) fn holds(page: &Page, id: ExGuid) -> bool { .iter() .any(|o| o.id == id || identities(&o.paragraphs).contains(&id)), PageObject::Ink(ink) => ink.strokes.iter().any(|s| s.id == id), - PageObject::Image(_) | PageObject::Unsupported(_) => false, + PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Unsupported(_) => { + false + } } }) } @@ -926,6 +928,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { OutlineEdit::Position { x, y } => { let layout = match page.objects.iter_mut().find(|o| o.id() == *object) { Some(PageObject::Ink(ink)) => &mut ink.layout, + Some(PageObject::Attachment(file)) => &mut file.layout, _ => &mut outline_mut(page, *object)?.layout, }; layout.x = Some(stored(*x)); @@ -1090,12 +1093,29 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { PageObject::Outline(outline) } PageObject::Ink(ink) => PageObject::Ink(inked(ink)), + PageObject::Attachment(file) => { + let mut file = file.clone(); + let layout = &mut file.layout; + for value in [ + &mut layout.x, + &mut layout.y, + &mut layout.max_width, + &mut layout.max_height, + ] { + *value = value.map(stored); + } + layout.width_set_by_user = layout + .max_width + .map(|_| layout.width_set_by_user == Some(true)); + PageObject::Attachment(file) + } object => object.clone(), }; let outline = matches!(object, PageObject::Outline(_)); let at = page_position(page, *before)?; page.objects.insert(at, object); - if outline { + // Placing an object before another moves it there, which retitles as a move does. + if outline || before.is_some() { retitle(page, None); } } @@ -1123,19 +1143,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { source_path, size, } => { - let (path, index) = lists(page) - .into_iter() - .find_map(|(path, _, list)| { - list.iter() - .position(|p| matches!(&p.content, ParagraphContent::Attachment(a) if a.id == *attachment)) - .map(|index| (path, index)) - }) - .ok_or_else(|| invalid("The attachment is not on the page"))?; - let ParagraphContent::Attachment(stored_attachment) = - &mut list_at(page, &path)[index].content - else { - unreachable!() - }; + let stored_attachment = attachment_mut(page, *attachment)?; stored_attachment.filename = filename.clone(); stored_attachment.source_path = source_path.clone(); stored_attachment.size = size.map(|[w, h]| [stored(w), stored(h)]); @@ -1305,6 +1313,27 @@ fn image_mut(page: &mut Page, id: ExGuid) -> Result<&mut crate::page::Image, Err Ok(image) } +fn attachment_mut(page: &mut Page, id: ExGuid) -> Result<&mut crate::page::Attachment, Error> { + if let Some(at) = page.objects.iter().position(|o| o.id() == id) { + let PageObject::Attachment(file) = &mut page.objects[at] else { + return Err(invalid("Select an attachment")); + }; + return Ok(file); + } + let (path, index) = lists(page) + .into_iter() + .find_map(|(path, _, list)| { + list.iter() + .position(|p| matches!(&p.content, ParagraphContent::Attachment(a) if a.id == id)) + .map(|index| (path, index)) + }) + .ok_or_else(|| invalid("The attachment is not on the page"))?; + let ParagraphContent::Attachment(file) = &mut list_at(page, &path)[index].content else { + unreachable!() + }; + Ok(file) +} + fn ink_mut(page: &mut Page, id: ExGuid) -> Result<&mut crate::page::Ink, Error> { if let Some(at) = page.objects.iter().position(|o| o.id() == id) { let PageObject::Ink(ink) = &mut page.objects[at] else { diff --git a/crates/onestore/src/op/properties.rs b/crates/onestore/src/op/properties.rs index ec5d3f0bab088be8d9c84b70b21fb23679b41731..fd0e7c8f8c71714f3f8f3119643d428c382c5171 100644 --- a/crates/onestore/src/op/properties.rs +++ b/crates/onestore/src/op/properties.rs @@ -141,11 +141,17 @@ fn tag_definition_values(definition: &Definition) -> Result { else { return Err(invalid("A note tag must reference a tag definition")); }; + // NoteTagPropertyStatus: hasLabel, hasFontColor, hasHighlightColor and hasIcon, as + // OneNote's Customize Tags writes them. + let status = u32::from(label.is_some()) + | u32::from(color.is_some()) << 1 + | u32::from(highlight.is_some()) << 2 + | u32::from(shape.is_some_and(|shape| shape != 0)) << 3; let mut values: Values = vec![ (0x0c003473, vec![0]), (0x10003463, action_type.unwrap_or(0).to_le_bytes().to_vec()), (0x10003464, shape.unwrap_or(0).to_le_bytes().to_vec()), - (0x14003467, 0u32.to_le_bytes().to_vec()), + (0x14003467, status.to_le_bytes().to_vec()), ]; if let Some(label) = label { values.push((0x1c003468, crate::create::string(label))); diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index 95488f28ebfdedfdfcff7882bcfada67eefdb74d..3e20993b312677f5c6be13b739f43c7e44edbb4c 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -1105,6 +1105,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { filename: "notes.txt".into(), source_path: Some(r"C:\notes.txt".into()), size: None, + layout: Layout::default(), bytes: Some(b"attached bytes".as_slice().into()), preview: None, recording: None, @@ -2957,3 +2958,20 @@ fn rule_lines_store_what_onenote_stores() { .unwrap(); } } + +/// Text added without a language stores the writer's, and lowering it back must not write +/// LCID 0 on the text object, which runs later written without a language would read. +#[test] +fn an_unset_language_is_not_paragraph_formatting() { + let language = |language| Format { + language, + ..Default::default() + }; + let stored = Paragraph::new(String::new(), language(Some(0x409))); + let fields = |target| lower::paragraph_fields(&stored, &target).unwrap().language; + assert_eq!(fields(Paragraph::new("Fresh".into(), language(None))), None); + assert_eq!( + fields(Paragraph::new("Frais".into(), language(Some(0x40c)))), + Some(0x40c) + ); +} diff --git a/crates/onestore/src/outline.rs b/crates/onestore/src/outline.rs index 85e51698be4f74ba52823924ab2f41c4141d394c..1ee2b5c68dfc56c69dd2eaa907fcb3d7979b5078 100644 --- a/crates/onestore/src/outline.rs +++ b/crates/onestore/src/outline.rs @@ -13,8 +13,9 @@ use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum OutlineEdit { - /// Moves an ordinary page outline to coordinates measured in points, or offsets a page's - /// ink drawing from its strokes' coordinates by that much, as OneNote 2010 moves one. + /// Moves an ordinary page outline or a file on the page to coordinates measured in + /// points, or offsets a page's ink drawing from its strokes' coordinates by that much, + /// as OneNote 2010 moves one. /// Refreshes automatic page titles when the leading outline changes. Position { x: f32, y: f32 }, /// Changes an ordinary outline's maximum width in points, at least 36. @@ -65,12 +66,14 @@ impl OutlineEdit { } } Self::Position { .. } => { - if !matches!(node.kind, Kind::Outline { .. } | Kind::Ink { .. }) - || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice()) + if !matches!( + node.kind, + Kind::Outline { .. } | Kind::Ink { .. } | Kind::Attachment { .. } + ) || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice()) || !view.nodes[page].children.contains(&object) { return Err(invalid( - "Select an ordinary outline or ink drawing directly on the page", + "Select an ordinary outline, file or ink drawing directly on the page", )); } } diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 9006bcee87082032d060d61da2d1775baa8a3ad6..54e6a9a648bd982a20eca80f23a3a5f643714b08 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -179,6 +179,8 @@ pub enum PageObject { Outline(Outline), Title(Title), Image(Image), + /// A file on a blank part of the page, as OneNote places one attached or dropped there. + Attachment(Attachment), Ink(Ink), Unsupported(Unsupported), } @@ -189,6 +191,7 @@ impl PageObject { Self::Outline(value) => value.id, Self::Title(value) => value.id, Self::Image(value) => value.id, + Self::Attachment(value) => value.id, Self::Ink(value) => value.id, Self::Unsupported(value) => value.id, } @@ -198,6 +201,7 @@ impl PageObject { Self::Outline(value) => &value.layout, Self::Title(value) => &value.layout, Self::Image(value) => &value.layout, + Self::Attachment(value) => &value.layout, Self::Ink(value) => &value.layout, Self::Unsupported(value) => &value.layout, } @@ -208,6 +212,7 @@ impl PageObject { Self::Outline(value) => &mut value.layout, Self::Title(value) => &mut value.layout, Self::Image(value) => &mut value.layout, + Self::Attachment(value) => &mut value.layout, Self::Ink(value) => &mut value.layout, Self::Unsupported(value) => &mut value.layout, } @@ -268,8 +273,11 @@ pub struct Attachment { pub filename: String, /// Where the file was inserted from, when recorded. pub source_path: Option, - /// The icon OneNote rendered for the file, when present. + /// The icon's size in points. pub size: Option<[f32; 2]>, + /// On the page, where the file's column sits and its extent; a paragraph's file has none. + #[serde(default)] + pub layout: Layout, #[serde(with = "payload")] pub bytes: Option>, #[serde(with = "payload")] @@ -301,6 +309,7 @@ impl PartialEq for Attachment { && self.filename == other.filename && self.source_path == other.source_path && self.size == other.size + && self.layout == other.layout && self.recording == other.recording } } @@ -343,6 +352,7 @@ impl Attachment { filename: filename.clone().unwrap_or_default(), source_path: source_path.clone(), size: icon_width.zip(*icon_height).map(|(w, h)| [w, h]), + layout: node.layout.clone(), bytes: payload(container)?, preview: payload(preview)?, recording: recording_id.map(|id| Recording { @@ -818,6 +828,11 @@ impl Page { page.objects .push(PageObject::Image(Image::read(revision, id, node)?)); } + Kind::Attachment { .. } => { + page.objects.push(PageObject::Attachment(Attachment::read( + revision, id, node, + )?)); + } Kind::Ink { .. } => { page.objects .push(PageObject::Ink(Ink::read(revision, id, node)?)); diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs index 978dce428560ee28bf04ce078c79c03279a212d0..55f59a68a64fd9054bacf05fa1d18a6a66c758ef 100644 --- a/crates/onestore/src/tree.rs +++ b/crates/onestore/src/tree.rs @@ -89,7 +89,10 @@ impl TreeEdit { .get(1) .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?; let compatible = |parent: ExGuid| match view.nodes[&self.object].kind { - Kind::Outline { .. } | Kind::Image { .. } | Kind::Ink { .. } => parent == *page, + Kind::Outline { .. } + | Kind::Image { .. } + | Kind::Attachment { .. } + | Kind::Ink { .. } => parent == *page, Kind::Paragraph { .. } => matches!( view.nodes[&parent].kind, Kind::Outline { .. } diff --git a/crates/onestore/tests/page_attachments.rs b/crates/onestore/tests/page_attachments.rs index d37e475a5e83bf32ef0aebeb4ab2e9ec5f3c048d..2a477e5cd3a580a69eb1dc362deb2339331b7ceb 100644 --- a/crates/onestore/tests/page_attachments.rs +++ b/crates/onestore/tests/page_attachments.rs @@ -112,6 +112,7 @@ fn insert_attachment(section: &str, preview: Option>) -> Vec { filename: "notes 🦀.txt".into(), source_path: None, size: Some([24.0, 24.0]), + layout: Default::default(), bytes: Some(Arc::from(PAYLOAD)), preview: preview.clone(), recording: None, @@ -182,6 +183,7 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { filename: "sub/dir.txt".into(), source_path: None, size: None, + layout: Default::default(), bytes: Some(Arc::from(PAYLOAD)), preview: None, recording: None, @@ -230,3 +232,139 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { .unwrap(); } } + +const FLOATING: &[u8] = include_bytes!("../../../corpus/attachment-floating/native/files.one"); + +fn floating(page: &Page) -> Vec<&Attachment> { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Attachment(file) => Some(file), + _ => None, + }) + .collect() +} + +#[test] +fn files_onenote_places_on_the_page_read_as_page_objects_sharing_one_payload() { + let store = Store::parse(FLOATING).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document.pages().unwrap()[1]; + let page = Page::from_space(&document, space).unwrap(); + let files = floating(&page); + let [moved, attached] = files.as_slice() else { + panic!("{page:?}"); + }; + // Inserted through COM then dragged, and attached at a click on blank page. + assert_eq!( + [moved.layout.x, moved.layout.y], + [Some(288.0), Some(140.40001)] + ); + assert_eq!(moved.layout.max_width, None); + assert_eq!( + [attached.layout.x, attached.layout.y], + [Some(342.0), Some(284.4)] + ); + assert_eq!( + [attached.layout.max_width, attached.layout.max_height], + [Some(54.0), Some(62.999985)] + ); + for file in files { + assert_eq!(file.filename, "float.txt"); + assert_eq!( + file.bytes.as_deref(), + Some(b"Floating file, stored at page level.\r\n".as_slice()) + ); + assert_eq!(file.size, Some([24.0, 24.0])); + } +} + +/// `ONESTORE_FLOATING_EXPORT` names a directory receiving the section for a cold reopen. +#[test] +fn a_file_on_the_page_is_attached_moved_renamed_and_removed_as_ops() { + let source = onestore::create_section("files.one", "Floating", "Author").unwrap(); + let (space, before) = first_page(&source); + let (_, native) = first_page(NATIVE); + let file = Attachment { + id: new_id().unwrap(), + filename: "float 🦀.txt".into(), + source_path: Some(r"C:\inputs\float 🦀.txt".into()), + size: Some([24.0, 24.0]), + layout: onestore::document::Layout { + x: Some(342.0), + y: Some(284.4), + max_width: Some(54.0), + max_height: Some(63.0), + width_set_by_user: Some(false), + reserved_width: None, + }, + bytes: Some(Arc::from(PAYLOAD)), + preview: attachments(&native)[0].preview.clone(), + recording: None, + }; + let steps: [&dyn Fn(&mut Page); 4] = [ + &|page| page.objects.push(PageObject::Attachment(file.clone())), + &|page| { + let PageObject::Attachment(file) = page.objects.last_mut().unwrap() else { + panic!() + }; + [file.layout.x, file.layout.y] = [Some(126.0), Some(410.4)]; + }, + &|page| { + let PageObject::Attachment(file) = page.objects.last_mut().unwrap() else { + panic!() + }; + file.filename = "renamed.txt".into(); + }, + &|page| { + page.objects.pop(); + }, + ]; + let mut image = source.clone(); + let mut expected = before.clone(); + for (step, edit) in steps.iter().enumerate() { + edit(&mut expected); + let arena = onestore::Arena::default(); + let section = onestore::Section::open(&arena, image.clone()).unwrap(); + let stored = section.page(space).unwrap(); + let lowered = onestore::op::lower_page(&stored, &expected).unwrap(); + let mut predicted = stored.clone(); + for op in &lowered { + onestore::op::predict(&mut predicted, op).unwrap(); + } + image = ops::page_edited(&image, space, lowered).unwrap(); + let read = page_in(&image, space); + assert_eq!(read, predicted, "step {step}"); + assert_eq!(read, expected, "step {step}"); + if let [file] = floating(&read)[..] { + assert_eq!(file.bytes.as_deref(), Some(PAYLOAD)); + assert_eq!(file.preview, attachments(&native)[0].preview); + } + if step == 1 + && let Some(directory) = std::env::var_os("ONESTORE_FLOATING_EXPORT") + { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("files.one"), &image).unwrap(); + let file_id = Store::parse(&image).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents( + "Open Notebook.onetoc2", + &[("files.one", file_id)], + ) + .unwrap(), + ) + .unwrap(); + } + } + assert!(floating(&page_in(&image, space)).is_empty()); + // A file on the page has a position; one in a paragraph has none. + let mut unplaced = before.clone(); + unplaced.objects.push(PageObject::Attachment(Attachment { + layout: Default::default(), + ..file.clone() + })); + assert!(ops::saved(&source, space, &unplaced).is_err()); +} diff --git a/crates/onestore/tests/page_model.rs b/crates/onestore/tests/page_model.rs index 6977397bc55e99f12b245fa050e509cb8ac9b851..483af0241fa4dd52a53d8d3b5c7303c50c39be07 100644 --- a/crates/onestore/tests/page_model.rs +++ b/crates/onestore/tests/page_model.rs @@ -60,7 +60,10 @@ fn identities(page: &Page) -> Vec { ids.extend(outline.unsupported.iter().map(|u| u.id)); } } - PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => {} + PageObject::Image(_) + | PageObject::Attachment(_) + | PageObject::Ink(_) + | PageObject::Unsupported(_) => {} } } ids diff --git a/crates/onestore/tests/protected.rs b/crates/onestore/tests/protected.rs index 73e918906d5c247534598db1f26aa96efc616dea..3fe69af3dfd091e9720a1e9b92db3ce347fea91d 100644 --- a/crates/onestore/tests/protected.rs +++ b/crates/onestore/tests/protected.rs @@ -175,6 +175,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { filename: "sealed.txt".into(), source_path: None, size: Some([24.0, 24.0]), + layout: Default::default(), bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), preview: None, recording: None, diff --git a/crates/onestore/tests/support/page_edits.rs b/crates/onestore/tests/support/page_edits.rs index 8051562a6c97d03477487eb64113a31217080ac1..53718a2e02fe072b70ffe31f93b7dcffe64c86f6 100644 --- a/crates/onestore/tests/support/page_edits.rs +++ b/crates/onestore/tests/support/page_edits.rs @@ -142,6 +142,7 @@ fn projection(page: &Page) -> String { } PageObject::Title(title) => out.push_str(&format!("title {}\n", title.id)), PageObject::Image(image) => out.push_str(&format!("image {}\n", image.id)), + PageObject::Attachment(file) => out.push_str(&format!("file {}\n", file.id)), PageObject::Ink(ink) => out.push_str(&format!("ink {}\n", ink.id)), PageObject::Unsupported(u) => out.push_str(&format!("unsupported {}\n", u.id)), } diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 74c3fffb528dc2a1619d7212304b2142e124aa5a..e40ce53dd77d37695e369db8391b861205b0df51 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -66,6 +66,3 @@ pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy"); pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); pub const SYNC_ERROR: &[&str] = art!("icons/sync-error"); pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline"); - -pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); -pub const TAG_DEFINITION: &[&str] = art!("tags/definition"); diff --git a/crates/snowbound/src/attachment.rs b/crates/snowbound/src/attachment.rs new file mode 100644 index 0000000000000000000000000000000000000000..38964cd234786198af2c532a4c81612d676e4b5c --- /dev/null +++ b/crates/snowbound/src/attachment.rs @@ -0,0 +1,94 @@ +//! Files on the page, as OneNote 2010 attaches, opens and saves them. + +use crate::{State, platform}; +use onestore::page::Attachment; +use std::{error::Error, path::Path}; + +/// The size OneNote 2010 shows a file's icon at, in points. +const ICON_SIZE: [f32; 2] = [24.0, 24.0]; + +impl State { + /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes + /// at the caret or at `at`, with the icon the system shows for it. + pub(crate) fn attach( + &mut self, + path: &Path, + at: Option<[f32; 2]>, + ) -> Result<(), Box> { + if self.session.as_ref().is_some_and(crate::Session::read_only) { + return Ok(()); + } + let (Some(name), Ok(bytes)) = ( + path.file_name().and_then(|name| name.to_str()), + std::fs::read(path), + ) else { + platform::alert("Couldn't attach the file", "Choose a file you can open."); + return Ok(()); + }; + let file = Attachment { + id: onestore::page::text::new_id()?, + filename: name.to_owned(), + source_path: path.to_str().map(str::to_owned), + size: Some(ICON_SIZE), + layout: Default::default(), + bytes: Some(bytes.into()), + preview: platform::file_icon(path).map(Into::into), + recording: None, + }; + let response = match at { + Some(point) => { + let scale = self.ui.scale(); + let corner = self.ui.rect(crate::page()).unwrap_or_default(); + self.view.drop_attachment( + [ + (point[0] - corner[0]) * scale, + (point[1] - corner[1]) * scale, + ], + file, + )? + } + None => self.view.insert_attachment(file)?, + }; + self.respond(response); + Ok(()) + } + + /// Open: a copy of the file, in a folder of its own, with the system's application for it. + pub(crate) fn open_attachment(&self, file: &Attachment) -> Result<(), Box> { + let Some(bytes) = &file.bytes else { + unstored(); + return Ok(()); + }; + let root = std::env::temp_dir().join("Snowbound Attachments"); + std::fs::create_dir_all(&root)?; + // A copy opened earlier may still be open, edited, in its application. + let folder = (0..1000) + .map(|index| root.join(index.to_string())) + .find(|folder| std::fs::create_dir(folder).is_ok()) + .ok_or("No folder is free for a copy of the file")?; + let path = folder.join(&file.filename); + std::fs::write(&path, bytes)?; + platform::open_file(&path); + Ok(()) + } + + /// Save As: the file's bytes where the user chooses, under its name by default. + pub(crate) fn save_attachment(&self, file: &Attachment) -> Result<(), Box> { + let Some(bytes) = &file.bytes else { + unstored(); + return Ok(()); + }; + if let Some(path) = platform::pick_new("Save As", &file.filename, "Save") { + std::fs::write(path, bytes)?; + } + Ok(()) + } +} + +/// A file another program stored beside the section rather than in it. +fn unstored() { + platform::alert( + "Couldn't open the file", + "Open this page in OneNote to open or save the file.", + ); +} diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 420ee3f08b9a9315d2b6f74054240d7670965d94..8f279e1436b4656f8cb42d2752b716e5cf09058f 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -2,7 +2,7 @@ //! chords on each platform, when it applies and what it does. use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search}; -use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, NoteTag, Toggle}; +use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Toggle}; use canvas::interaction::Request; use draw::edit::{Key, Modifiers, NamedKey, Platform}; use std::{error::Error, sync::Arc}; @@ -63,7 +63,9 @@ pub enum Id { Indent, Outdent, ClearFormatting, - Tag(NoteTag), + /// The tag at this place in the user's tag list. + Tag(usize), + CustomizeTags, RemoveTags, FindTags, Help, @@ -176,15 +178,6 @@ macro_rules! row { }; } -macro_rules! tag { - ($tag:expr, $digit:literal) => { - row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)]) - }; - ($tag:expr) => { - row!(Id::Tag($tag), $tag.label(), NONE, NONE) - }; -} - const NONE: &[Chord] = &[]; /// OneNote 2010's chords off macOS; there, AppKit's and OneNote for Mac's. @@ -378,35 +371,7 @@ pub const COMMANDS: &[Command] = &[ &[cmd('n').shift()], &[cmd('n').shift()] ), - tag!(NoteTag::ToDo, '1'), - tag!(NoteTag::Important, '2'), - tag!(NoteTag::Question, '3'), - tag!(NoteTag::RememberForLater, '4'), - tag!(NoteTag::Definition, '5'), - tag!(NoteTag::Highlight, '6'), - tag!(NoteTag::Contact, '7'), - tag!(NoteTag::Address, '8'), - tag!(NoteTag::PhoneNumber, '9'), - tag!(NoteTag::WebSiteToVisit), - tag!(NoteTag::Idea), - tag!(NoteTag::Password), - tag!(NoteTag::Critical), - tag!(NoteTag::ProjectA), - tag!(NoteTag::ProjectB), - tag!(NoteTag::MovieToSee), - tag!(NoteTag::BookToRead), - tag!(NoteTag::MusicToListenTo), - tag!(NoteTag::SourceForArticle), - tag!(NoteTag::RememberForBlog), - tag!(NoteTag::DiscussWithPersonA), - tag!(NoteTag::DiscussWithPersonB), - tag!(NoteTag::DiscussWithManager), - tag!(NoteTag::SendInEmail), - tag!(NoteTag::ScheduleMeeting), - tag!(NoteTag::CallBack), - tag!(NoteTag::ToDoPriority1), - tag!(NoteTag::ToDoPriority2), - tag!(NoteTag::ClientRequest), + row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE), row!( Id::RemoveTags, "Remove Tag", @@ -417,6 +382,9 @@ pub const COMMANDS: &[Command] = &[ row!(Id::Help, "Snowbound Help", NONE, NONE), ]; +/// The most tags a list holds: MS-ONE's action types 0 to 99 number them. +pub const TAGS: usize = 100; + /// Where Snowbound Help leads. const HELP: &str = "https://shale.paperclover.net/snowbound"; @@ -443,6 +411,7 @@ pub fn choices() -> impl Iterator { .map(|command| Choice::Command(command.id)) .chain(FONTS.map(|name| Choice::Font(name.to_owned()))) .chain(SIZES.into_iter().map(Choice::Size)) + .chain((0..TAGS).map(|place| Choice::Command(Id::Tag(place)))) .chain(std::iter::once(Choice::Highlight(None))) .chain( HIGHLIGHTS @@ -455,6 +424,7 @@ pub fn choices() -> impl Iterator { #[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub fn title(choice: Choice) -> String { match choice { + Choice::Command(Id::Tag(_)) => unreachable!("The tag list names its tags"), Choice::Command(id) => command(id).title.to_owned(), Choice::Font(name) => name, Choice::Size(size) => format!("{size}"), @@ -483,11 +453,30 @@ pub fn index(choice: &Choice) -> usize { /// The command `key` with `modifiers` runs here. pub fn find(key: &Key, modifiers: Modifiers) -> Option { - let pressed = pressed(key, modifiers, Platform::CURRENT)?; + ran( + pressed(key, modifiers, Platform::CURRENT)?, + Platform::CURRENT, + ) +} + +/// The command `chord` runs on `platform`. +fn ran(chord: Chord, platform: Platform) -> Option { COMMANDS .iter() - .find(|command| command.chords(Platform::CURRENT).contains(&pressed)) + .find(|command| command.chords(platform).contains(&chord)) .map(|command| command.id) + .or_else(|| { + (0..9) + .find(|&place| tag_chord(place) == Some(chord)) + .map(Id::Tag) + }) +} + +/// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to Ctrl+9, +/// as OneNote's do. +pub fn tag_chord(place: usize) -> Option { + let digit = u8::try_from(place).ok().filter(|place| *place < 9)?; + Some(cmd(char::from(b'1' + digit))) } fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option { @@ -599,10 +588,11 @@ impl Chord { /// The chord a menu or hint shows for `id` here, empty where it has none. pub fn shortcut(id: Id) -> String { - command(id) - .chords(Platform::CURRENT) - .first() - .map_or_else(String::new, |chord| chord.label(Platform::CURRENT)) + let chord = match id { + Id::Tag(place) => tag_chord(place), + id => command(id).chords(Platform::CURRENT).first().copied(), + }; + chord.map_or_else(String::new, |chord| chord.label(Platform::CURRENT)) } impl State { @@ -627,7 +617,7 @@ impl State { pub(crate) fn status(&self, choice: &Choice, format: &FormatState) -> Status { let session = self.session.as_ref(); let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); - let modal = self.options.is_some() || self.link.is_some(); + let modal = self.options.is_some() || self.link.is_some() || self.tag_list.is_some(); let page = (session.is_some() || self.temporary) && !modal; let writable = page && !session.is_some_and(|session| session.read_only()); let text = writable && self.view.accepts_text(); @@ -655,9 +645,12 @@ impl State { Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor, }; match id { - Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::CommandPalette | Id::Help => { - enabled(!modal) - } + Id::Settings + | Id::NewNotebook + | Id::OpenNotebook + | Id::CommandPalette + | Id::CustomizeTags + | Id::Help => enabled(!modal), Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()), Id::NewSection | Id::NewSectionGroup => enabled( !modal @@ -715,14 +708,15 @@ impl State { }, Id::PageColor => enabled(writable && session.is_some()), Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")), - // Attach File awaits #23, Record Audio and Video #36. - Id::Attachment | Id::RecordAudio | Id::RecordVideo => enabled(false), + // Record Audio and Video await #36. + Id::RecordAudio | Id::RecordVideo => enabled(false), Id::InsertSpace => Status { enabled: writable, checked: self.view.inserting_space(), }, Id::Table | Id::Picture + | Id::Attachment | Id::Symbol | Id::Link | Id::Equation @@ -739,7 +733,10 @@ impl State { Id::Bullets => checked(format.bullets), Id::Numbering => checked(format.numbering), Id::Align(alignment) => checked(format.alignment == Some(alignment)), - Id::Tag(tag) => checked(format.tags.contains(&tag)), + Id::Tag(place) => match self.tags.get(place) { + Some(tag) => checked(format.tags.contains(&(tag.clone(), place as u16))), + None => Status::default(), + }, } } @@ -815,14 +812,19 @@ impl State { return Ok(()); } Choice::PageColor(color) => { - return self.paper_page(color, self.view.editor.rule_lines()); + return self.paper_page(color, self.view.editor.rule_lines(), None); } Choice::Art(name) => { - return self.art_page(name.and_then(canvas::template::find)); + let editor = &self.view.editor; + return self.paper_page( + editor.page_color(), + editor.rule_lines(), + Some(name.and_then(canvas::template::find)), + ); } Choice::RuleLines(lines) => { let lines = lines.map(|index| canvas::template::RULE_LINES[index].1); - return self.paper_page(self.view.editor.page_color(), lines); + return self.paper_page(self.view.editor.page_color(), lines, None); } }; let field = self.ui.focused().filter(|focus| *focus != page()); @@ -951,11 +953,18 @@ impl State { return Ok(()); } Id::Picture => { - let Some(path) = platform::pick_picture() else { + let Some(path) = platform::pick_file("Insert Picture", &crate::PICTURE_TYPES) + else { return Ok(()); }; return self.insert_picture(std::fs::read(path)?); } + Id::Attachment => { + let Some(path) = platform::pick_file("Attach File", &[]) else { + return Ok(()); + }; + return self.attach(&path, None); + } Id::ScreenClipping => { #[cfg(target_os = "macos")] platform::clip_screen(self.proxy.clone()); @@ -971,7 +980,7 @@ impl State { self.save_settings(); return Ok(()); } - Id::Attachment | Id::RecordAudio | Id::RecordVideo => { + Id::RecordAudio | Id::RecordVideo => { return Ok(()); } Id::InsertSpace => { @@ -1016,7 +1025,14 @@ impl State { Id::Indent => return format(self, Formatting::Indent), Id::Outdent => return format(self, Formatting::Outdent), Id::ClearFormatting => return format(self, Formatting::Clear), - Id::Tag(tag) => return format(self, Formatting::Tag(tag)), + Id::Tag(place) => { + let tag = self.tags[place].clone(); + return format(self, Formatting::Tag(tag, place as u16)); + } + Id::CustomizeTags => { + self.open_customize_tags(); + return Ok(()); + } Id::RemoveTags => return format(self, Formatting::RemoveTags), }; self.commands.push(response); @@ -1039,8 +1055,9 @@ mod tests { command .chords(platform) .iter() - .map(move |chord| (chord, command.title)) + .map(move |chord| (*chord, command.title)) }) + .chain((0..9).filter_map(|place| Some((tag_chord(place)?, "a tag")))) .collect(); for (at, (chord, title)) in chords.iter().enumerate() { if let Some((_, other)) = @@ -1081,11 +1098,10 @@ mod tests { control, command, }; - let pressed = pressed(&Key::Character(key.into()), modifiers, platform)?; - COMMANDS - .iter() - .find(|listed| listed.chords(platform).contains(&pressed)) - .map(|listed| listed.id) + ran( + pressed(&Key::Character(key.into()), modifiers, platform)?, + platform, + ) }; let mac = Platform::MacOs; assert_eq!( @@ -1110,10 +1126,7 @@ mod tests { find(mac, "0", false, false, true, true), Some(Id::RemoveTags) ); - assert_eq!( - find(mac, "7", false, false, false, true), - Some(Id::Tag(NoteTag::Contact)) - ); + assert_eq!(find(mac, "7", false, false, false, true), Some(Id::Tag(6))); assert_eq!(find(mac, "q", false, false, false, true), None); // The highlighter's chord runs the command that applies its last pick. assert_eq!( diff --git a/crates/snowbound/src/link.rs b/crates/snowbound/src/link.rs index cb537b9dc7f0d78ca14e7ac49fd7dafe7a09a6ec..b6a48dc43cab972a0916a1e6a17e856b74b96d7f 100644 --- a/crates/snowbound/src/link.rs +++ b/crates/snowbound/src/link.rs @@ -275,7 +275,7 @@ impl State { Ok(()) } - /// A secondary press on the page's text opens its context menu there. + /// A secondary press on the page's text or a file opens its context menu there. pub(crate) fn open_text_menu(&mut self) -> Result<(), Box> { let Some((response, context)) = self.view.context()? else { return Ok(()); @@ -288,7 +288,7 @@ impl State { } /// The page's context menu while it is open, with OneNote 2010's commands for text, - /// links and equations. + /// links, equations and files. pub(crate) fn text_menu(&mut self) -> Result<(), Box> { let Some((context, point)) = &self.text_menu else { return Ok(()); @@ -301,48 +301,54 @@ impl State { text, ..Item::default() }; - let mut items = vec![ - Item { - disabled: !context.selected, - ..item("Cut") - }, - Item { - disabled: !context.selected, - ..item("Copy") - }, - item("Paste"), - ]; - match &context.link { - Some(_) => items.extend([ + let items = if context.attachment.is_some() { + // OneNote 2010's commands for the file itself; its clipboard holds text alone. + vec![item("Open"), item("Save As…")] + } else { + let mut items = vec![ Item { - separated: true, - ..item("Edit Link…") + disabled: !context.selected, + ..item("Cut") }, - item("Copy Link to Paragraph"), Item { - separated: true, - ..item("Copy Link") + disabled: !context.selected, + ..item("Copy") }, - item("Select Link"), - item("Remove Link"), - ]), - None => items.extend([ - Item { - separated: true, - ..item("Link…") - }, - item("Copy Link to Paragraph"), - ]), - } - if context.equation { - items.extend([ - Item { - separated: true, - ..item("Professional") - }, - item("Linear"), - ]); - } + item("Paste"), + ]; + match &context.link { + Some(_) => items.extend([ + Item { + separated: true, + ..item("Edit Link…") + }, + item("Copy Link to Paragraph"), + Item { + separated: true, + ..item("Copy Link") + }, + item("Select Link"), + item("Remove Link"), + ]), + None => items.extend([ + Item { + separated: true, + ..item("Link…") + }, + item("Copy Link to Paragraph"), + ]), + } + if context.equation { + items.extend([ + Item { + separated: true, + ..item("Professional") + }, + item("Linear"), + ]); + } + items + }; let Some(chosen) = ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None) else { @@ -352,6 +358,8 @@ impl State { return Ok(()); }; let response = match items[chosen].text { + "Open" => return self.open_attachment(&context.attachment.unwrap()), + "Save As…" => return self.save_attachment(&context.attachment.unwrap()), "Cut" => self.view.copy(true)?, "Copy" => self.view.copy(false)?, "Paste" => { diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 8870b50becd4f298ea228c184b4a190a75231c27..a700797a0f7b2cb98b94398d14cac4f70b633c47 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -593,6 +593,8 @@ pub fn install_menu() {} /// The toolbar and keyboard stand in for a menu bar. pub fn update_menu(_: impl FnOnce() -> Vec) {} +pub fn update_tag_menu(_: &[canvas::editor::NoteTag]) {} + pub fn clear_marked_text(_: &Window) {} #[cfg(feature = "wgpu")] @@ -1056,24 +1058,23 @@ fn dialog( .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) } -/// Asks for a picture to insert. -pub fn pick_picture() -> Option { - let patterns = crate::PICTURE_TYPES - .map(|kind| format!("*.{kind}")) - .join(" "); +/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. +pub fn pick_file(title: &str, types: &[&str]) -> Option { + let patterns = match types { + [] => "*".to_owned(), + types => types + .iter() + .map(|kind| format!("*.{kind}")) + .collect::>() + .join(" "), + }; let asked = dialog( [ "--file-selection", - "--title=Insert Picture", - &format!("--file-filter=Pictures | {patterns}"), - ], - [ - "--getopenfilename", - ".", - &patterns, - "--title", - "Insert Picture", + &format!("--title={title}"), + &format!("--file-filter={patterns}"), ], + ["--getopenfilename", ".", &patterns, "--title", title], ); asked .unwrap_or_else(|error| { @@ -1084,6 +1085,21 @@ pub fn pick_picture() -> Option { .map(PathBuf::from) } +/// No icon theme lookup; the page draws a blank page for the file. +pub fn file_icon(_: &std::path::Path) -> Option> { + None +} + +/// winit reports drops without a position; the file goes to the caret. +pub fn drop_point(_: &Window) -> Option<[f32; 2]> { + None +} + +/// Opens a copy of an attachment with the desktop's application for it. +pub fn open_file(path: &std::path::Path) { + reveal(path); +} + /// Opens GNOME's character map, whose picks the user copies in. pub fn character_palette() { if let Err(error) = Command::new("gnome-characters").spawn() { @@ -1151,8 +1167,8 @@ pub fn pick_notebook(title: &str) -> Option { .map(PathBuf::from) } -/// Asks where to create something named `name` by default. -pub fn pick_new(title: &str, name: &str) -> Option { +/// Asks where to put something named `name` by default; the dialog names its own button. +pub fn pick_new(title: &str, name: &str, _action: &str) -> Option { let asked = dialog( [ "--file-selection", diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 21ef151bf0d0118cddc65db877471adfd1b9608e..d4fccd8adaf5efa67099198b7b6e2675c6756482 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -11,7 +11,7 @@ use objc2::{ use objc2_app_kit::{ NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType, - NSMenuItem, + NSMenu, NSMenuItem, }; use objc2_foundation::{ MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, @@ -38,6 +38,7 @@ static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); thread_local! { static IN_KEY_DOWN: Cell = const { Cell::new(false) }; static STATUSES: RefCell> = const { RefCell::new(Vec::new()) }; + static TAGS_MENU: RefCell>> = const { RefCell::new(None) }; } unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) { @@ -427,14 +428,14 @@ pub fn pick_notebook(title: &str) -> Option { } } -/// Asks where to create something named `name` by default, with the system's save panel. -pub fn pick_new(title: &str, name: &str) -> Option { +/// Asks where to `action` something named `name` by default, with the system's save panel. +pub fn pick_new(title: &str, name: &str, action: &str) -> Option { let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); unsafe { let panel = objc2_app_kit::NSSavePanel::savePanel(mtm); panel.setCanCreateDirectories(true); panel.setTitle(Some(&NSString::from_str(title))); - panel.setPrompt(Some(&NSString::from_str("Create"))); + panel.setPrompt(Some(&NSString::from_str(action))); panel.setNameFieldStringValue(&NSString::from_str(name)); if panel.runModal() != objc2_app_kit::NSModalResponseOK { return None; @@ -443,14 +444,18 @@ pub fn pick_new(title: &str, name: &str) -> Option { } } -/// Asks for a picture to insert, with the system's open panel. -pub fn pick_picture() -> Option { +/// Asks for a file to insert, one of `types` (extensions) unless empty, with the system's +/// open panel titled `title`. +pub fn pick_file(title: &str, types: &[&str]) -> Option { let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); unsafe { let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); - let types = crate::PICTURE_TYPES.map(NSString::from_str); - #[allow(deprecated, reason = "Allowed content types need macOS 11")] - panel.setAllowedFileTypes(Some(&objc2_foundation::NSArray::from_vec(types.into()))); + if !types.is_empty() { + let types = types.iter().map(|kind| NSString::from_str(kind)).collect(); + #[allow(deprecated, reason = "Allowed content types need macOS 11")] + panel.setAllowedFileTypes(Some(&objc2_foundation::NSArray::from_vec(types))); + } + panel.setTitle(Some(&NSString::from_str(title))); panel.setPrompt(Some(&NSString::from_str("Insert"))); if panel.runModal() != objc2_app_kit::NSModalResponseOK { return None; @@ -460,6 +465,88 @@ pub fn pick_picture() -> Option { } } +/// The icon Finder shows for the file at `path`, as a 32 pixel PNG like the one OneNote +/// 2010 stores with an attachment. +pub fn file_icon(path: &std::path::Path) -> Option> { + MainThreadMarker::new().expect("AppKit draws on the main thread"); + let class = |name| AnyClass::get(name).expect("AppKit is linked"); + unsafe { + let workspace: Retained = msg_send_id![class("NSWorkspace"), sharedWorkspace]; + let icon: Retained = + msg_send_id![&workspace, iconForFile: &*NSString::from_str(path.to_str()?)]; + let bitmap: Allocated = msg_send_id![class("NSBitmapImageRep"), alloc]; + let bitmap: Option> = msg_send_id![ + bitmap, + initWithBitmapDataPlanes: std::ptr::null_mut::<*mut u8>(), + pixelsWide: 32isize, + pixelsHigh: 32isize, + bitsPerSample: 8isize, + samplesPerPixel: 4isize, + hasAlpha: true, + isPlanar: false, + colorSpaceName: &*NSString::from_str("NSDeviceRGBColorSpace"), + bytesPerRow: 0isize, + bitsPerPixel: 0isize + ]; + let bitmap = bitmap?; + let graphics = class("NSGraphicsContext"); + let context: Option> = + msg_send_id![graphics, graphicsContextWithBitmapImageRep: &*bitmap]; + let _: () = msg_send![graphics, saveGraphicsState]; + let _: () = msg_send![graphics, setCurrentContext: context.as_deref()]; + let rect = NSRect::new( + NSPoint::new(0.0, 0.0), + objc2_foundation::NSSize::new(32.0, 32.0), + ); + // NSCompositingOperationSourceOver. + let _: () = msg_send![&icon, drawInRect: rect, fromRect: NSRect::ZERO, operation: 2usize, fraction: 1.0f64]; + let _: () = msg_send![graphics, restoreGraphicsState]; + let properties: Retained = msg_send_id![class("NSDictionary"), dictionary]; + // NSBitmapImageFileTypePNG. + let data: Option> = + msg_send_id![&bitmap, representationUsingType: 4usize, properties: &*properties]; + let data = data?; + let length: usize = msg_send![&data, length]; + let bytes: *const std::ffi::c_void = msg_send![&data, bytes]; + Some(std::slice::from_raw_parts(bytes.cast::(), length).to_vec()) + } +} + +/// Where the pointer is in the window, in points from the content's top left corner, for a +/// drop, which AppKit reports without a position. +pub fn drop_point(window: &Window) -> Option<[f32; 2]> { + let window = ns_window(window); + unsafe { + let point: NSPoint = msg_send![&window, mouseLocationOutsideOfEventStream]; + let content: Retained = msg_send_id![&window, contentView]; + let frame: NSRect = msg_send![&content, frame]; + Some([point.x as f32, (frame.size.height - point.y) as f32]) + } +} + +/// Opens a copy of an attachment with its application, quarantined as a download is, so +/// Gatekeeper checks it where OneNote 2010 warns before opening one. +pub fn open_file(path: &std::path::Path) { + use std::os::unix::ffi::OsStrExt; + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + let value = format!("0081;{seconds:08x};Snowbound;"); + if let Ok(file) = std::ffi::CString::new(path.as_os_str().as_bytes()) { + unsafe { + libc::setxattr( + file.as_ptr(), + c"com.apple.quarantine".as_ptr(), + value.as_ptr().cast(), + value.len(), + 0, + 0, + ); + } + } + reveal(path); +} + /// Lets the user drag out part of the screen, then sends it as a PNG. pub fn clip_screen(proxy: EventLoopProxy) { std::thread::spawn(move || { @@ -910,6 +997,18 @@ pub fn install_menu() { app.setWindowsMenu(Some(&menus.window)); app.setHelpMenu(Some(&menus.help)); } + TAGS_MENU.set(Some(menus.tags)); +} + +/// Lists `tags` in the menu bar's Tags menu. +pub fn update_tag_menu(tags: &[canvas::editor::NoteTag]) { + let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); + let app = NSApplication::sharedApplication(mtm); + TAGS_MENU.with_borrow(|menu| { + if let Some(menu) = menu { + crate::menubar::tags(mtm, &app, menu, tags); + } + }); } /// The table's statuses, in `commands::choices` order, which the menu bar's items show. diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 025a25adf18cc9376699b36a6bf180593ad167c6..698324550e8f9a554c2220a561f170fe54e68797 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,6 +1,7 @@ #[cfg(target_os = "macos")] mod aqua; mod art; +mod attachment; mod background; mod commands; #[cfg(test)] @@ -28,6 +29,7 @@ mod sidebar; #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] mod surface; mod sync; +mod tags; mod templates; use canvas::gpu::page::PageScene; @@ -524,6 +526,10 @@ struct State { options: Option, /// The Link dialog's fields while it is open. link: Option, + /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. + tags: Vec, + /// The Customize Tags dialog's list while it is open. + tag_list: Option, /// The page's context menu while it is open: what it was opened on, and where. text_menu: Option<(canvas::interaction::Context, [f32; 2])>, color_scheme: settings::ColorScheme, @@ -815,6 +821,10 @@ impl State { title_focus: None, menu: None, link: None, + tags: stored + .tags + .unwrap_or_else(canvas::editor::NoteTag::defaults), + tag_list: None, text_menu: None, options: None, color_scheme: stored.color_scheme, @@ -860,6 +870,7 @@ impl State { accessibility: accessibility::Accessibility::default(), }; state.title(); + platform::update_tag_menu(&state.tags); Ok(state) } @@ -930,6 +941,7 @@ impl State { let (section, open_tab, open_page) = self.build()?; self.options_dialog(); self.link_dialog()?; + self.customize_tags(); self.palette(); self.sync_popup()?; self.text_menu()?; @@ -1468,21 +1480,16 @@ impl State { /// taking notes fold last, and those with chords or a place in the menu bar first. fn tools(&mut self, theme: &Theme) { use Entry::{Open, Rule, Run, Soon}; - use canvas::editor::{ - Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, - }; + use canvas::editor::{Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, Toggle}; use commands::{Choice, Id as Cmd}; use ui::shell::TOOL; // A focused picture has no text to show a format for. let state = self.format_state(); let statuses: Vec<_> = commands::COMMANDS .iter() - .map(|command| { - ( - command.id, - self.status(&Choice::Command(command.id), &state), - ) - }) + .map(|command| command.id) + .chain((0..self.tags.len()).map(Cmd::Tag)) + .map(|id| (id, self.status(&Choice::Command(id), &state))) .collect(); let status_of = |id| { statuses @@ -1499,7 +1506,7 @@ impl State { let label = format!("{:.0}%", self.view.zoom() * 100.0); let ui = &mut self.ui; let text = theme.text; - let tags = NoteTag::ALL.map(Cmd::Tag); + let tags: Vec<_> = self.tags.iter().enumerate().collect(); let mut choice = group( ui, "navigate", @@ -1890,11 +1897,13 @@ impl State { ) .or(choice); // The gallery's menu: every tag, then managing them. - let mut all_tags: Vec<_> = tags.into_iter().map(Run).collect(); + let mut all_tags: Vec<_> = tags + .iter() + .map(|&(place, tag)| Entry::Tag(place, tag)) + .collect(); all_tags.extend([ Rule, - // Customize Tags awaits #8. - Soon("Customize Tags…", None), + Run(Cmd::CustomizeTags), Run(Cmd::RemoveTags), Rule, Run(Cmd::FindTags), @@ -1906,12 +1915,18 @@ impl State { |ui| { divider(ui, theme); let mut choice = None; - for id in &tags[..3] { - choice = tool(ui, *id, status_of(*id)).or(choice); + for &(place, tag) in tags.iter().take(3) { + choice = tag_tool(ui, place, tag, status_of(Cmd::Tag(place))).or(choice); } dropdown(ui, "tags", Head::More, &all_tags, status_of).or(choice) }, - |ui| dropdown(ui, "menu", Head::Split(tags[0]), &all_tags, status_of), + |ui| { + let head = match tags.first() { + Some(&(place, tag)) => Head::Tag(place, tag), + None => Head::More, + }; + dropdown(ui, "menu", head, &all_tags, status_of) + }, ) .or(choice); let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation]; @@ -2342,6 +2357,7 @@ impl State { self.respond(response); } Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, + Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?, Command::Choose(choice) => self.run(choice)?, } Ok(()) @@ -3789,8 +3805,10 @@ fn group( /// A row of a toolbar menu. #[derive(Clone, Copy)] -enum Entry { +enum Entry<'a> { Run(commands::Id), + /// Applies the tag at this place in the tag list. + Tag(usize, &'a canvas::editor::NoteTag), /// Opens the toolbar's popup `name`, which applies the command, under the button the /// popup's own control would open it from, or where that is folded away, this menu's. Open(&'static str, commands::Id), @@ -3802,8 +3820,10 @@ enum Entry { /// What opens a toolbar menu: the arrow of a button running a command, a button showing /// artwork, or a bare arrow. -enum Head { +enum Head<'a> { Split(commands::Id), + /// A button applying the tag at this place in the tag list. + Tag(usize, &'a canvas::editor::NoteTag), Menu(&'static [&'static str]), More, } @@ -3831,8 +3851,17 @@ fn dropdown( tip(ui, id); ui::Anchor::Below(ui.rect(button).unwrap_or_default()) } + Head::Tag(place, tag) if status_of(commands::Id::Tag(place)).enabled => { + let on = status_of(commands::Id::Tag(place)).checked; + if ui::shell::split_button(ui, part, tag_art(tag), None, on, menu).clicked { + choice = Some(Choice::Command(commands::Id::Tag(place))); + } + tag_tip(ui, place, tag); + ui::Anchor::Below(ui.rect(button).unwrap_or_default()) + } // Where its command does not apply, the button only opens the menu. Head::Split(id) => ui::shell::menu_button(ui, part, artwork(id).unwrap_or_default(), menu), + Head::Tag(_, tag) => ui::shell::menu_button(ui, part, tag_art(tag), menu), Head::Menu(icon) => ui::shell::menu_button(ui, part, icon, menu), Head::More => { let open = ui.popup_open(menu); @@ -3847,6 +3876,7 @@ fn dropdown( .iter() .map(|entry| match entry { Entry::Run(id) => commands::shortcut(*id), + Entry::Tag(place, _) => commands::shortcut(commands::Id::Tag(*place)), _ => String::new(), }) .collect(); @@ -3861,12 +3891,22 @@ fn dropdown( Entry::Run(id) => ui::popup::Item { text: commands::command(id).title, icon: artwork(id), - colored: matches!(id, commands::Id::Tag(_)), shortcut: key, checked: status_of(id).checked, disabled: !status_of(id).enabled, ..Default::default() }, + Entry::Tag(place, tag) => ui::popup::Item { + text: &tag.label, + icon: TagIcon::of(tag.shape, false).map(tag_sources), + ink: tag.color.map(colorref), + highlight: tag.highlight.map(colorref), + colored: true, + shortcut: key, + checked: status_of(commands::Id::Tag(place)).checked, + disabled: !status_of(commands::Id::Tag(place)).enabled, + ..Default::default() + }, Entry::Open(_, id) => ui::popup::Item { text: commands::command(id).title, icon: artwork(id), @@ -3889,6 +3929,7 @@ fn dropdown( } match ui::popup::menu(ui, menu, anchor, &items, None).map(|index| actions[index]) { Some(Entry::Run(id)) => Some(Choice::Command(id)), + Some(Entry::Tag(place, _)) => Some(Choice::Command(commands::Id::Tag(place))), Some(Entry::Open(name, _)) => { ui.open_popup(toolbar_popup(name)); None @@ -3904,12 +3945,7 @@ fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option Option Option { + let part = ("tag", place); + // Artwork in its own colours takes white. + let tint = [1.0; 4]; + if !status.enabled { + ui::shell::unavailable(ui, part, tag_art(tag), tint, false); + return None; + } + let clicked = ui::shell::tool_button(ui, part, tag_art(tag), tint, status.checked).clicked; + tag_tip(ui, place, tag); + clicked.then_some(commands::Choice::Command(commands::Id::Tag(place))) +} + /// The artwork the toolbar and its menus show for command `id`. fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { use canvas::editor::{Alignment, Toggle}; @@ -3948,7 +4003,6 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { Cmd::Align(Alignment::Right) => art::ALIGN_RIGHT, Cmd::Outdent => art::OUTDENT, Cmd::Indent => art::INDENT, - Cmd::Tag(tag) => tag_icon(tag), Cmd::FindTags => art::FIND_TAGS, Cmd::Table => art::TABLE, Cmd::Picture => art::PICTURE, @@ -3970,14 +4024,36 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { }) } -/// The artwork the toolbar shows for `tag`. -fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] { - use canvas::editor::NoteTag; - match tag { - NoteTag::RememberForLater => art::TAG_REMEMBER, - NoteTag::Definition => art::TAG_DEFINITION, - tag => tag_sources(TagIcon::of(tag.shape(), false).unwrap_or(TagIcon::Other)), +/// The artwork the toolbar shows for `tag`: its symbol, or for a tag that only colours +/// text, a chip of lines in its font colour on its highlight. +fn tag_art(tag: &canvas::editor::NoteTag) -> &'static [&'static str] { + use std::collections::BTreeMap; + use std::sync::Mutex; + // Artwork is borrowed for the program's life, so each colour pair is made once. + type Chips = BTreeMap<(Option, Option), &'static [&'static str]>; + static CHIPS: Mutex = Mutex::new(BTreeMap::new()); + if let Some(icon) = TagIcon::of(tag.shape, false) { + return tag_sources(icon); } + let hex = |colorref: u32| { + let [red, green, blue, _] = colorref.to_le_bytes(); + format!("#{red:02x}{green:02x}{blue:02x}") + }; + let mut chips = CHIPS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + chips.entry((tag.color, tag.highlight)).or_insert_with(|| { + let ink = tag.color.map_or_else(|| "#000000".into(), hex); + let fill = tag.highlight.map_or_else(|| "#ffffff".into(), hex); + let svg = format!( + r##" + + + +"## + ); + Box::leak(Box::new([&*svg.leak()])) + }) } /// The toolbar's popup `name`, which the menu bar opens too. @@ -3985,6 +4061,12 @@ fn toolbar_popup(name: &str) -> Id { Id::ROOT.child(("popup", name)) } +/// A tooltip naming the tag at `place` and its chord on the box built last. +fn tag_tip(ui: &mut Ui, place: usize, tag: &canvas::editor::NoteTag) { + let key = commands::shortcut(commands::Id::Tag(place)); + ui::popup::tooltip(ui, &tag.label, &key, None); +} + /// A tooltip naming command `id` and its chord on the box built last. It names the command, /// not a dialog it opens, so it leaves off the menu's ellipsis. fn tip(ui: &mut Ui, id: commands::Id) { @@ -4344,6 +4426,11 @@ impl ApplicationHandler for App { }) } WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), + WindowEvent::DroppedFile(path) => { + let at = platform::drop_point(&state.window); + state.attach(&path, at)?; + state.window.request_redraw(); + } WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?, // Smart zoom: to 200% about the fingers, or back to 100%. WindowEvent::DoubleTapGesture { .. } => { diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index d3c2385afee9d422163d91c014fff8bb38cf1765..48fcc4edfc83221b32b995d19c257721598b5dc7 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -7,7 +7,7 @@ use notebook::session::Notebook; use onestore::{ ExGuid, PageCreation, PageEdit, op::{Edit, Op, PageOp, SectionOp}, - page::{Image, Page, PageObject}, + page::{Page, PageObject}, }; use std::{error::Error, sync::Arc}; @@ -134,26 +134,19 @@ impl State { self.edit_page(ops) } - /// Gives the open page `template`'s art, or none, in place of the art it had; its colour - /// and rule lines stay. - pub(crate) fn art_page( - &mut self, - template: Option<&'static Template>, - ) -> Result<(), Box> { - self.persist()?; - let session = self.session.as_ref().ok_or("No section is open")?; - let ops = art_ops(&session.section.page(session.space)?, template)?; - self.edit_page(ops) - } - - /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, and - /// `rule_lines` as one undo step; its art stays. + /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, + /// `rule_lines` and, when given, `template`'s art or none in place of the art it had, as + /// one undo step. pub(crate) fn paper_page( &mut self, color: Option, rule_lines: Option, + art: Option>, ) -> Result<(), Box> { - let response = self.view.set_paper(color, rule_lines)?; + let art = art + .map(|template| template.map_or(Ok(Vec::new()), Template::pictures)) + .transpose()?; + let response = self.view.set_paper(color, rule_lines, art)?; self.respond(response); Ok(()) } @@ -177,7 +170,7 @@ impl State { /// Asks where to create a notebook and creates it, as File, New does in OneNote. pub(crate) fn new_notebook(&mut self) -> Result<(), Box> { - let Some(root) = platform::pick_new("New Notebook", "My Notebook") else { + let Some(root) = platform::pick_new("New Notebook", "My Notebook", "Create") else { return Ok(()); }; let page = self.dated_page(None)?; @@ -503,25 +496,9 @@ pub fn art_ops(page: &Page, template: Option<&Template>) -> Result, _ => true, }) .map(PageObject::id); - for art in template.map_or(&[][..], |template| template.art) { - let bytes = - canvas::gpu::page::template_picture(art.art).ok_or("That template's art is missing")?; + for image in template.map_or(Ok(Vec::new()), Template::pictures)? { ops.push(PageOp::Add { - object: PageObject::Image(Image { - id: onestore::page::text::new_id()?, - layout: onestore::document::Layout { - x: Some(art.position[0]), - y: Some(art.position[1]), - max_width: art.size.map(|size| size[0]), - max_height: art.size.map(|size| size[1]), - width_set_by_user: art.size.map(|_| true), - ..Default::default() - }, - size: art.size, - bytes: Some(bytes.into()), - alt: None, - background: true, - }), + object: PageObject::Image(image), before: under, }); } @@ -873,9 +850,10 @@ mod tests { } /// Backgrounds the Page Color menu gives OneNote's ruled pages - /// (`corpus/rule-lines/native`): art on a page with rule lines, keeping them, art taken - /// away, and a colour outside OneNote's menu. `SNOWBOUND_BACKGROUND_EXPORT` names a new - /// directory that receives the notebook for a cold reopen in OneNote 2010. + /// (`corpus/rule-lines/native`) through the editor, each step one edit: art on a page + /// with rule lines, keeping them; art taken away, undone, redone and replaced; a colour + /// outside OneNote's menu; and art on a coloured page. `SNOWBOUND_BACKGROUND_EXPORT` + /// names a new directory that receives the notebook for a cold reopen in OneNote 2010. #[test] fn backgrounds_go_on_and_off_pages_that_have_content() { let temporary = @@ -896,38 +874,73 @@ mod tests { .unwrap() .0 }; - let on_page = |space, ops: Vec| { - edit( - &file, - ops.into_iter().map(|op| Op::Page { space, op }).collect(), - ) + enum Step { + Art(Option<&'static str>), + Color(u32), + Undo, + Redo, + } + use Step::*; + let mut engine = canvas::layout::TextEngine::default(); + let mut run = |title: &str, steps: &[Step]| { + let space = titled(title); + let mut editor = + canvas::editor::CanvasEditor::from_page(page(&file, space), &mut engine).unwrap(); + for step in steps { + let (color, lines) = (editor.page_color(), editor.rule_lines()); + let changed = match step { + Art(name) => { + let art = name.map_or(Ok(Vec::new()), |name| { + canvas::template::find(name).unwrap().pictures() + }); + editor.set_paper(color, lines, Some(art.unwrap())) + } + Color(color) => editor.set_paper(Some(*color), lines, None), + Undo => editor.undo(&mut engine).unwrap(), + Redo => editor.redo(&mut engine).unwrap(), + }; + assert!(changed); + let ops = editor.take_ops().unwrap(); + assert!(!ops.is_empty()); + edit( + &file, + ops.into_iter().map(|op| Op::Page { space, op }).collect(), + ); + } + page(&file, space) }; - let ivy = canvas::template::find("Ivy"); - let (standard, wide, grid) = (titled("Standard"), titled("Wide"), titled("SmallGrid")); - on_page(standard, art_ops(&page(&file, standard), ivy).unwrap()); - on_page(wide, art_ops(&page(&file, wide), ivy).unwrap()); - on_page(wide, art_ops(&page(&file, wide), None).unwrap()); - // A warm grey no menu offers. - on_page(grid, vec![PageOp::Color(Some(0x00c8d8e8))]); - - let standard = page(&file, standard); - assert!(standard.rule_lines.is_some()); - assert_eq!( - standard - .objects + let lemon = canvas::template::PAGE_COLORS[2]; + assert_eq!(lemon.0, "Lemon"); + let pages = [ + run("Standard", &[Art(Some("Ivy"))]), + run("Wide", &[Art(Some("Ivy")), Art(None)]), + // A warm grey no menu offers. + run("SmallGrid", &[Color(0x00c8d8e8)]), + run("College", &[Art(Some("Ivy")), Undo]), + run("MediumGrid", &[Art(Some("Ivy")), Undo, Redo]), + run("LargeGrid", &[Art(Some("Ivy")), Art(Some("Tulips")), Undo]), + run("VeryLargeGrid", &[Color(lemon.1), Art(Some("Sparks"))]), + ]; + let art = |page: &Page| -> Vec { + page.objects .iter() - .filter(|object| matches!(object, PageObject::Image(image) if image.background)) - .count(), - ivy.unwrap().art.len() - ); - let wide = page(&file, wide); - assert!( - !wide - .objects - .iter() - .any(|object| matches!(object, PageObject::Image(_))) - ); - assert_eq!(page(&file, grid).color, Some(0x00c8d8e8)); + .filter_map(|object| match object { + PageObject::Image(image) if image.background => image.layout.max_width, + _ => None, + }) + .collect() + }; + let [standard, wide, grid, college, medium, large, very_large] = &pages; + let [ivy, sparks] = ["Ivy", "Sparks"] + .map(|name| canvas::template::find(name).unwrap().art[0].size.unwrap()[0]); + assert_eq!(art(standard), [ivy]); + assert!(pages.iter().all(|page| page.rule_lines.is_some())); + assert!(art(wide).is_empty() && art(college).is_empty()); + assert_eq!(grid.color, Some(0x00c8d8e8)); + assert_eq!(art(medium), [ivy]); + assert_eq!(art(large), [ivy]); + assert_eq!(art(very_large), [sparks]); + assert_eq!(very_large.color, Some(lemon.1)); if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") { copy(&temporary, Path::new(&directory)); } diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index 3ed76986a00f27b1b691df3aa38b3367b2b4a347..6337a925113e86553b817a4b022bb1c906af84b9 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -156,48 +156,8 @@ const MENUS: &[Item] = &[ C(Id::ClearFormatting), ], ), - Item::Menu( - "Tags", - &[ - C(Id::Tag(NoteTag::ToDo)), - C(Id::Tag(NoteTag::Important)), - C(Id::Tag(NoteTag::Question)), - C(Id::Tag(NoteTag::RememberForLater)), - C(Id::Tag(NoteTag::Definition)), - C(Id::Tag(NoteTag::Highlight)), - C(Id::Tag(NoteTag::Contact)), - C(Id::Tag(NoteTag::Address)), - C(Id::Tag(NoteTag::PhoneNumber)), - Item::Menu( - "More Tags", - &[ - C(Id::Tag(NoteTag::WebSiteToVisit)), - C(Id::Tag(NoteTag::Idea)), - C(Id::Tag(NoteTag::Password)), - C(Id::Tag(NoteTag::Critical)), - C(Id::Tag(NoteTag::ProjectA)), - C(Id::Tag(NoteTag::ProjectB)), - C(Id::Tag(NoteTag::MovieToSee)), - C(Id::Tag(NoteTag::BookToRead)), - C(Id::Tag(NoteTag::MusicToListenTo)), - C(Id::Tag(NoteTag::SourceForArticle)), - C(Id::Tag(NoteTag::RememberForBlog)), - C(Id::Tag(NoteTag::DiscussWithPersonA)), - C(Id::Tag(NoteTag::DiscussWithPersonB)), - C(Id::Tag(NoteTag::DiscussWithManager)), - C(Id::Tag(NoteTag::SendInEmail)), - C(Id::Tag(NoteTag::ScheduleMeeting)), - C(Id::Tag(NoteTag::CallBack)), - C(Id::Tag(NoteTag::ToDoPriority1)), - C(Id::Tag(NoteTag::ToDoPriority2)), - C(Id::Tag(NoteTag::ClientRequest)), - ], - ), - S, - C(Id::RemoveTags), - C(Id::FindTags), - ], - ), + // Filled from the tag list by `tags`. + Item::Menu("Tags", &[]), Item::Menu( "Window", &[ @@ -216,6 +176,7 @@ pub struct Menus { pub services: Retained, pub window: Retained, pub help: Retained, + pub tags: Retained, } /// Builds the bar; the table's items send `choose:` to `target`, which validates them. @@ -241,6 +202,7 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus { .expect("The bar has the menus AppKit fills") }; Menus { + tags: named("Tags"), window: named("Window"), help: named("Help"), services, @@ -302,6 +264,38 @@ fn menu( menu } +/// Fills the Tags menu from the user's tag list, as OneNote's gallery lists it: the first +/// nine with their chords, the rest under More Tags, then managing them. +pub fn tags(mtm: MainThreadMarker, target: &AnyObject, menu: &NSMenu, tags: &[NoteTag]) { + unsafe { menu.removeAllItems() }; + let choose = |menu: &NSMenu, id: Id, title: &str, chord: Option| { + let item = entry(mtm, title, Some(objc2::sel!(choose:)), chord); + unsafe { + item.setTarget(Some(target)); + item.setTag(commands::index(&Choice::Command(id)) as isize); + } + menu.addItem(&item); + }; + let more = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("More Tags")) }; + for (place, tag) in tags.iter().enumerate().take(commands::TAGS) { + let chord = commands::tag_chord(place); + let shown = if chord.is_some() { menu } else { &*more }; + choose(shown, Id::Tag(place), &tag.label, chord); + } + if tags.len() > 9 { + let holder = entry(mtm, "More Tags", None, None); + holder.setSubmenu(Some(&more)); + menu.addItem(&holder); + } + if !tags.is_empty() { + menu.addItem(&NSMenuItem::separatorItem(mtm)); + } + for id in [Id::CustomizeTags, Id::RemoveTags, Id::FindTags] { + let command = commands::command(id); + choose(menu, id, command.title, command.mac.first().copied()); + } +} + fn entry( mtm: MainThreadMarker, title: &str, @@ -418,6 +412,7 @@ mod tests { let target: Retained = unsafe { objc2::msg_send_id![objc2::class!(NSObject), new] }; let menus = build(mtm, &target); + tags(mtm, &target, &menus.tags, &NoteTag::defaults()); let (mut tree, mut chords) = (String::new(), Vec::new()); dump(&menus.bar, 0, &mut tree, &mut chords); println!("{tree}"); diff --git a/crates/snowbound/src/palette.rs b/crates/snowbound/src/palette.rs index ea81a5d6ba02c878bed1b4b0cfd68f98edcaaeed..00d7e36e5b1512af1943fff7f51c088b07a53c44 100644 --- a/crates/snowbound/src/palette.rs +++ b/crates/snowbound/src/palette.rs @@ -44,6 +44,15 @@ impl State { target: Some(Target::Command(command.id)), }) .collect(); + rows.extend(self.tags.iter().enumerate().map(|(place, tag)| { + let id = commands::Id::Tag(place); + Row { + text: tag.label.clone(), + after: commands::shortcut(id), + disabled: !self.status(&Choice::Command(id), &format).enabled, + target: Some(Target::Command(id)), + } + })); let heading = |text: &str| Row { text: text.to_owned(), after: String::new(), diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index dd3826e3c65e5c84cc30db4fe60a3ef76bd85f65..5eb4c5c55ddebb656d5df14353429404d8a6843a 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -24,6 +24,8 @@ pub struct Settings { pub light_pages: bool, /// Where searches look first, as "Set This Scope as Default" chose. pub search_scope: crate::search::Scope, + /// The tag list Customize Tags edits; none keeps OneNote's. + pub tags: Option>, } /// What the toolbar's buttons apply from their menus' last picks. @@ -128,6 +130,7 @@ impl crate::State { color_scheme: self.color_scheme, light_pages: self.light_pages, search_scope: self.search.default, + tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), }; if let Err(error) = settings.save(path) { eprintln!("Cannot save the settings in {}: {error}", path.display()); @@ -177,6 +180,12 @@ mod tests { color_scheme: ColorScheme::Dark, light_pages: true, search_scope: crate::search::Scope::Notebook, + tags: Some(vec![canvas::editor::NoteTag { + label: "Snow check".into(), + shape: 61, + color: Some(0x0000_0080), + highlight: Some(0x00ff_cc00), + }]), }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 9c2a8d132fde1ac1f5f4e20c5632d95d198d1709..dfa5e7ef97c6a36eb4c2a2a6f8607a613f29ef9e 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -2,8 +2,8 @@ //! Synchronization for the open section's notebook and each of its sections. use crate::{Session, State, art, filetime, library, platform}; -use notebook::session::SyncStatus; -use std::{error::Error, io::ErrorKind}; +use notebook::session::{SyncState, SyncStatus}; +use std::error::Error; use ui::{ Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL, }; @@ -20,7 +20,6 @@ fn button() -> Id { /// The status's label and icon, and what the reader can do about an error. fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) { - use ErrorKind::*; if library::offline() { return ( "Working offline", @@ -28,48 +27,30 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option Some("Changes stay on this computer until you sync."), ); } - match sync.error.as_ref().map(std::io::Error::kind) { - Some(PermissionDenied | ReadOnlyFilesystem) => ( + match sync.state() { + SyncState::ReadOnly => ( "Unable to sync", art::SYNC_ERROR, Some( "You can’t change this notebook where it’s stored. Changes stay on this computer.", ), ), - Some(WouldBlock | ResourceBusy) => ( + SyncState::InUse => ( "Section in use", art::SYNC_BUSY, Some("Someone else is saving this section. Sync continues when they finish."), ), - Some( - NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected - | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe - | AddrNotAvailable, - ) => ( + SyncState::NotConnected => ( "Not connected", art::SYNC_OFFLINE, Some("Changes stay on this computer and sync when the notebook is back."), ), - Some(_) => ("Unable to sync", art::SYNC_ERROR, None), - None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", art::SYNC_BUSY, None), - None => ("Up to date", art::SYNC_DONE, None), + SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None), + SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None), + SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None), } } -/// Labels from the best state to the worst, which a notebook's status shows. -const ORDER: [&str; 5] = [ - "Up to date", - "Syncing…", - "Section in use", - "Not connected", - "Unable to sync", -]; - -fn rank(sync: &SyncStatus) -> usize { - let label = describe(sync).0; - ORDER.iter().position(|shown| *shown == label).unwrap_or(0) -} - /// Every section of the open section's notebook with its status, in catalog order: the /// open one's from its session, the others' from the notebook's background sync. fn sections(session: &Session) -> Vec<(String, SyncStatus)> { @@ -102,7 +83,7 @@ fn overall(sections: &[(String, SyncStatus)]) -> SyncStatus { .iter() .map(|(_, sync)| sync) .filter(|sync| sync.error.is_some()) - .max_by_key(|sync| rank(sync)); + .max_by_key(|sync| sync.state()); SyncStatus { synced: sections .iter() diff --git a/crates/snowbound/src/tags.rs b/crates/snowbound/src/tags.rs new file mode 100644 index 0000000000000000000000000000000000000000..48caad352c92fb43c36cb95572541b638a35d51a --- /dev/null +++ b/crates/snowbound/src/tags.rs @@ -0,0 +1,810 @@ +//! OneNote 2010's Customize Tags dialog and its New Tag and Modify Tag dialog, which edit +//! the user's tag list: the tags the toolbar, the menus and Ctrl+1 to Ctrl+9 apply. + +use crate::{State, commands, platform}; +use canvas::editor::NoteTag; +use canvas::gpu::{colorref, tag_sources}; +use canvas::outline::{TagIcon, symbol_name}; +use draw::edit::Platform; +use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px}; +use winit::keyboard::NamedKey; + +const WIDTH: f32 = 300.0; +const EDITOR_WIDTH: f32 = 280.0; +const ROW: f32 = 20.0; +/// Rows the list shows at once; the wheel scrolls the rest. +const ROWS: f32 = 19.0; +const SIDE: f32 = 16.0; +const CELL: f32 = 23.0; + +/// OneNote 2010's symbol gallery: ten rows of five groups of three, 0 for None, which spans +/// the first group's top row, and for the empty cells at the end. +const GALLERY: [[u16; 15]; 10] = [ + [ + 0, 0, 0, 40, 13, 61, 41, 82, 62, 106, 107, 108, 124, 125, 126, + ], + [3, 2, 1, 43, 84, 64, 34, 75, 54, 109, 18, 110, 127, 128, 27], + [6, 5, 4, 36, 77, 56, 46, 87, 67, 14, 15, 17, 129, 130, 131], + [9, 8, 7, 42, 83, 63, 47, 88, 68, 21, 24, 111, 132, 133, 134], + [ + 12, 11, 10, 39, 81, 60, 38, 79, 58, 112, 22, 23, 135, 136, 137, + ], + [ + 28, 69, 48, 29, 70, 49, 16, 80, 59, 113, 25, 26, 138, 139, 140, + ], + [ + 30, 71, 50, 31, 72, 51, 37, 78, 57, 114, 115, 116, 141, 142, 143, + ], + [32, 73, 52, 33, 74, 53, 45, 86, 66, 117, 118, 119, 0, 0, 0], + [94, 95, 96, 35, 76, 55, 100, 101, 102, 120, 19, 20, 0, 0, 0], + [ + 97, 98, 99, 44, 85, 65, 103, 104, 105, 121, 122, 123, 0, 0, 0, + ], +]; + +/// Font Color's swatches, Office's forty in their grid, as COLORREFs. +const FONT_COLORS: [u32; 40] = [ + 0x000000, 0x003399, 0x003333, 0x003300, 0x663300, 0x800000, 0x993333, 0x333333, // + 0x000080, 0x0066ff, 0x008080, 0x008000, 0x808000, 0xff0000, 0x996666, 0x808080, // + 0x0000ff, 0x0099ff, 0x00cc99, 0x669933, 0xcccc33, 0xff6633, 0x800080, 0x969696, // + 0xff00ff, 0x00ccff, 0x00ffff, 0x00ff00, 0xffff00, 0xffcc00, 0x663399, 0xc0c0c0, // + 0xcc99ff, 0x99ccff, 0x99ffff, 0xccffcc, 0xffffcc, 0xffcc99, 0xff99cc, 0xffffff, +]; + +/// Highlight Color's swatches as COLORREFs, which differ from the highlighter's in Sky Blue +/// and Dark Green. +const HIGHLIGHTS: [u32; 15] = [ + 0x00ffff, 0x00ff00, 0xffcc00, 0xff00ff, 0xff0000, // + 0x0000ff, 0x800000, 0x808000, 0x003300, 0x800080, // + 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000, +]; + +/// The Customize Tags dialog's list while it is open. +pub struct TagList { + tags: Vec, + selected: Option, + /// The New Tag or Modify Tag dialog's fields, and the place Modify Tag replaces. + editor: Option<(NoteTag, Option)>, +} + +fn id() -> Id { + Id::ROOT.child("customize-tags") +} + +fn editor_id() -> Id { + id().child("tag") +} + +fn name_field() -> Id { + editor_id().child("name") +} + +fn popup(name: &str) -> Id { + editor_id().child(("popup", name)) +} + +/// A tag's artwork before its name: its symbol, or none. +fn artwork(tag: &NoteTag) -> Option<&'static [&'static str]> { + TagIcon::of(tag.shape, false).map(tag_sources) +} + +/// The tag's name in its colours, as the list and Preview show it. +fn label(ui: &mut Ui, part: &str, text: &str, tag: &NoteTag) { + ui.leaf( + part, + Spec { + size: [fit(), px(ROW - 2.0)], + text: Some(text), + color: Some(tag.color.map_or(ui.theme.text, colorref)), + fill: tag.highlight.map(colorref), + pad: [2.0, 0.0], + ..Spec::default() + }, + ); +} + +fn icon(ui: &mut Ui, part: &str, art: Option<&'static [&'static str]>) { + ui.leaf( + part, + Spec { + size: [px(SIDE + 4.0), px(ROW)], + icon: art, + color: Some([1.0; 4]), + center: true, + ..Spec::default() + }, + ); +} + +/// A text button that takes no clicks while not `enabled`. +fn button(ui: &mut Ui, part: &str, text: &str, enabled: bool) -> bool { + if enabled { + return ui::button(ui, part, text).clicked; + } + let theme = ui.theme.clone(); + ui.leaf( + part, + Spec { + size: [fit(), px(theme.font_size * 2.0)], + text: Some(text), + color: Some(theme.text_dim), + fill: Some(theme.chip), + radius: 4.0, + pad: [theme.font_size * 0.75, 0.0], + center: true, + ..Spec::default() + }, + ); + false +} + +/// A square button showing `art` that takes no clicks while not `enabled`, named by a +/// tooltip. +fn tool(ui: &mut Ui, part: &str, art: &'static [&'static str], tip: &str, enabled: bool) -> bool { + let text = ui.theme.text; + if !enabled { + ui::shell::unavailable(ui, part, art, text, false); + return false; + } + let clicked = ui::shell::tool_button(ui, part, art, text, false).clicked; + ui::popup::tooltip(ui, tip, "", None); + clicked +} + +/// A button showing `art` in its own colours, with `bar` under it, and a menu arrow; opens +/// popup `menu` and returns where it opens. +fn picker( + ui: &mut Ui, + part: &str, + art: Option<&'static [&'static str]>, + bar: Option<[f32; 4]>, + menu: Id, + tint: [f32; 4], +) -> Anchor { + let theme = ui.theme.clone(); + let open = ui.popup_open(menu); + let button = ui.open( + part, + Spec { + flags: Flags::CLICKABLE, + size: [children(), px(ui::shell::TOOL)], + fill: open.then(|| theme.hover()), + hover_fill: Some(theme.hover()), + border: Some(theme.chip), + radius: 4.0, + ..Spec::default() + }, + ); + ui.open( + "icon", + Spec { + size: [px(ui::shell::TOOL), px(ui::shell::TOOL)], + icon: art, + color: Some(tint), + center: true, + ..Spec::default() + }, + ); + if let Some(color) = bar { + let top = (ui::shell::TOOL - 16.0) / 2.0; + ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0); + } + ui.close(); + ui.leaf( + "arrow", + Spec { + size: [px(12.0), px(ui::shell::TOOL)], + icon: Some(ui::shell::CHEVRON), + color: Some(theme.text_dim), + center: true, + ..Spec::default() + }, + ); + ui.close(); + if ui.signal(button).pressed { + ui.open_popup(menu); + } + Anchor::Below(ui.rect(button).unwrap_or_default()) +} + +/// The trailing row of a dialog: its buttons at the right. +fn buttons(ui: &mut Ui) { + ui.open( + "buttons", + Spec { + size: [fill(), children()], + pad: [0.0, 8.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); +} + +/// The frame of a dialog titled `title`, centred in the window. +fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) { + let theme = ui.theme.clone(); + ui.open_as( + id, + Spec { + axis: Axis::Y, + size: [px(width), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [14.0, 10.0], + gap: 6.0, + anchor: Some(Anchor::Dialog), + ..Spec::default() + }, + ); + ui.leaf( + "title", + Spec { + size: [fill(), px(ROW + 4.0)], + text: Some(title), + bold: true, + ..Spec::default() + }, + ); +} + +impl State { + /// Customize Tags opens on the tag list with its first tag selected, as OneNote's does. + pub(crate) fn open_customize_tags(&mut self) { + self.tag_list = Some(TagList { + tags: self.tags.clone(), + selected: (!self.tags.is_empty()).then_some(0), + editor: None, + }); + self.ui.open_popup(id()); + } + + /// Builds the Customize Tags dialog while it is open. OK keeps a changed list, which the + /// toolbar, the menus and the chords then apply; Cancel, Escape or a press outside leave + /// it. + pub(crate) fn customize_tags(&mut self) { + let Some(list) = &mut self.tag_list else { + return; + }; + let ui = &mut self.ui; + if !ui.popup_open(id()) { + self.tag_list = None; + return; + } + let theme = ui.theme.clone(); + dialog(ui, id(), "Customize Tags", WIDTH); + ui.leaf( + "all", + Spec { + size: [fill(), px(ROW)], + text: Some("All Tags:"), + ..Spec::default() + }, + ); + ui.open( + "body", + Spec { + size: [fill(), children()], + gap: 6.0, + ..Spec::default() + }, + ); + ui.open( + "list", + Spec { + flags: Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, + size: [fill(), px(ROW * ROWS + 4.0)], + fill: Some(theme.base), + border: Some(theme.chip), + radius: 4.0, + pad: [2.0, 2.0], + ..Spec::default() + }, + ); + let mut modify = None; + for (place, tag) in list.tags.iter().enumerate() { + let selected = list.selected == Some(place); + let row = ui.open( + ("tag", place), + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(ROW)], + fill: selected.then_some(theme.selection), + hover_fill: Some(theme.hover()), + radius: 3.0, + ..Spec::default() + }, + ); + icon(ui, "icon", artwork(tag)); + let text = match commands::tag_chord(place) { + Some(chord) => format!("{} ({})", tag.label, chord.label(Platform::CURRENT)), + None => tag.label.clone(), + }; + label(ui, "label", &text, tag); + ui.close(); + let signal = ui.signal(row); + if signal.pressed { + list.selected = Some(place); + } + if signal.pressed && signal.unit == draw::edit::SelectionUnit::Word { + modify = Some(place); + } + } + ui.close(); + ui.open( + "order", + Spec { + axis: Axis::Y, + size: [children(), children()], + gap: 4.0, + ..Spec::default() + }, + ); + let last = list.tags.len().saturating_sub(1); + let up = tool( + ui, + "up", + crate::art::CHEVRON_UP, + "Move Tag Up", + list.selected.is_some_and(|place| place > 0), + ); + let down = tool( + ui, + "down", + ui::shell::CHEVRON, + "Move Tag Down", + list.selected.is_some_and(|place| place < last), + ); + let remove = tool( + ui, + "remove", + crate::art::CLOSE, + "Remove", + list.selected.is_some(), + ); + ui.close(); + ui.close(); + ui.open( + "edit", + Spec { + size: [fill(), children()], + gap: 8.0, + ..Spec::default() + }, + ); + let new = button( + ui, + "new", + "New Tag…", + list.tags.len() < commands::TAGS && list.editor.is_none(), + ); + modify = modify.or_else(|| { + button(ui, "modify", "Modify Tag…", list.selected.is_some()) + .then_some(list.selected) + .flatten() + }); + ui.close(); + buttons(ui); + let ok = button(ui, "ok", "OK", list.tags != self.tags); + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + ui.close(); + if let Some(place) = list.selected { + if up { + list.tags.swap(place, place - 1); + list.selected = Some(place - 1); + } else if down { + list.tags.swap(place, place + 1); + list.selected = Some(place + 1); + } else if remove { + list.tags.remove(place); + list.selected = (!list.tags.is_empty()).then(|| place.min(list.tags.len() - 1)); + } + } + if new { + // A new tag has no symbol, and takes the top of the list. + let tag = NoteTag { + label: "Undefined".into(), + shape: 0, + color: None, + highlight: None, + }; + list.editor = Some((tag, None)); + } else if let Some(place) = modify { + list.editor = Some((list.tags[place].clone(), Some(place))); + } + if (new || modify.is_some()) && !ui.popup_open(editor_id()) { + ui.open_popup(editor_id()); + ui.set_focus(Some(name_field())); + } + if let Some((tag, place)) = tag_editor(ui, &mut list.editor) { + match place { + Some(place) => list.tags[place] = tag, + None => { + list.tags.insert(0, tag); + list.selected = Some(0); + } + } + } + ui.close(); + if ok { + self.tags = list.tags.clone(); + self.save_settings(); + platform::update_tag_menu(&self.tags); + } else if !cancel { + return; + } + self.ui.close_popup(id()); + self.ui.set_focus(Some(crate::page())); + self.tag_list = None; + } +} + +/// Builds the New Tag or Modify Tag dialog while it is open over Customize Tags. Returns the +/// tag and the place it replaces once OK keeps it. +fn tag_editor( + ui: &mut Ui, + editor: &mut Option<(NoteTag, Option)>, +) -> Option<(NoteTag, Option)> { + if !ui.popup_open(editor_id()) { + *editor = None; + return None; + } + let (tag, place) = editor.as_mut()?; + let theme = ui.theme.clone(); + let entered = + ui::popup::navigation(ui, &[name_field()], &[NamedKey::Enter]).contains(&NamedKey::Enter); + let title = if place.is_some() { + "Modify Tag" + } else { + "New Tag" + }; + dialog(ui, editor_id(), title, EDITOR_WIDTH); + let caption = |ui: &mut Ui, part: &str, text: &str| { + ui.leaf( + part, + Spec { + size: [fill(), px(ROW)], + text: Some(text), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + }; + caption(ui, "format", "Format"); + ui.leaf( + "name-label", + Spec { + size: [fill(), px(ROW)], + text: Some("Display name:"), + ..Spec::default() + }, + ); + ui::text_field( + ui, + name_field(), + &mut tag.label, + "", + Spec { + size: [fill(), px(ROW + 4.0)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + ui.open( + "pickers", + Spec { + size: [fill(), children()], + gap: 10.0, + ..Spec::default() + }, + ); + let column = |ui: &mut Ui, part: &str, text: &str| { + ui.open( + part, + Spec { + axis: Axis::Y, + size: [children(), children()], + gap: 2.0, + ..Spec::default() + }, + ); + ui.leaf( + "label", + Spec { + size: [fit(), px(ROW)], + text: Some(text), + ..Spec::default() + }, + ); + }; + column(ui, "symbol", "Symbol:"); + let symbol = artwork(tag).unwrap_or(crate::art::FONT_COLOR); + let tint = if tag.shape == 0 { theme.text } else { [1.0; 4] }; + let anchor = picker(ui, "picker", Some(symbol), None, popup("symbol"), tint); + if let Some(shape) = gallery(ui, anchor) { + tag.shape = shape; + } + ui.close(); + column(ui, "color", "Font Color:"); + let bar = tag.color.map_or(theme.text, colorref); + let anchor = picker( + ui, + "picker", + Some(crate::art::FONT_COLOR), + Some(bar), + popup("color"), + theme.text, + ); + if let Some(color) = swatch(ui, "color", anchor, "Automatic", &FONT_COLORS, 8) { + tag.color = color; + } + ui.close(); + column(ui, "highlight", "Highlight Color:"); + let bar = tag.highlight.map(colorref); + let anchor = picker( + ui, + "picker", + Some(crate::art::HIGHLIGHTER), + bar, + popup("highlight"), + theme.text, + ); + if let Some(color) = swatch(ui, "highlight", anchor, "None", &HIGHLIGHTS, 5) { + tag.highlight = color; + } + ui.close(); + ui.close(); + caption(ui, "preview-label", "Preview"); + ui.open( + "preview", + Spec { + size: [fill(), px(ROW * 2.0)], + fill: Some(theme.base), + border: Some(theme.chip), + radius: 4.0, + pad: [8.0, ROW / 2.0], + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + icon(ui, "icon", artwork(tag)); + label(ui, "label", &tag.label, tag); + ui.leaf( + "end", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + ui.close(); + ui.leaf( + "note", + Spec { + size: [fill(), fit()], + text: Some("Customizations do not affect notes you have already tagged."), + color: Some(theme.text_dim), + overflow: ui::Overflow::Wrap, + ..Spec::default() + }, + ); + buttons(ui); + let named = !tag.label.trim().is_empty(); + let ok = button(ui, "ok", "OK", named) || entered && named; + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + ui.close(); + ui.close(); + if ok { + ui.close_popup(editor_id()); + return editor.take().map(|(mut tag, place)| { + tag.label = tag.label.trim().to_owned(); + (tag, place) + }); + } + if cancel { + ui.close_popup(editor_id()); + *editor = None; + } + None +} + +/// Symbol's gallery below `anchor` while it is open. Returns the symbol picked, 0 for None. +fn gallery(ui: &mut Ui, anchor: Anchor) -> Option { + let id = popup("symbol"); + if !ui.popup_open(id) { + return None; + } + let theme = ui.theme.clone(); + ui.open_as( + id, + Spec { + axis: Axis::Y, + size: [children(), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 6.0, + pad: [6.0, 6.0], + anchor: Some(anchor), + ..Spec::default() + }, + ); + let mut picked = None; + for (row, shapes) in GALLERY.iter().enumerate() { + ui.open( + ("row", row), + Spec { + size: [children(), px(CELL)], + ..Spec::default() + }, + ); + for (column, &shape) in shapes.iter().enumerate() { + // A rule between the groups of three. + let gap = if column % 3 == 0 && column > 0 { + 8.0 + } else { + 0.0 + }; + if gap > 0.0 { + ui.leaf( + ("gap", column), + Spec { + size: [px(gap), px(CELL)], + ..Spec::default() + }, + ); + } + if shape == 0 && row == 0 && column > 0 { + continue; + } + let none = shape == 0 && row == 0; + let spec = if none { + Spec { + flags: Flags::CLICKABLE, + size: [px(CELL * 3.0), px(CELL)], + text: Some("None"), + hover_fill: Some(theme.hover()), + radius: 3.0, + center: true, + ..Spec::default() + } + } else if shape == 0 { + Spec { + size: [px(CELL), px(CELL)], + ..Spec::default() + } + } else { + Spec { + flags: Flags::CLICKABLE, + size: [px(CELL), px(CELL)], + icon: TagIcon::of(shape, false).map(tag_sources), + color: Some([1.0; 4]), + hover_fill: Some(theme.hover()), + radius: 3.0, + center: true, + ..Spec::default() + } + }; + if ui.leaf(("cell", column), spec).clicked && (none || shape != 0) { + picked = Some(shape); + } + if let Some(name) = symbol_name(shape) { + ui::popup::tooltip(ui, name, "", None); + } + } + ui.close(); + } + ui.close(); + if picked.is_some() { + ui.close_popup(id); + } + picked +} + +/// A colour popup of `swatches` below `anchor` while it is open. Returns the COLORREF +/// picked, or `Some(None)` for the button labelled `none`. +fn swatch( + ui: &mut Ui, + name: &str, + anchor: Anchor, + none: &str, + swatches: &[u32], + columns: usize, +) -> Option> { + let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); + let chosen = ui::popup::colors(ui, popup(name), anchor, none, &colors, columns)?; + Some(chosen.and_then(|chosen| { + swatches + .iter() + .zip(&colors) + .find(|(_, color)| **color == chosen) + .map(|(stored, _)| *stored) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but + /// the follow-up flags it keeps for Outlook tasks. + /// OneNote 2010's gallery, cell by cell: its page "Cells" tags a paragraph with a tag + /// named for each cell's row and column, its symbol picked from that cell + /// (`corpus/custom-tags/native`). + #[test] + fn the_gallery_lays_symbols_out_as_onenotes_does() { + use onestore::{ + RevisionIndex, Store, + document::{Document, Kind}, + page::Page, + }; + let bytes = include_bytes!("../../../corpus/custom-tags/native/shapes.one"); + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let cells = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| Page::from_space(&document, space).unwrap()) + .find(|page| page.title.starts_with("Cells")) + .unwrap(); + let picked: std::collections::BTreeMap = cells + .definitions + .values() + .filter_map(|definition| match &definition.kind { + Kind::TagDefinition { + label: Some(label), + shape: Some(shape), + .. + } => Some((label.clone(), *shape)), + _ => None, + }) + .collect(); + let mut cells = 0; + for (row, shapes) in GALLERY.iter().enumerate() { + for (column, shape) in shapes.iter().enumerate() { + let stored = picked.get(&format!("c{row}-{column}")); + assert_eq!( + stored.copied().unwrap_or(0), + *shape, + "row {row}, column {column}" + ); + cells += usize::from(stored.is_some()); + } + } + assert_eq!(cells, 138); + } + + #[test] + fn the_gallery_offers_every_symbol_once() { + let mut offered: Vec = GALLERY + .iter() + .flatten() + .copied() + .filter(|shape| *shape != 0) + .collect(); + offered.sort_unstable(); + let expected: Vec = (1..=143) + .filter(|shape| !(89..=93).contains(shape)) + .collect(); + assert_eq!(offered, expected); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b9e03f0f6f97d06abb84eb3826ab15930067d507..c7bc234d9f4527f200ff5eeff635c3f193398335 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -606,6 +606,12 @@ enum Display { /// Top and bottom. colors: [[f32; 4]; 2], }, + Shadow { + rect: [f32; 4], + radius: f32, + blur: f32, + color: [f32; 4], + }, Icon { sources: &'static [&'static str], origin: [f32; 2], @@ -1452,12 +1458,26 @@ impl Ui { spread, } in shadows { + let origin = [painted[0] - spread, painted[1] + drop - spread]; let grown = size.map(|side| side + 2.0 * spread); - self.display.push(Display::Path { - data: outline(node.shape, grown, node.radius + spread), - origin: [painted[0] - spread, painted[1] + drop - spread], - style: PathStyle::Shadow(blur), - colors: [color; 2], + self.display.push(match node.shape { + Shape::Rounded => Display::Shadow { + rect: [ + origin[0], + origin[1], + origin[0] + grown[0], + origin[1] + grown[1], + ], + radius: node.radius + spread, + blur, + color, + }, + Shape::Tab { .. } => Display::Path { + data: outline(node.shape, grown, node.radius + spread), + origin, + style: PathStyle::Shadow(blur), + colors: [color; 2], + }, }); } } @@ -1723,6 +1743,17 @@ impl Ui { style: *style, colors: *colors, }), + &Display::Shadow { + rect, + radius, + blur, + color, + } => primitives.push(Primitive::Shadow { + rect, + radius, + blur, + color, + }), Display::Segment { from, to, color } => primitives.push(Primitive::Segment { from: *from, to: *to, diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 4ffb7534af4ee799ceaa7e5042a7bd3fd28dc3bd..10c173f9c128fedcae9fd22afc2cea9bd8cbbfad 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -27,6 +27,8 @@ const PALETTE: f32 = 560.0; const TIP_WIDTH: f32 = 280.0; /// A colour grid's cell, around its swatch. const CELL: f32 = 22.0; +/// Inset of an item's text from the highlight it previews. +const SAMPLE_PAD: f32 = 2.0; /// Seconds a popup takes to ease to the height of its results. const RESIZE: f32 = 0.15; thread_local! { @@ -50,6 +52,10 @@ pub struct Item<'a> { pub text: &'a str, /// The family the text previews, as in a font menu. pub font: Option<&'a str>, + /// The colour and highlight the text previews, as a tag without a symbol shows in + /// OneNote's Tags menu. + pub ink: Option<[f32; 4]>, + pub highlight: Option<[f32; 4]>, pub icon: Option<&'static [&'static str]>, /// The icon has colours of its own, so the text's colour does not tint it. pub colored: bool, @@ -87,7 +93,14 @@ pub fn menu( }; let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| { [ - text.max(measure(item.text)), + text.max( + measure(item.text) + + if item.highlight.is_some() { + 2.0 * SAMPLE_PAD + } else { + 0.0 + }, + ), shortcut.max(measure(item.shortcut)), ] }); @@ -414,18 +427,49 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) { }, ); } - ui.leaf( - "text", - Spec { - size: [fill(), fill()], - text: Some(item.text), - font: item.font, - font_size: Some(style.font_size - if item.heading { 2.0 } else { 0.0 }), - bold: item.heading, - color: Some(color), - ..Spec::default() - }, - ); + let text = Spec { + size: [fill(), fill()], + text: Some(item.text), + font: item.font, + font_size: Some(style.font_size - if item.heading { 2.0 } else { 0.0 }), + bold: item.heading, + color: Some(match (item.ink, item.highlight) { + (None, None) => color, + // Automatic text on a highlight is black, as on OneNote's page. + (ink, highlight) => { + let ink = ink.unwrap_or([0.0, 0.0, 0.0, 1.0]); + if item.disabled { + mix(ink, highlight.unwrap_or(style.fill), 0.5) + } else { + ink + } + } + }), + ..Spec::default() + }; + match item.highlight { + Some(highlight) => { + ui.leaf( + "text", + Spec { + size: [fit(), px(style.row - 4.0)], + fill: Some(highlight), + pad: [SAMPLE_PAD, 0.0], + ..text + }, + ); + ui.leaf( + "rest", + Spec { + size: [fill(), fill()], + ..Spec::default() + }, + ); + } + None => { + ui.leaf("text", text); + } + } if !item.shortcut.is_empty() { ui.leaf( "shortcut", diff --git a/tools/native/read.ps1 b/tools/native/read.ps1 index 476094ab1e0c47077988a6461db2080cf208642b..a42366b32e8baaf13dae92e9472cae7a071220cc 100644 --- a/tools/native/read.ps1 +++ b/tools/native/read.ps1 @@ -99,7 +99,9 @@ try { $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) - $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); kind = $file.LocalName; name = $file.GetAttribute('preferredName'); sha256 = $hash; bytes = $bytes.Length } } diff --git a/tools/test_attachment_floating.py b/tools/test_attachment_floating.py new file mode 100644 index 0000000000000000000000000000000000000000..b2bca50b08f5b44495326d0a8d00b0bf71e085cf --- /dev/null +++ b/tools/test_attachment_floating.py @@ -0,0 +1,43 @@ +from pathlib import Path +import hashlib +import json +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest +import xml.etree.ElementTree as ET + +from native_xml import ns + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/attachment-floating' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +class AttachmentFloatingTest(unittest.TestCase): + def test_files_the_editor_put_on_the_page_open_in_onenote_where_they_were_placed(self): + read = FIXTURE / 'cold/read' + with TemporaryDirectory() as temporary: + copy = Path(temporary) / 'read' + shutil.copytree(read, copy) + compare(FIXTURE / 'candidate', copy) + page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml'))) + self.assertEqual(page.findall('.//one:OE/one:InsertedFile', ns), []) + placed = {} + for inserted in page.findall('one:InsertedFile', ns): + position = inserted.find('one:Position', ns) + placed[inserted.get('preferredName')] = ( + round(float(position.get('x')), 2), round(float(position.get('y')), 2)) + # Attached at the caret a click on blank page left, the second then dragged. + self.assertEqual(placed, {'float 🦀.txt': (342.0, 284.4), 'large.bin': (288.0, 140.4)}) + # The bytes OneNote hands an application when the file is opened. + payloads = json.loads((read / 'payloads.json').read_text(encoding='utf-8-sig')) + self.assertEqual(sorted((p['name'], p['bytes']) for p in payloads), + [('float 🦀.txt', 37), ('large.bin', 2 << 20)]) + for payload in payloads: + data = (read / (payload['sha256'] + '.attachment')).read_bytes() + self.assertEqual(hashlib.sha256(data).hexdigest(), payload['sha256']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_attachment_insert.py b/tools/test_attachment_insert.py new file mode 100644 index 0000000000000000000000000000000000000000..7b37940b91a70de5108f5de7b4f8efc5571d4d4d --- /dev/null +++ b/tools/test_attachment_insert.py @@ -0,0 +1,42 @@ +from pathlib import Path +import hashlib +import json +import re +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest +import xml.etree.ElementTree as ET + +from native_xml import ns + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/attachment-insert' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +class AttachmentInsertTest(unittest.TestCase): + def test_files_attached_through_the_editor_open_in_onenote_with_their_bytes(self): + read = FIXTURE / 'cold/read' + with TemporaryDirectory() as temporary: + copy = Path(temporary) / 'read' + shutil.copytree(read, copy) + compare(FIXTURE / 'candidate', copy) + page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml'))) + paragraphs = [] + for element in page.findall('.//one:OE', ns): + inserted = element.find('one:InsertedFile', ns) + text = element.find('one:T', ns) + paragraphs.append('[%s]' % inserted.get('preferredName') if inserted is not None + else re.sub(r'<[^>]*>', '', text.text or '')) + self.assertEqual(paragraphs, ['Before the file', '[notes 🦀.txt]', 'After the file', '[large.bin]', '']) + # The bytes OneNote hands an application when the file is opened. + payloads = json.loads((read / 'payloads.json').read_text(encoding='utf-8-sig')) + self.assertEqual(sorted(p['bytes'] for p in payloads), [30, 2 << 20]) + for payload in payloads: + data = (read / (payload['sha256'] + '.attachment')).read_bytes() + self.assertEqual(hashlib.sha256(data).hexdigest(), payload['sha256']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_custom_tags.py b/tools/test_custom_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..49cda5dd226553055a6e307392513fefb1197443 --- /dev/null +++ b/tools/test_custom_tags.py @@ -0,0 +1,45 @@ +from pathlib import Path +import re +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest +import xml.etree.ElementTree as ET + +from native_xml import ns + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/custom-tags' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +class CustomTagsTest(unittest.TestCase): + def test_customized_tags_render_natively_with_their_names_symbols_and_colours(self): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'cold/read', read) + compare(FIXTURE / 'candidate', read) + page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml'))) + definitions = [(d.get('type'), d.get('symbol'), d.get('name'), d.get('fontColor'), d.get('highlightColor')) + for d in page.findall('one:TagDef', ns)] + self.assertEqual(definitions, [ + ('0', '61', 'Snow check', '#800000', '#00CCFF'), + ('1', '48', 'Snow task', 'automatic', 'none'), + ('2', '31', 'Snow circle', 'automatic', 'none'), + ('3', '0', 'Snow ink', '#0000FF', 'none'), + ]) + types = {d.get('index'): d.get('type') for d in page.findall('one:TagDef', ns)} + tagged = [(sorted((types[t.get('index')], t.get('completed')) for t in oe.findall('one:Tag', ns)), + re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or '')) + for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None] + self.assertEqual(tagged, [ + ([('0', 'true')], 'Custom symbol and colours'), + ([('1', 'false')], 'Custom check box'), + ([('1', 'true')], 'Custom check box, checked'), + ([('2', 'true'), ('3', 'true')], 'Two custom tags'), + ([], 'Untagged'), + ]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_page_background.py b/tools/test_page_background.py index 2d9b18122052346c297e25785d47e50493a0581d..254d9e2d2cf3614b2fa72b47266fa6aea6ea4d24 100644 --- a/tools/test_page_background.py +++ b/tools/test_page_background.py @@ -24,19 +24,29 @@ class PageBackgroundTest(unittest.TestCase): pages[page.get('name')] = page def art(title): - return [image.get('backgroundImage') for image in pages[title].findall('one:Image', ns)] + return [(image.get('backgroundImage'), image.find('one:Size', ns).get('width')) + for image in pages[title].findall('one:Image', ns)] def rules(title): return pages[title].find('one:PageSettings/one:RuleLines', ns).get('visible') - # Art joins a ruled page, which keeps its lines; art taken away leaves none. - self.assertEqual(art('Standard'), ['true']) - self.assertEqual(rules('Standard'), 'true') + def color(title): + return pages[title].find('one:PageSettings', ns).get('color') + + ivy, sparks = [('true', '174.5')], [('true', '611.4000244140625')] + # Art joins a ruled page, which keeps its lines; art taken away or undone leaves none; + # redone, or undone after other art replaced it, the art is back. + self.assertEqual(art('Standard'), ivy) self.assertEqual(art('Wide'), []) - self.assertEqual(rules('Wide'), 'true') - # A colour no menu offers reads back as written. - self.assertEqual(pages['SmallGrid'].find('one:PageSettings', ns).get('color'), '#E8D8C8') - + self.assertEqual(art('College'), []) + self.assertEqual(art('MediumGrid'), ivy) + self.assertEqual(art('LargeGrid'), ivy) + for title in ('Standard', 'Wide', 'College', 'MediumGrid', 'LargeGrid', 'VeryLargeGrid'): + self.assertEqual(rules(title), 'true', title) + # A colour no menu offers reads back as written; art over a colour keeps it. + self.assertEqual(color('SmallGrid'), '#E8D8C8') + self.assertEqual(art('VeryLargeGrid'), sparks) + self.assertEqual(color('VeryLargeGrid'), '#FDFDDD') if __name__ == '__main__': unittest.main() diff --git a/tools/verify-document.py b/tools/verify-document.py index fb7f9b0f63f8a5990cbccd78b6c0cdb1d03b9a87..204850536973afb47ec6a97de46a7eef31cef67d 100644 --- a/tools/verify-document.py +++ b/tools/verify-document.py @@ -178,7 +178,8 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads, a frame_size = stored_size + (1.44 if kind['printout'] else 0) assert abs(frame_size - native_size) < .002, ('Image display dimension differs', axis, frame_size, native_size) else: - expected = [p for p in native_payloads if p['page'] == page.get('ID') and p['object'] == parents[native].get('objectID')] + owner = native if parents[native] is page else parents[native] + expected = [p for p in native_payloads if p['page'] == page.get('ID') and p['object'] == owner.get('objectID')] assert len(expected) == 1, 'Native attachment association is ambiguous' assert hashlib.sha256(data).hexdigest() == expected[0]['sha256'], 'Associated attachment bytes differ' assert kind['filename'] == expected[0]['name'], 'Attachment filename differs'