authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 10:06:24-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 12:03:18-07:00
log91a2bf8cea7dd59ae56add4cee8bb968e653a439
tree5282a37dbfda458e3174e9d68faac4001071af42
parentf4e1afda77c93656bcf5c1e0e634aa9ca735d9ec
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: attachments, customize tags, typing undo runs, undoable background art, and iOS catch-up

- File attachments (#23): Attach File, drag and drop, open, and Save As, stored inline with a file-type icon as OneNote 2010 does; files on blank page are page-level objects that move, delete, and sync. - Customize Tags (#8): OneNote's dialog with all 143 symbols and both palettes; the tag list lives in settings and drives the toolbar, menus, palette, and shortcuts; tag definitions store OneNote's status bits. - Undo groups a run of typing until the caret moves or another edit, as OneNote 2010 does, on desktop and iOS. - Background art is one undoable editor step that renders on the next frame, with an opaque backing that hides rule lines as OneNote's art does. - Renderer: shadows are computed in the shader and the icon atlas evicts and grows instead of failing a frame. - iOS: sync status and sheet, tags with the new artwork, Tags Summary, page background, cross-notebook search, and a replica creation race fix. - An unset paragraph language is no longer written as language 0. Assisted-by: claude-opus-5.5

315 files changed, 10688 insertions(+), 1481 deletions(-)

Cargo.lock+1
......@@ -1888,6 +1888,7 @@ dependencies = [
18881888 "onestore",
18891889 "parley",
18901890 "pollster",
1891 "resvg",
18911892 "serde",
18921893 "serde_json",
18931894 "tempfile",
apps/ios/Snowbound/App.swift+33-11
......@@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
1919 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
2020 ) -> Bool {
2121 sb_set_coordinator(coordinate)
22 sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } }
2223 #if DEBUG
2324 Presenter.watch()
2425 #endif
......@@ -77,11 +78,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
7778 split.setViewController(pages, for: .supplementary)
7879 split.setViewController(UIViewController(), for: .secondary)
7980 notebooks.onOpen = { [weak self] tab, notebook in self?.open(tab, of: notebook) }
80 pages.onOpen = { [weak self] section, id, find in self?.show(section: section, page: id, find: find) }
81 pages.onOpenSection = { [weak self] notebook, path, id, find in
82 guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return }
83 self?.open(tab, of: notebook, page: id, find: find)
84 }
81 pages.onOpen = { [weak self] section, id in self?.show(section: section, page: id) }
8582 window.rootViewController = split
8683 window.makeKeyAndVisible()
8784 self.window = window
......@@ -116,11 +113,13 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
116113 }
117114
118115 func open(
119 _ tab: Tab, of notebook: Notebook, page: String? = nil, find: String? = nil,
116 _ tab: Tab, of notebook: Notebook, page: String? = nil, reveal: Reveal? = nil,
120117 then: ((Section) -> Void)? = nil
121118 ) {
122119 guard Author.name != nil else {
123 return Author.ask(from: split) { [weak self] in self?.open(tab, of: notebook, page: page, find: find, then: then) }
120 return Author.ask(from: split) { [weak self] in
121 self?.open(tab, of: notebook, page: page, reveal: reveal, then: then)
122 }
124123 }
125124 pages.loading(tab)
126125 split.show(.supplementary)
......@@ -134,15 +133,37 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
134133 notebooks.select(tab, of: notebook)
135134 Place.saved = Place(notebook: notebook.id, section: tab.path, page: page)
136135 if let page, section.row(of: page) != nil {
137 show(section: section, page: page, find: find)
136 show(section: section, page: page, reveal: reveal)
138137 }
139138 then?(section)
140139 }
141140 }
142141
143 func show(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) {
142 /// Opens `page` of the section at catalog `path` in `notebook`, as a search result or
143 /// the Tags Summary leads there.
144 func open(_ path: String, of notebook: Notebook, page: String, reveal: Reveal) {
145 if let section = pages.section, section.notebook === notebook, section.tab.path == path {
146 return show(section: section, page: page, reveal: reveal)
147 }
148 guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return }
149 open(tab, of: notebook, page: page, reveal: reveal)
150 }
151
152 /// OneNote's Tags Summary for `notebook`, as a sheet over `controller`.
153 func showTags(of notebook: Notebook, from controller: UIViewController) {
154 let tags = TagsViewController(notebook: notebook)
155 tags.onOpen = { [weak self] notebook, path, page, paragraph in
156 self?.open(path, of: notebook, page: page, reveal: .paragraph(paragraph))
157 }
158 let navigation = UINavigationController(rootViewController: tags)
159 navigation.sheetPresentationController?.detents = [.medium(), .large()]
160 navigation.sheetPresentationController?.prefersGrabberVisible = true
161 controller.present(navigation, animated: true)
162 }
163
164 func show(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) {
144165 showingPage = true
145 let controller = PageViewController(section: section, page: page, find: find, titleFocus: titleFocus)
166 let controller = PageViewController(section: section, page: page, reveal: reveal, titleFocus: titleFocus)
146167 controller.onOpen = { [weak self] id in self?.show(section: section, page: id) }
147168 split.setViewController(UINavigationController(rootViewController: controller), for: .secondary)
148169 split.show(.secondary)
......@@ -204,7 +225,8 @@ final class RootViewController: UISplitViewController {
204225 [
205226 UIKeyCommand(title: "New Page", action: #selector(newPage), input: "n", modifierFlags: .command),
206227 UIKeyCommand(
207 title: "New Subpage", action: #selector(newSubpage), input: "n", modifierFlags: [.command, .shift]),
228 title: "New Subpage", action: #selector(newSubpage), input: "n",
229 modifierFlags: [.command, .shift, .alternate]),
208230 UIKeyCommand(title: "Search", action: #selector(search), input: "f", modifierFlags: [.command, .alternate]),
209231 ]
210232 }
apps/ios/Snowbound/CanvasView.swift+151-19
......@@ -106,6 +106,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
106106 /// gestures move; the canvas paints neither.
107107 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)
108108 private let history = CanvasUndoManager()
109 private let spaceHint = Hint()
109110
110111 weak var inputDelegate: UITextInputDelegate?
111112 lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self)
......@@ -134,6 +135,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
134135 minimumZoomScale = 0.25
135136 maximumZoomScale = 4
136137 keyboardDismissMode = .interactive
138 // A phone on its side keeps the page clear of the sensor housing, as it keeps the top.
139 contentInsetAdjustmentBehavior = .always
137140 addSubview(content)
138141 insertSubview(metal, at: 0)
139142 // 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
487490 default:
488491 display.isActivated = isFirstResponder
489492 edit(external: true) { sb_view_release(handle) }
493 spaceHint.hide()
490494 }
491495 }
492496
......@@ -512,7 +516,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
512516
513517 /// Tells the bar buttons and the format bar what the selection now has.
514518 private func changedSelection() {
515 if let handle { formatBar.show(sb_view_format(handle)) }
519 formatBar.show(format)
516520 onChange?()
517521 }
518522
......@@ -576,14 +580,20 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
576580 }
577581 }
578582
583 /// A page selection copies every outline, as OneNote 2010 copies it.
579584 override func copy(_ sender: Any?) {
580 guard let range = selectedTextRange, let text = text(in: range) else { return }
585 guard let handle, let text = take(sb_view_copy(handle, false)) else { return }
581586 UIPasteboard.general.string = text
582587 }
583588
584589 override func cut(_ sender: Any?) {
585 copy(sender)
586 delete(sender)
590 guard let handle else { return }
591 var text: String?
592 edit(external: true) {
593 text = take(sb_view_copy(handle, true))
594 return text != nil
595 }
596 if let text { UIPasteboard.general.string = text }
587597 }
588598
589599 override func delete(_ sender: Any?) {
......@@ -614,6 +624,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
614624 override func toggleItalics(_ sender: Any?) { apply(1) }
615625 override func toggleUnderline(_ sender: Any?) { apply(2) }
616626
627 /// Tags and the highlighter join the system's text actions.
628 func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
629 guard let handle else { return nil }
630 let bits = sb_view_format(handle)
631 let tag = UIMenu(
632 title: "Tag", image: UIImage(systemName: "tag"),
633 children: Tags.menu(bits: bits) { [weak self] in self?.apply($0) })
634 var extra: [UIMenuElement] = [tag]
635 if !textRange.isEmpty {
636 extra.insert(
637 UIAction(title: "Highlight", image: UIImage(systemName: "highlighter")) { [weak self] _ in
638 self?.apply(10)
639 }, at: 0)
640 }
641 return UIMenu(children: suggestedActions + [UIMenu(options: .displayInline, children: extra)])
642 }
643
617644 // MARK: Page
618645
619646 /// A conflict page, which shows what is stored and takes no edits.
......@@ -624,6 +651,55 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
624651
625652 var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" }
626653
654 /// The selection's formatting, from `sb_view_format`.
655 var format: UInt64 { handle.map(sb_view_format) ?? 0 }
656
657 var pagePaper: Paper? { handle.flatMap { decode(Paper.self, sb_view_paper($0)) } }
658
659 /// Gives the page colour `color` and rule lines `ruled` of `paper.rules`, or none.
660 func setPaper(color: [UInt8]?, ruled: Int?) {
661 guard let handle else { return }
662 edit(external: true) {
663 sb_view_set_paper(
664 handle, color.map { Int16($0[0]) } ?? -1, color?[1] ?? 0, color?[2] ?? 0, Int8(ruled ?? -1))
665 }
666 }
667
668 /// Puts template `name`'s art behind the page, or none.
669 func setArt(_ name: String?) {
670 guard let handle else { return }
671 edit(external: true) { sb_view_set_art(handle, name) }
672 }
673
674 /// Insert Space: the next drag moves what lies below or right of where it starts.
675 func insertSpace() {
676 guard let handle else { return }
677 sb_view_insert_space(handle)
678 spaceHint.show("Drag down or right to add space", in: self)
679 }
680
681 /// Types the date, time or both as the system writes them short, and a space, as
682 /// OneNote's Insert Date and Time do.
683 func insertDate(_ date: Bool, time: Bool) {
684 let now = Date()
685 let text = DateFormatter.localizedString(
686 from: now, dateStyle: date ? .short : .none, timeStyle: time ? .short : .none)
687 if !isFirstResponder { _ = becomeFirstResponder() }
688 insertText(text + " ")
689 }
690
691 /// Selects paragraph `id` and brings it into view.
692 func selectParagraph(_ id: String) -> Bool {
693 guard let handle else { return false }
694 var found = false
695 edit(external: true) {
696 found = sb_view_select_paragraph(handle, id)
697 return found
698 }
699 if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) }
700 return found
701 }
702
627703 /// Applies `sb_view_apply` formatting to the selection.
628704 func apply(_ command: UInt8) {
629705 guard let handle else { return }
......@@ -682,26 +758,38 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
682758 override var keyCommands: [UIKeyCommand]? {
683759 guard isFirstResponder else { return nil }
684760 // Ctrl+1 to Ctrl+9 in OneNote, where the Mac's Command stands for Control.
685 let tags = FormatBar.tags.enumerated().map { index, name in
686 let command = UIKeyCommand(
687 title: name, action: #selector(tagged), input: "\(index + 1)", modifierFlags: .command,
688 propertyList: index)
689 command.wantsPriorityOverSystemBehavior = true
690 return command
691 }
692 let outdent = UIKeyCommand(
693 title: "Decrease Indent", action: #selector(outdented), input: "\t", modifierFlags: .shift)
694 outdent.wantsPriorityOverSystemBehavior = true
761 let tags = Tags.all.prefix(Tags.keyed).enumerated().map { index, tag in
762 UIKeyCommand(
763 title: tag.name, action: #selector(formatted), input: "\(index + 1)", modifierFlags: .command,
764 propertyList: 16 + index)
765 }
766 // The desktop's chords, as OneNote for Mac's.
767 let formats: [(String, String, UIKeyModifierFlags, Int)] = [
768 ("Strikethrough", "x", [.command, .shift], 3),
769 ("Bullets", ".", .command, 4),
770 ("Numbering", "/", .command, 5),
771 ("Increase Indent", "]", .command, 6),
772 ("Decrease Indent", "[", .command, 7),
773 ("Remove Tag", "0", [.command, .control], 8),
774 ("Clear Formatting", "n", [.command, .shift], 9),
775 ("Highlight", "h", [.command, .control], 10),
776 ("Decrease Indent", "\t", .shift, 7),
777 ]
778 let commands = tags + formats.map { title, input, modifiers, command in
779 UIKeyCommand(
780 title: title, action: #selector(formatted), input: input, modifierFlags: modifiers,
781 propertyList: command)
782 }
783 for command in commands { command.wantsPriorityOverSystemBehavior = true }
695784 let done = UIKeyCommand(title: "Done", action: #selector(finished), input: UIKeyCommand.inputEscape)
696 return tags + [outdent, done]
785 return commands + [done]
697786 }
698787
699 @objc private func tagged(_ command: UIKeyCommand) {
700 guard let index = command.propertyList as? Int else { return }
701 apply(16 + UInt8(index))
788 @objc private func formatted(_ command: UIKeyCommand) {
789 guard let command = command.propertyList as? Int else { return }
790 apply(UInt8(command))
702791 }
703792
704 @objc private func outdented() { apply(7) }
705793 @objc private func finished() { _ = resignFirstResponder() }
706794
707795 // MARK: Coordinates
......@@ -948,3 +1036,47 @@ private final class LineTokenizer: UITextInputStringTokenizer {
9481036 granularity == .line || super.isPosition(position, withinTextUnit: granularity, inDirection: direction)
9491037 }
9501038}
1039
1040/// A short instruction floating at the bottom of the page until the gesture it asks for.
1041private final class Hint: UIVisualEffectView {
1042 private let label = UILabel()
1043
1044 init() {
1045 super.init(effect: UIBlurEffect(style: .systemThickMaterial))
1046 label.font = .preferredFont(forTextStyle: .subheadline)
1047 label.textAlignment = .center
1048 label.translatesAutoresizingMaskIntoConstraints = false
1049 contentView.addSubview(label)
1050 NSLayoutConstraint.activate([
1051 label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
1052 label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
1053 label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
1054 label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
1055 ])
1056 layer.cornerRadius = 18
1057 clipsToBounds = true
1058 isUserInteractionEnabled = false
1059 }
1060
1061 required init?(coder: NSCoder) { fatalError() }
1062
1063 /// Shows `text` over the bottom of `view`'s visible area, and says it.
1064 func show(_ text: String, in view: UIScrollView) {
1065 label.text = text
1066 guard let host = view.superview else { return }
1067 translatesAutoresizingMaskIntoConstraints = false
1068 host.addSubview(self)
1069 NSLayoutConstraint.activate([
1070 centerXAnchor.constraint(equalTo: host.centerXAnchor),
1071 bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -16),
1072 ])
1073 alpha = 0
1074 UIView.animate(withDuration: 0.2) { self.alpha = 1 }
1075 UIAccessibility.post(notification: .announcement, argument: text)
1076 }
1077
1078 func hide() {
1079 guard superview != nil else { return }
1080 UIView.animate(withDuration: 0.2, animations: { self.alpha = 0 }) { _ in self.removeFromSuperview() }
1081 }
1082}
apps/ios/Snowbound/FormatBar.swift+12-13
......@@ -1,7 +1,7 @@
11import UIKit
22
3/// Formatting above the keyboard, as Notes offers it: tags and lists first, then text
4/// styles, indentation, tags and pictures, the keyboard's dismissal at the end.
3/// Formatting above the keyboard, as Notes offers it: To Do first, then text styles, lists,
4/// indentation, tags and pictures, the keyboard's dismissal at the end.
55final class FormatBar: UIInputView {
66 /// `sb_view_apply` commands and `sb_view_format` bits.
77 private static let buttons: [(symbol: String, label: String, command: UInt8)] = [
......@@ -10,18 +10,14 @@ final class FormatBar: UIInputView {
1010 ("italic", "Italic", 1),
1111 ("underline", "Underline", 2),
1212 ("strikethrough", "Strikethrough", 3),
13 ("highlighter", "Highlight", 10),
1314 ("list.bullet", "Bullets", 4),
1415 ("list.number", "Numbering", 5),
1516 ("decrease.indent", "Decrease Indent", 7),
1617 ("increase.indent", "Increase Indent", 6),
1718 ]
18 /// OneNote 2010's default tags in their order, Ctrl+1 to Ctrl+9.
19 static let tags = [
20 "To Do", "Important", "Question", "Remember for Later", "Definition", "Highlight", "Contact", "Address",
21 "Phone Number",
22 ]
23
2419 private var toggles: [(UIButton, UInt8)] = []
20 private var bits: UInt64 = 0
2521 var onApply: ((UInt8) -> Void)?
2622 /// Asks for a picture from the camera (true) or the photo library.
2723 var onPicture: ((Bool) -> Void)?
......@@ -44,9 +40,11 @@ final class FormatBar: UIInputView {
4440 row.addArrangedSubview(button)
4541 }
4642 let tag = Self.button("tag", "Tags")
47 tag.menu = UIMenu(children: Self.tags.enumerated().map { index, name in
48 UIAction(title: name) { [weak self] _ in self?.onApply?(16 + UInt8(index)) }
49 })
43 tag.menu = UIMenu(children: [
44 UIDeferredMenuElement.uncached { [weak self] done in
45 done(Tags.menu(bits: self?.bits ?? 0) { self?.onApply?($0) })
46 }
47 ])
5048 tag.showsMenuAsPrimaryAction = true
5149 row.addArrangedSubview(tag)
5250 let picture = Self.button("photo", "Insert Picture")
......@@ -105,9 +103,10 @@ final class FormatBar: UIInputView {
105103 }
106104
107105 /// Marks what the selection has, from `sb_view_format`.
108 func show(_ bits: UInt32) {
106 func show(_ bits: UInt64) {
107 self.bits = bits
109108 for (button, command) in toggles {
110 button.isSelected = bits & (1 << UInt32(command)) != 0
109 button.isSelected = bits & (1 << UInt64(command)) != 0
111110 }
112111 }
113112}
apps/ios/Snowbound/Lists.swift+83-65
......@@ -39,11 +39,14 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
3939 ])
4040 navigationItem.rightBarButtonItem = UIBarButtonItem(
4141 title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)
42 navigationItem.searchController = SearchViewController.controller()
43 toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()]
4244 }
4345
4446 override func viewWillAppear(_ animated: Bool) {
4547 super.viewWillAppear(animated)
4648 navigationController?.navigationBar.prefersLargeTitles = true
49 navigationController?.setToolbarHidden(false, animated: false)
4750 }
4851
4952 func reload() {
......@@ -170,6 +173,10 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
170173 UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {
171174 [weak self] _ in self?.newSection(in: notebook)
172175 },
176 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
177 guard let self else { return }
178 (view.window?.windowScene?.delegate as? SceneDelegate)?.showTags(of: notebook, from: self)
179 },
173180 UIAction(title: "Close Notebook", image: UIImage(systemName: "xmark.circle"), attributes: .destructive) {
174181 [weak self] _ in
175182 Notebooks.remove(notebook)
......@@ -250,7 +257,7 @@ extension UIFont {
250257}
251258
252259/// A section's pages, subpages indented, each page's conflict pages beneath it.
253final class PagesViewController: UITableViewController, UISearchResultsUpdating {
260final class PagesViewController: UITableViewController {
254261 private struct Item {
255262 let row: Row
256263 let version: Row.Version?
......@@ -260,13 +267,9 @@ final class PagesViewController: UITableViewController, UISearchResultsUpdating
260267 private(set) var section: Section?
261268 private var items: [Item] = []
262269 private var selected: String?
263 private let results = SearchViewController()
264 private lazy var search = UISearchController(searchResultsController: results)
265 private var searching: DispatchWorkItem?
270 private let search = SearchViewController.controller()
266271 /// Opens a page of the section.
267 var onOpen: ((Section, String, String?) -> Void)?
268 /// Opens a page of another section of the notebook, found by search.
269 var onOpenSection: ((Notebook, String, String, String?) -> Void)?
272 var onOpen: ((Section, String) -> Void)?
270273
271274 init() {
272275 super.init(style: .plain)
......@@ -277,22 +280,14 @@ final class PagesViewController: UITableViewController, UISearchResultsUpdating
277280 override func viewDidLoad() {
278281 super.viewDidLoad()
279282 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
280 search.searchResultsUpdater = self
281 search.searchBar.placeholder = "Search Notebook"
282283 navigationItem.searchController = search
283284 navigationItem.hidesSearchBarWhenScrolling = false
284 results.onOpen = { [weak self] found, query in
285 guard let self, let section, let notebook = Optional(section.notebook) else { return }
286 if found.section == section.tab.path {
287 onOpen?(section, found.page, query)
288 } else {
289 onOpenSection?(notebook, found.section, found.page, query)
290 }
291 }
292285 let compose = UIBarButtonItem(
293286 title: "New Page", image: UIImage(systemName: "square.and.pencil"),
294287 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })
295 toolbarItems = [.flexibleSpace(), compose]
288 toolbarItems = [
289 .flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace(), compose,
290 ]
296291 NotificationCenter.default.addObserver(
297292 self, selector: #selector(changed), name: Section.changed, object: nil)
298293 }
......@@ -398,7 +393,7 @@ final class PagesViewController: UITableViewController, UISearchResultsUpdating
398393 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
399394 guard let section else { return }
400395 selected = items[indexPath.row].id
401 onOpen?(section, items[indexPath.row].id, nil)
396 onOpen?(section, items[indexPath.row].id)
402397 }
403398
404399 override func tableView(
......@@ -432,34 +427,6 @@ final class PagesViewController: UITableViewController, UISearchResultsUpdating
432427 return UIMenu(children: actions)
433428 })
434429 }
435
436 func updateSearchResults(for controller: UISearchController) {
437 searching?.cancel()
438 let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? ""
439 guard let section, let library = section.notebook.handle, !query.isEmpty else {
440 results.show([], query: query)
441 return
442 }
443 // Each keystroke waits for the next before the notebook is read.
444 let work = DispatchWorkItem { [weak self] in
445 let (library, open, path) = (Int(bitPattern: library), Int(bitPattern: section.handle), section.tab.path)
446 self?.results.searching(query)
447 background({
448 decode(
449 [Found].self,
450 sb_library_search(
451 OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query)) ?? []
452 }) { [weak self] found in
453 guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return }
454 self?.results.show(
455 found, query: query,
456 sections: Dictionary(
457 section.notebook.tabs.map { ($0.path, $0.name) }, uniquingKeysWith: { first, _ in first }))
458 }
459 }
460 searching = work
461 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
462 }
463430}
464431
465432/// A page `sb_library_search` found.
......@@ -470,42 +437,90 @@ struct Found: Decodable {
470437 let snippet: String
471438}
472439
473final class SearchViewController: UITableViewController {
474 private var found: [Found] = []
475 private var sections: [String: String] = [:]
440/// Every open notebook's pages holding a query, grouped by notebook, as Notes searches
441/// every folder.
442final class SearchViewController: UITableViewController, UISearchResultsUpdating {
443 private var found: [(notebook: Notebook, pages: [Found])] = []
476444 private var query = ""
477 var onOpen: ((Found, String) -> Void)?
445 private var searching: DispatchWorkItem?
446
447 /// A search bar whose results this shows.
448 static func controller() -> UISearchController {
449 let results = SearchViewController(style: .insetGrouped)
450 let search = UISearchController(searchResultsController: results)
451 search.searchResultsUpdater = results
452 search.searchBar.placeholder = "Search Notebooks"
453 return search
454 }
478455
479456 override func viewDidLoad() {
480457 super.viewDidLoad()
481458 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found")
482459 }
483460
484 func searching(_ query: String) {
485 guard found.isEmpty else { return }
486 var loading = UIContentUnavailableConfiguration.loading()
487 loading.text = "Searching…"
488 contentUnavailableConfiguration = loading
461 func updateSearchResults(for controller: UISearchController) {
462 searching?.cancel()
463 let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? ""
464 guard !query.isEmpty else { return show([], query: query) }
465 // Each keystroke waits for the next before the notebooks are read.
466 let work = DispatchWorkItem { [weak self] in
467 if self?.found.isEmpty == true {
468 var loading = UIContentUnavailableConfiguration.loading()
469 loading.text = "Searching…"
470 self?.contentUnavailableConfiguration = loading
471 }
472 let notebooks = Notebooks.all.compactMap { notebook -> (Notebook, Int, Int, String?)? in
473 guard let library = notebook.handle else { return nil }
474 // Open sections are searched as their edits leave them.
475 let open = Section.all.first { $0.notebook === notebook }
476 return (notebook, Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path)
477 }
478 background({
479 notebooks.map { notebook, library, open, path in
480 let found = decode(
481 [Found].self,
482 sb_library_search(
483 OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query))
484 return (notebook: notebook, pages: found ?? [])
485 }
486 }) { [weak self] found in
487 guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return }
488 self?.show(found.filter { !$0.pages.isEmpty }, query: query)
489 }
490 }
491 searching = work
492 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
489493 }
490494
491 func show(_ found: [Found], query: String, sections: [String: String] = [:]) {
495 private func show(_ found: [(notebook: Notebook, pages: [Found])], query: String) {
492496 self.found = found
493497 self.query = query
494 self.sections = sections
495498 tableView.reloadData()
496 contentUnavailableConfiguration = found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil
499 contentUnavailableConfiguration =
500 found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil
497501 }
498502
499 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { found.count }
503 override func numberOfSections(in tableView: UITableView) -> Int { found.count }
504
505 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
506 found[section].notebook.name
507 }
508
509 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
510 found[section].pages.count
511 }
500512
501513 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
502 let found = found[indexPath.row]
514 let (notebook, pages) = found[indexPath.section]
515 let page = pages[indexPath.row]
503516 let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath)
504517 var content = UIListContentConfiguration.subtitleCell()
505 content.text = found.title.isEmpty ? "Untitled Page" : found.title
506 let section = sections[found.section] ?? ""
518 content.text = page.title.isEmpty ? "Untitled Page" : page.title
519 let tab = notebook.tabs.first { $0.path == page.section }
520 content.image = UIImage(systemName: "rectangle.portrait.fill")
521 content.imageProperties.tintColor = tab?.uiColor ?? .systemGray
507522 content.secondaryAttributedText = highlighted(
508 [section, found.snippet].filter { !$0.isEmpty }.joined(separator: " · "))
523 [tab?.name ?? "", page.snippet].filter { !$0.isEmpty }.joined(separator: " · "))
509524 content.secondaryTextProperties.numberOfLines = 2
510525 cell.contentConfiguration = content
511526 return cell
......@@ -526,6 +541,9 @@ final class SearchViewController: UITableViewController {
526541 }
527542
528543 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
529 onOpen?(found[indexPath.row], query)
544 let (notebook, pages) = found[indexPath.section]
545 let page = pages[indexPath.row]
546 (view.window?.windowScene?.delegate as? SceneDelegate)?
547 .open(page.section, of: notebook, page: page.page, reveal: .text(query))
530548 }
531549}
apps/ios/Snowbound/Notebooks.swift+17
......@@ -209,10 +209,27 @@ final class Notebook {
209209 if let handle { sb_library_free(handle) }
210210 handle = library
211211 self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil
212 follow()
212213 reload(done: done)
213214 }
214215 }
215216
217 /// Works offline or online, as Work Offline stands.
218 func follow() {
219 if let handle { sb_library_set_offline(handle, Sync.offline) }
220 }
221
222 /// Where the notebook is kept, as the sync sheet names it.
223 var location: String {
224 switch source {
225 case .files: "In Files"
226 case .documents: "On this \(UIDevice.current.model), in Snowbound’s folder"
227 case .server(let server):
228 "smb://\(server.host)/\([server.share, server.root].filter { !$0.isEmpty }.joined(separator: "/"))"
229 case .path(let path): path
230 }
231 }
232
216233 /// Lists the sections again, off the main thread.
217234 func reload(done: @escaping () -> Void) {
218235 guard let handle else {
apps/ios/Snowbound/PageViewController.swift+98-9
......@@ -1,6 +1,14 @@
11import PhotosUI
22import UIKit
33
4/// What a page shows selected as it opens.
5enum Reveal {
6 /// The first match of a search.
7 case text(String)
8 /// A paragraph the Tags Summary lists, by id.
9 case paragraph(String)
10}
11
412final class PageViewController: UIViewController, PHPickerViewControllerDelegate,
513 UIImagePickerControllerDelegate, UINavigationControllerDelegate
614{
......@@ -9,7 +17,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
917
1018 private let section: Section
1119 private let page: String
12 private let find: String?
20 private let reveal: Reveal?
1321 private let titleFocus: Bool
1422 private lazy var canvas = CanvasView(section: section, page: page)
1523 private let bar = ConflictBar()
......@@ -21,14 +29,18 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
2129 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
2230 private lazy var redo = UIBarButtonItem(
2331 systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
32 private lazy var more = UIBarButtonItem(
33 title: "Page", image: UIImage(systemName: "ellipsis.circle"),
34 menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }]))
2435
25 init(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) {
36 init(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) {
2637 self.section = section
2738 self.page = page
28 self.find = find
39 self.reveal = reveal
2940 self.titleFocus = titleFocus
3041 super.init(nibName: nil, bundle: nil)
3142 navigationItem.largeTitleDisplayMode = .never
43 navigationItem.rightBarButtonItems = [more]
3244 showTitle(section.row(of: page)?.row.title ?? "")
3345 }
3446
......@@ -60,13 +72,21 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
6072 self, selector: #selector(sectionChanged), name: Section.changed, object: section)
6173 NotificationCenter.default.addObserver(self, selector: #selector(leaving), name: Self.leaving, object: nil)
6274 conflictChanged()
75 registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (controller: PageViewController, _) in
76 controller.fitTitle()
77 }
6378 }
6479
65 /// The page is on screen: a found word is selected, a new page's title takes the caret.
80 /// The page is on screen: a found word or tagged paragraph is selected, a new page's
81 /// title takes the caret.
6682 private func opened() {
67 if let find, canvas.find(find) {
68 _ = canvas.becomeFirstResponder()
69 } else if titleFocus, canvas.focusTitle() {
83 let revealed =
84 switch reveal {
85 case .text(let query): canvas.find(query)
86 case .paragraph(let id): canvas.selectParagraph(id)
87 case nil: false
88 }
89 if revealed || titleFocus && canvas.focusTitle() {
7090 _ = canvas.becomeFirstResponder()
7191 }
7292 }
......@@ -75,23 +95,92 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
7595 self.title = title.isEmpty ? "Untitled Page" : title
7696 }
7797
98 /// A phone's bar has no room for the title beside the editing buttons, and the page
99 /// shows it anyway, as Notes leaves it out.
100 private func fitTitle() {
101 let crowded = canvas.isFirstResponder && traitCollection.horizontalSizeClass == .compact
102 navigationItem.titleView = crowded ? UIView() : nil
103 }
104
78105 /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it.
79106 private func editingChanged() {
80107 if !canvas.readOnly, let title = canvas.pageTitle {
81108 showTitle(title)
82109 section.retitle(page, title)
83110 }
111 fitTitle()
84112 guard canvas.isFirstResponder else {
85 navigationItem.rightBarButtonItems = nil
113 if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more] }
86114 return
87115 }
88116 undo.isEnabled = canvas.canUndo(redo: false)
89117 redo.isEnabled = canvas.canUndo(redo: true)
90118 if navigationItem.rightBarButtonItems?.first !== done {
91 navigationItem.rightBarButtonItems = [done, redo, undo]
119 navigationItem.rightBarButtonItems = [done, more, redo, undo]
92120 }
93121 }
94122
123 // MARK: Page menu
124
125 /// The desktop's page and insert commands that suit a touch screen.
126 private func pageMenu() -> [UIMenuElement] {
127 let editable: UIMenuElement.Attributes = canvas.readOnly ? .disabled : []
128 let scene = view.window?.windowScene?.delegate as? SceneDelegate
129 let insert = UIMenu(
130 title: "Insert", image: UIImage(systemName: "plus.circle"),
131 children: [
132 UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle"), attributes: editable) {
133 [weak self] _ in self?.pickPicture(camera: false)
134 },
135 UIAction(
136 title: "Take Photo", image: UIImage(systemName: "camera"),
137 attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? editable : .disabled
138 ) { [weak self] _ in self?.pickPicture(camera: true) },
139 UIMenu(options: .displayInline, children: [
140 UIAction(title: "Date", image: UIImage(systemName: "calendar"), attributes: editable) {
141 [weak self] _ in self?.canvas.insertDate(true, time: false)
142 },
143 UIAction(title: "Time", image: UIImage(systemName: "clock"), attributes: editable) {
144 [weak self] _ in self?.canvas.insertDate(false, time: true)
145 },
146 UIAction(title: "Date & Time", image: UIImage(systemName: "calendar.badge.clock"), attributes: editable) {
147 [weak self] _ in self?.canvas.insertDate(true, time: true)
148 },
149 ]),
150 UIAction(
151 title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable
152 ) { [weak self] _ in self?.canvas.insertSpace() },
153 ])
154 return [
155 insert,
156 UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) {
157 [weak self] _ in self?.showPaper()
158 },
159 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
160 guard let self else { return }
161 scene?.showTags(of: section.notebook, from: self)
162 },
163 UIMenu(options: .displayInline, children: [
164 UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in
165 scene?.newPage(subpage: true)
166 },
167 UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) {
168 [weak self] _ in
169 guard let self else { return }
170 section.delete(page) { _ in }
171 },
172 ]),
173 ]
174 }
175
176 private func showPaper() {
177 let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas))
178 navigation.sheetPresentationController?.detents = [.medium(), .large()]
179 navigation.sheetPresentationController?.prefersGrabberVisible = true
180 navigation.sheetPresentationController?.largestUndimmedDetentIdentifier = .medium
181 present(navigation, animated: true)
182 }
183
95184 @objc private func leaving() { canvas.commitComposition() }
96185
97186 @objc private func sectionChanged(_ notification: Notification) {
apps/ios/Snowbound/PaperViewController.swift created+183
......@@ -0,0 +1,183 @@
1import UIKit
2
3/// The page's paper from `sb_view_paper`, with the choices OneNote offers.
4struct Paper: Decodable {
5 struct Color: Decodable {
6 let name: String
7 let rgb: [UInt8]
8
9 init(from decoder: Decoder) throws {
10 var pair = try decoder.unkeyedContainer()
11 name = try pair.decode(String.self)
12 rgb = try pair.decode([UInt8].self)
13 }
14 }
15
16 let color: [UInt8]?
17 let ruled: Int?
18 let colors: [Color]
19 let rules: [String]
20 let templates: [String]
21}
22
23extension UIColor {
24 convenience init(rgb: [UInt8]) {
25 self.init(red: CGFloat(rgb[0]) / 255, green: CGFloat(rgb[1]) / 255, blue: CGFloat(rgb[2]) / 255, alpha: 1)
26 }
27
28 var rgb: [UInt8] {
29 var (red, green, blue, alpha): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0)
30 getRed(&red, green: &green, blue: &blue, alpha: &alpha)
31 return [red, green, blue].map { UInt8((min(1, max(0, $0)) * 255).rounded()) }
32 }
33}
34
35/// OneNote's View, Page Color and Rule Lines, and a template's art behind the page, as a
36/// sheet over the page, each choice taking effect at once.
37final class PaperViewController: UITableViewController {
38 private enum Part: Int, CaseIterable { case color, rules, background }
39
40 private let canvas: CanvasView
41 private var paper: Paper?
42 private let swatches = UIStackView()
43 private let well = UIColorWell()
44
45 init(canvas: CanvasView) {
46 self.canvas = canvas
47 super.init(style: .insetGrouped)
48 title = "Page Background"
49 }
50
51 required init?(coder: NSCoder) { fatalError() }
52
53 override func viewDidLoad() {
54 super.viewDidLoad()
55 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row")
56 navigationItem.rightBarButtonItem = UIBarButtonItem(
57 systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) })
58 well.title = "Custom Color"
59 well.supportsAlpha = false
60 well.addAction(UIAction { [weak self] _ in
61 guard let self, let color = well.selectedColor else { return }
62 canvas.setPaper(color: color.rgb, ruled: paper?.ruled)
63 reload()
64 }, for: .valueChanged)
65 reload()
66 }
67
68 private func reload() {
69 paper = canvas.pagePaper
70 showSwatches()
71 tableView.reloadData()
72 }
73
74 private func showSwatches() {
75 swatches.arrangedSubviews.forEach { $0.removeFromSuperview() }
76 guard let paper else { return }
77 let none = swatch(nil, name: "No Color", chosen: paper.color == nil)
78 swatches.addArrangedSubview(none)
79 for color in paper.colors {
80 swatches.addArrangedSubview(swatch(color.rgb, name: color.name, chosen: paper.color == color.rgb))
81 }
82 let custom = paper.color.flatMap { color in paper.colors.contains { $0.rgb == color } ? nil : color }
83 well.selectedColor = custom.map(UIColor.init(rgb:))
84 swatches.addArrangedSubview(well)
85 }
86
87 private func swatch(_ rgb: [UInt8]?, name: String, chosen: Bool) -> UIButton {
88 var configuration = UIButton.Configuration.plain()
89 configuration.background.backgroundColor = rgb.map(UIColor.init(rgb:)) ?? .white
90 configuration.background.cornerRadius = 16
91 configuration.background.strokeColor = chosen ? .tintColor : .separator
92 configuration.background.strokeWidth = chosen ? 3 : 1
93 if rgb == nil {
94 configuration.image = UIImage(systemName: "line.diagonal")
95 configuration.baseForegroundColor = .systemRed
96 }
97 let button = UIButton(configuration: configuration, primaryAction: UIAction { [weak self] _ in
98 guard let self else { return }
99 canvas.setPaper(color: rgb, ruled: paper?.ruled)
100 reload()
101 })
102 button.accessibilityLabel = name
103 button.accessibilityTraits.insert(chosen ? .selected : [])
104 button.widthAnchor.constraint(equalToConstant: 32).isActive = true
105 button.heightAnchor.constraint(equalToConstant: 32).isActive = true
106 return button
107 }
108
109 private let parts = Part.allCases
110
111 override func numberOfSections(in tableView: UITableView) -> Int { parts.count }
112
113 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
114 switch parts[section] {
115 case .color: "Page Color"
116 case .rules: "Rule Lines"
117 case .background: "Background"
118 }
119 }
120
121 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
122 guard let paper else { return 0 }
123 return switch parts[section] {
124 case .color: 1
125 case .rules: 1 + paper.rules.count
126 case .background: 1 + paper.templates.count
127 }
128 }
129
130 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
131 let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath)
132 cell.contentView.subviews.filter { $0 is UIScrollView }.forEach { $0.removeFromSuperview() }
133 cell.accessoryType = .none
134 cell.contentConfiguration = nil
135 guard let paper else { return cell }
136 switch parts[indexPath.section] {
137 case .color:
138 cell.selectionStyle = .none
139 let scroll = UIScrollView()
140 scroll.showsHorizontalScrollIndicator = false
141 swatches.spacing = 12
142 swatches.alignment = .center
143 swatches.translatesAutoresizingMaskIntoConstraints = false
144 scroll.translatesAutoresizingMaskIntoConstraints = false
145 scroll.addSubview(swatches)
146 cell.contentView.addSubview(scroll)
147 NSLayoutConstraint.activate([
148 scroll.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor),
149 scroll.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor),
150 scroll.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
151 scroll.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
152 scroll.heightAnchor.constraint(equalToConstant: 56),
153 swatches.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 16),
154 swatches.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -16),
155 swatches.centerYAnchor.constraint(equalTo: scroll.frameLayoutGuide.centerYAnchor),
156 ])
157 case .rules:
158 cell.selectionStyle = .default
159 var content = cell.defaultContentConfiguration()
160 content.text = indexPath.row == 0 ? "None" : paper.rules[indexPath.row - 1]
161 cell.contentConfiguration = content
162 let chosen = indexPath.row == 0 ? paper.ruled == nil : paper.ruled == indexPath.row - 1
163 cell.accessoryType = chosen ? .checkmark : .none
164 case .background:
165 cell.selectionStyle = .default
166 var content = cell.defaultContentConfiguration()
167 content.text = indexPath.row == 0 ? "None" : paper.templates[indexPath.row - 1]
168 cell.contentConfiguration = content
169 }
170 return cell
171 }
172
173 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
174 tableView.deselectRow(at: indexPath, animated: true)
175 guard let paper else { return }
176 switch parts[indexPath.section] {
177 case .color: return
178 case .rules: canvas.setPaper(color: paper.color, ruled: indexPath.row == 0 ? nil : indexPath.row - 1)
179 case .background: canvas.setArt(indexPath.row == 0 ? nil : paper.templates[indexPath.row - 1])
180 }
181 reload()
182 }
183}
apps/ios/Snowbound/Script.swift+4-1
......@@ -10,7 +10,7 @@ extension CanvasView {
1010 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
1111 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`
1212 /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall`
13 /// is the Select All command; `done`
13 /// is the Select All command; `rotate:landscape` or `rotate:portrait` turns the device; `done`
1414 /// ends editing, `tree` saves the view hierarchy to
1515 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
1616 /// screenshot command.
......@@ -54,6 +54,9 @@ extension CanvasView {
5454 case "doubletap":
5555 let values = argument.split(separator: ",").compactMap { Double($0) }
5656 zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
57 case "rotate":
58 let orientation: UIInterfaceOrientationMask = argument == "portrait" ? .portrait : .landscapeRight
59 window?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
5760 case "done": _ = resignFirstResponder()
5861 case "selectall": selectAll(nil)
5962 case "format": apply(UInt8(argument) ?? 0)
apps/ios/Snowbound/Sync.swift created+260
......@@ -0,0 +1,260 @@
1import UIKit
2
3/// A section's sync status from `sb_library_sync_status`.
4struct SectionSync: Decodable {
5 enum State: UInt8, Decodable, Comparable {
6 case upToDate, syncing, inUse, notConnected, readOnly, failed
7
8 static func < (a: State, b: State) -> Bool { a.rawValue < b.rawValue }
9 }
10
11 let path: String
12 let state: State
13 /// Seconds since 1970 when the section file was last reached.
14 let synced: Double?
15 let queued: Int
16 let error: String?
17}
18
19/// OneNote's Work Offline and Sync Now over every open notebook, and their status.
20enum Sync {
21 /// Posted on the main thread when a notebook's closed sections report.
22 static let changed = Notification.Name("SyncChanged")
23 private static let key = "offline"
24
25 static var offline: Bool {
26 get { UserDefaults.standard.bool(forKey: key) }
27 set {
28 UserDefaults.standard.set(newValue, forKey: key)
29 for notebook in Notebooks.all { notebook.follow() }
30 NotificationCenter.default.post(name: changed, object: nil)
31 }
32 }
33
34 static func now() {
35 for notebook in Notebooks.all {
36 if let handle = notebook.handle { sb_library_sync_now(handle) }
37 }
38 }
39
40 /// Every open notebook's sections with their status, read off the main thread.
41 static func status(done: @escaping ([(Notebook, [SectionSync])]) -> Void) {
42 let notebooks = Notebooks.all.compactMap { notebook in notebook.handle.map { (notebook, Int(bitPattern: $0)) } }
43 background({
44 notebooks.map { notebook, pointer in
45 (notebook, decode([SectionSync].self, sb_library_sync_status(OpaquePointer(bitPattern: pointer))) ?? [])
46 }
47 }, done: done)
48 }
49
50 /// What `state` comes to for the reader, as the desktop names it.
51 static func label(_ state: SectionSync.State) -> String {
52 if offline { return "Working offline" }
53 return switch state {
54 case .upToDate: "Up to date"
55 case .syncing: "Syncing…"
56 case .inUse: "Section in use"
57 case .notConnected: "Not connected"
58 case .readOnly, .failed: "Unable to sync"
59 }
60 }
61
62 static func symbol(_ state: SectionSync.State) -> String {
63 if offline { return "icloud.slash" }
64 return switch state {
65 case .upToDate: "checkmark.circle"
66 case .syncing, .inUse: "arrow.triangle.2.circlepath"
67 case .notConnected: "wifi.slash"
68 case .readOnly, .failed: "exclamationmark.triangle"
69 }
70 }
71
72 /// What the reader can do about `state`.
73 static func advice(_ state: SectionSync.State) -> String? {
74 let device = UIDevice.current.model
75 if offline { return "Changes stay on this \(device) until you sync." }
76 return switch state {
77 case .readOnly: "You can’t change this notebook where it’s stored. Changes stay on this \(device)."
78 case .inUse: "Someone else is saving this section. Sync continues when they finish."
79 case .notConnected: "Changes stay on this \(device) and sync when the notebook is back."
80 default: nil
81 }
82 }
83
84 static func changes(_ count: Int) -> String { count == 1 ? "1 change" : "\(count) changes" }
85
86 /// When a section was last reached: the time, and the date too before today.
87 static func when(_ seconds: Double) -> String {
88 let date = Date(timeIntervalSince1970: seconds)
89 return Calendar.current.isDateInToday(date)
90 ? DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short)
91 : DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short)
92 }
93}
94
95/// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync
96/// sheet.
97final class SyncIndicator: UIButton {
98 init() {
99 var configuration = UIButton.Configuration.plain()
100 configuration.imagePadding = 4
101 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1)
102 configuration.baseForegroundColor = .secondaryLabel
103 configuration.titleLineBreakMode = .byTruncatingTail
104 configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
105 var attributes = $0
106 attributes.font = .preferredFont(forTextStyle: .caption1)
107 return attributes
108 }
109 super.init(frame: .zero)
110 self.configuration = configuration
111 addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered)
112 for name in [Sync.changed, Section.changed] {
113 NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
114 }
115 refresh()
116 }
117
118 required init?(coder: NSCoder) { fatalError() }
119
120 @objc private func refresh() {
121 Sync.status { [weak self] notebooks in
122 let sections = notebooks.flatMap(\.1)
123 guard let self, let worst = sections.map(\.state).max() else {
124 self?.isHidden = true
125 return
126 }
127 isHidden = false
128 let queued = sections.map(\.queued).reduce(0, +)
129 let label = Sync.label(worst)
130 configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label
131 configuration?.image = UIImage(systemName: Sync.symbol(worst))
132 configuration?.baseForegroundColor = worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel
133 accessibilityLabel = "Sync status: \(configuration?.title ?? label)"
134 sizeToFit()
135 }
136 }
137
138 private func present() {
139 guard let controller = window?.rootViewController else { return }
140 let navigation = UINavigationController(rootViewController: SyncViewController())
141 navigation.sheetPresentationController?.detents = [.medium(), .large()]
142 navigation.sheetPresentationController?.prefersGrabberVisible = true
143 (controller.presentedViewController ?? controller).present(navigation, animated: true)
144 }
145}
146
147/// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections.
148final class SyncViewController: UITableViewController {
149 private var notebooks: [(Notebook, [SectionSync])] = []
150 private var timer: Timer?
151 private let offline = UISwitch()
152
153 init() {
154 super.init(style: .insetGrouped)
155 title = "Sync Status"
156 }
157
158 required init?(coder: NSCoder) { fatalError() }
159
160 override func viewDidLoad() {
161 super.viewDidLoad()
162 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row")
163 navigationItem.rightBarButtonItem = UIBarButtonItem(
164 systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) })
165 offline.addAction(UIAction { [weak self] _ in
166 Sync.offline = self?.offline.isOn ?? false
167 }, for: .valueChanged)
168 for name in [Sync.changed, Section.changed] {
169 NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
170 }
171 }
172
173 override func viewWillAppear(_ animated: Bool) {
174 super.viewWillAppear(animated)
175 refresh()
176 // The last sync time follows each round, which reports only when something changes.
177 timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in self?.refresh() }
178 }
179
180 override func viewDidDisappear(_ animated: Bool) {
181 super.viewDidDisappear(animated)
182 timer?.invalidate()
183 }
184
185 @objc private func refresh() {
186 Sync.status { [weak self] notebooks in
187 self?.notebooks = notebooks.filter { !$0.1.isEmpty }
188 self?.tableView.reloadData()
189 }
190 }
191
192 override func numberOfSections(in tableView: UITableView) -> Int { 1 + notebooks.count }
193
194 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
195 section == 0 ? 2 : notebooks[section - 1].1.count
196 }
197
198 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
199 section == 0 ? nil : notebooks[section - 1].0.name
200 }
201
202 override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
203 guard section > 0 else { return Sync.offline ? Sync.advice(.upToDate) : nil }
204 let (notebook, sections) = notebooks[section - 1]
205 let worst = sections.map(\.state).max() ?? .upToDate
206 let synced = sections.map(\.synced)
207 let oldest = synced.contains(where: { $0 == nil }) ? nil : synced.compactMap { $0 }.min()
208 return [
209 notebook.location,
210 oldest.map { "Last sync \(Sync.when($0))" },
211 Sync.offline ? nil : Sync.advice(worst),
212 ].compactMap { $0 }.joined(separator: "\n")
213 }
214
215 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
216 let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath)
217 cell.accessoryView = nil
218 cell.selectionStyle = .none
219 if indexPath.section == 0 {
220 var content = UIListContentConfiguration.cell()
221 if indexPath.row == 0 {
222 content.text = "Work Offline"
223 offline.isOn = Sync.offline
224 cell.accessoryView = offline
225 } else {
226 content.text = "Sync Now"
227 content.textProperties.color = .tintColor
228 cell.selectionStyle = .default
229 }
230 cell.contentConfiguration = content
231 return cell
232 }
233 let (notebook, sections) = notebooks[indexPath.section - 1]
234 let sync = sections[indexPath.row]
235 var content = UIListContentConfiguration.valueCell()
236 let tab = notebook.tabs.first { $0.path == sync.path }
237 content.text = tab?.name ?? URL(fileURLWithPath: sync.path).deletingPathExtension().lastPathComponent
238 content.image = UIImage(systemName: "rectangle.portrait.fill")
239 content.imageProperties.tintColor = tab?.uiColor ?? .systemGray
240 let label = Sync.label(sync.state)
241 content.secondaryText = sync.queued > 0 ? "\(label), \(Sync.changes(sync.queued))" : label
242 if let error = sync.error {
243 content = UIListContentConfiguration.subtitleCell()
244 content.text = tab?.name ?? sync.path
245 content.image = UIImage(systemName: "rectangle.portrait.fill")
246 content.imageProperties.tintColor = tab?.uiColor ?? .systemGray
247 content.secondaryText = "\(label): \(error)"
248 content.secondaryTextProperties.color = .secondaryLabel
249 }
250 cell.contentConfiguration = content
251 return cell
252 }
253
254 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
255 tableView.deselectRow(at: indexPath, animated: true)
256 guard indexPath == IndexPath(row: 1, section: 0) else { return }
257 Sync.now()
258 refresh()
259 }
260}
apps/ios/Snowbound/Tags.swift created+193
......@@ -0,0 +1,193 @@
1import UIKit
2
3/// OneNote's tags in `sb_view_apply`'s order from 16, drawn with the page's artwork.
4enum Tags {
5 struct Tag {
6 let name: String
7 /// The symbol `sb_tag_icon` draws; 0 for a highlighting tag.
8 let shape: UInt16
9 let highlight: UIColor?
10 }
11
12 static let all: [Tag] = {
13 guard let text = take(sb_tags()),
14 let rows = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [[Any]]
15 else { return [] }
16 return rows.compactMap { row in
17 guard row.count == 3, let name = row[0] as? String, let shape = row[1] as? NSNumber else { return nil }
18 let highlight = (row[2] as? [NSNumber]).map { UIColor(rgb: $0.map(\.uint8Value)) }
19 return Tag(name: name, shape: shape.uint16Value, highlight: highlight)
20 }
21 }()
22
23 /// The first nine, which ⌘1 to ⌘9 apply, as Ctrl+1 to Ctrl+9 do in OneNote.
24 static let keyed = 9
25
26 private static var icons: [Int: UIImage] = [:]
27
28 /// Tag symbol `shape` as OneNote draws it, a check box `checked`; a highlighter in
29 /// `highlight` for a highlighting tag.
30 static func icon(_ shape: UInt16, checked: Bool = false, highlight: UIColor? = nil) -> UIImage? {
31 guard shape != 0 else {
32 // Highlighted lines, as the desktop draws these tags.
33 let fill = highlight ?? .systemYellow
34 return UIGraphicsImageRenderer(size: CGSize(width: 18, height: 18)).image { context in
35 let card = UIBezierPath(roundedRect: CGRect(x: 1.5, y: 3.5, width: 15, height: 11), cornerRadius: 1.5)
36 fill.setFill()
37 card.fill()
38 UIColor(white: 0, alpha: 0.35).setStroke()
39 card.stroke()
40 UIColor(white: 0.25, alpha: 1).setFill()
41 context.fill(CGRect(x: 4, y: 6.5, width: 10, height: 1.5))
42 context.fill(CGRect(x: 4, y: 10, width: 7, height: 1.5))
43 }
44 }
45 let key = Int(shape) << 1 | (checked ? 1 : 0)
46 if let icon = icons[key] { return icon }
47 let points: CGFloat = 18
48 let scale = UIScreen.main.scale
49 let pixels = Int(points * scale)
50 var rgba = [UInt8](repeating: 0, count: pixels * pixels * 4)
51 guard sb_tag_icon(shape, checked, UInt32(pixels), &rgba),
52 let provider = CGDataProvider(data: Data(rgba) as CFData),
53 let image = CGImage(
54 width: pixels, height: pixels, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: pixels * 4,
55 space: CGColorSpace(name: CGColorSpace.sRGB)!,
56 bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
57 provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent)
58 else { return UIImage(systemName: "tag") }
59 let icon = UIImage(cgImage: image, scale: scale, orientation: .up).withRenderingMode(.alwaysOriginal)
60 icons[key] = icon
61 return icon
62 }
63
64 /// Every tag as a menu, those the selection has checked, then Remove Tag; `apply` takes
65 /// the `sb_view_apply` command.
66 static func menu(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIMenuElement] {
67 let actions = all.enumerated().map { index, tag in
68 UIAction(
69 title: tag.name, image: icon(tag.shape, highlight: tag.highlight),
70 state: bits & (1 << UInt64(16 + index)) != 0 ? .on : .off
71 ) { _ in apply(16 + UInt8(index)) }
72 }
73 let tagged = bits >> 16 != 0
74 return [
75 UIMenu(options: .displayInline, children: Array(actions.prefix(keyed))),
76 UIMenu(title: "More Tags", image: UIImage(systemName: "tag"), children: Array(actions.dropFirst(keyed))),
77 UIAction(
78 title: "Remove Tag", image: UIImage(systemName: "tag.slash"),
79 attributes: tagged ? [] : .disabled
80 ) { _ in apply(8) },
81 ]
82 }
83}
84
85/// A tagged paragraph from `sb_library_tagged`.
86struct Tagged: Decodable {
87 let section: String
88 let page: String
89 let title: String
90 let paragraph: String
91 let name: String
92 let shape: UInt16
93 let checked: Bool
94 let text: String
95}
96
97/// OneNote's Tags Summary: a notebook's tagged paragraphs by tag, each opening its page with
98/// the paragraph selected.
99final class TagsViewController: UITableViewController {
100 private let notebook: Notebook
101 private var groups: [(name: String, items: [Tagged])] = []
102 private var unchecked = false
103 private var all: [Tagged] = []
104 /// Opens a page of the notebook: section path, page and paragraph.
105 var onOpen: ((Notebook, String, String, String) -> Void)?
106
107 init(notebook: Notebook) {
108 self.notebook = notebook
109 super.init(style: .insetGrouped)
110 title = "Tags Summary"
111 }
112
113 required init?(coder: NSCoder) { fatalError() }
114
115 override func viewDidLoad() {
116 super.viewDidLoad()
117 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "tagged")
118 navigationItem.rightBarButtonItem = UIBarButtonItem(
119 systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) })
120 navigationItem.leftBarButtonItem = UIBarButtonItem(
121 title: "Filter", image: UIImage(systemName: "line.3.horizontal.decrease.circle"),
122 menu: UIMenu(children: [
123 UIDeferredMenuElement.uncached { [weak self] done in
124 done([
125 UIAction(title: "Unchecked Only", state: self?.unchecked == true ? .on : .off) { _ in
126 self?.unchecked.toggle()
127 self?.show()
128 }
129 ])
130 }
131 ]))
132 var loading = UIContentUnavailableConfiguration.loading()
133 loading.text = "Finding Tags…"
134 contentUnavailableConfiguration = loading
135 guard let library = notebook.handle else { return }
136 let open = Section.all.first { $0.notebook === notebook }
137 let (pointer, section, path) = (Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path)
138 background({
139 decode([Tagged].self, sb_library_tagged(OpaquePointer(bitPattern: pointer), OpaquePointer(bitPattern: section), path))
140 ?? []
141 }) { [weak self] tagged in
142 self?.all = tagged
143 self?.show()
144 }
145 }
146
147 private func show() {
148 var order: [String] = []
149 var byName: [String: [Tagged]] = [:]
150 for tagged in all where !unchecked || !tagged.checked {
151 if byName[tagged.name] == nil { order.append(tagged.name) }
152 byName[tagged.name, default: []].append(tagged)
153 }
154 groups = order.map { ($0, byName[$0] ?? []) }
155 tableView.reloadData()
156 var empty = UIContentUnavailableConfiguration.empty()
157 empty.image = UIImage(systemName: "tag")
158 empty.text = unchecked ? "No Unchecked Tags" : "No Tags"
159 empty.secondaryText = unchecked ? nil : "Tag a paragraph from the format bar or its menu."
160 contentUnavailableConfiguration = groups.isEmpty ? empty : nil
161 }
162
163 override func numberOfSections(in tableView: UITableView) -> Int { groups.count }
164
165 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
166 groups[section].name
167 }
168
169 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
170 groups[section].items.count
171 }
172
173 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
174 let tagged = groups[indexPath.section].items[indexPath.row]
175 let cell = tableView.dequeueReusableCell(withIdentifier: "tagged", for: indexPath)
176 var content = UIListContentConfiguration.subtitleCell()
177 content.image = Tags.icon(tagged.shape, checked: tagged.checked)
178 content.text = tagged.text.isEmpty ? " " : tagged.text
179 content.textProperties.numberOfLines = 3
180 let section = notebook.tabs.first { $0.path == tagged.section }?.name
181 content.secondaryText = [tagged.title.isEmpty ? "Untitled Page" : tagged.title, section]
182 .compactMap { $0 }.joined(separator: " · ")
183 content.secondaryTextProperties.color = .secondaryLabel
184 cell.contentConfiguration = content
185 return cell
186 }
187
188 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
189 let tagged = groups[indexPath.section].items[indexPath.row]
190 dismiss(animated: true)
191 onOpen?(notebook, tagged.section, tagged.page, tagged.paragraph)
192 }
193}
arc/canvas.md+20-1
......@@ -41,7 +41,10 @@ the size of the edit, not the size of the page.
4141
4242Undo lives in the editor, not in storage. Each history entry keeps the inverse
4343of the change it made. Undoing applies that inverse and emits its ops as a new
44edit, so the file only ever moves forward, like OneNote's. Undoing a deletion
44edit, so the file only ever moves forward, like OneNote's. Undo steps are
45OneNote 2010's too: typing and backspaces at one caret are a single step until
46the caret moves or another kind of edit comes between, though each keystroke's
47ops still reach storage as it is typed. Undoing a deletion
4548brings back the original identities, so internal links to those paragraphs
4649survive an undo.
4750
......@@ -87,6 +90,22 @@ OneNote links it when it opens a page, without being stored as one. Ink draws st
8790coordinates. Page templates' background art is recreated as vector art and
8891recognised by the stored picture's hash. OneNote's bitmaps aren't shipped.
8992
93## Attached files
94
95A file sits in an outline's flow as OneNote draws it: its icon over its name,
96extension hidden, in a 54-point column. Attaching splits the caret's paragraph
97around the file, and the text after the caret follows it with the caret, as
98OneNote 2010 attaches and drops files. Every new file stores the icon OneNote
99stores beside it, because OneNote draws a broken picture for a file without
100one: the host's system icon where it has one, the canvas's blank page
101otherwise. A double click or the context menu's Open asks the host to open a
102copy; Save As writes the bytes where the user chooses.
103
104Attached or dropped where a click on blank page left the caret, the file goes
105on the page itself, as OneNote 2010 places it: the same column at the caret's
106grid point, outside any outline. It selects, drags on the grid, deletes and
107moves with Insert Space as a picture does, and opens and saves as above.
108
90109## Content the editor doesn't understand
91110
92111Nothing is lost for being unfamiliar. A paragraph the canvas can't draw
arc/file-format.md+8-3
......@@ -54,9 +54,14 @@ the file grows):
5454 file mean nothing until the header's transaction count says a transaction
5555 holding them has committed.
5656
57External payloads (large pictures, attachments, recordings) can live beside
58the section as `.onebin` files in a `_onefiles` folder. The section refers to
59them by name.
57The format lets payloads (pictures, attachments, recordings) live beside the
58section as `.onebin` files in a `_onefiles` folder, referred to by name. OneNote
592010 never writes them: every attachment it stores, 300 MiB ones included and
60on a share too, goes into the section's own file data store, with a 32-pixel
61PNG of the file's icon beside it. Snowbound writes the same. OneNote also
62stores identical bytes once per section, so two attachments of one file (or
63one icon) share a payload; Snowbound stores each anew, which OneNote reads the
64same.
6065
6166## What "append one revision" means
6267
arc/sync.md+7
......@@ -58,6 +58,13 @@ replayed and the open batch applied, rebuilt on open. The database runs in WAL
5858mode with full synchronous commits, and every setting is read back to check it
5959took.
6060
61An attached file is a payload like a picture: its bytes ride in the edit that
62inserts it and publish inside that edit's revision. No other file is written,
63so nothing needs to reach the share before the revision that names it. That
64ordering would matter only for `_onefiles` payloads, which OneNote 2010 never
65writes ([file format](file-format.md)). A large file makes a publication as
66large as itself.
67
6168A keystroke that publishes immediately costs three commits: the edit, the seal
6269(which also records the publication attempt), and the receipt. Each one is
6370ordered against something outside the cache. The edit must be durable before
arc/ui.md+6-4
......@@ -116,7 +116,8 @@ rounded or gradient rectangles, pen strokes and raster images.
116116- **Glyphs** are rasterized with Swash into an atlas, at quarter-pixel phases,
117117 so text placed at fractional positions stays crisp and doesn't shimmer as it
118118 moves. Icons and paths rasterize once per size and phase into the same
119 atlas.
119 atlas. A rounded box's soft shadow is worked out in the shader instead, so
120 popups of any size, widening or not, cost the atlas nothing.
120121- **The renderer never measures text.** Text reaches it through a trait that
121122 visits positioned glyph runs. Line metrics stay with whoever laid the text
122123 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.
128129 or within shows through a row. A popup's contents clip to its rounded
129130 outline, and one opening over a combo lays its rows out at their final
130131 width while only the outline widens.
131- **Caches are bounded.** The glyph atlas and image cache have fixed budgets.
132 Eviction keeps everything the current frame needs and rebuilds after
133 pressure. Images are filtered in linear light with premultiplied alpha.
132- **Caches are bounded.** The image cache has a fixed budget. A full glyph
133 atlas is cleared and rebuilt with only what the current frame needs; where
134 one frame alone needs more than half of it, it doubles, up to what the GPU
135 allows. Images are filtered in linear light with premultiplied alpha.
134136
135137GPU readback tests pin the rasterization down: successive quarter-pixel
136138translations have to move the ink's centroid in quarter-pixel steps, and
corpus/attachment-edit/README.md+2-1
......@@ -10,7 +10,8 @@ section's file-data store list, an embedded-file object declaring it by
1010identity and extension, and an attachment object with the file name, the
111124 pt icon size and the flags every native attachment carries. `icon/` also
1212embeds the native fixture's 724-byte icon PNG as the preview picture;
13`plain/` omits it, which OneNote accepts (it renders its own icon).
13`plain/` omits it, which OneNote opens but draws as a broken picture
14(`corpus/attachment-insert/without-icon.png`), so the editor always stores one.
1415OneNote's integrity check refuses an attachment object without
1516`0x1c001d61` (twenty bytes: 16, 1, zeros), so the writer always stores it.
1617
corpus/attachment-floating/README.md created+38
......@@ -0,0 +1,38 @@
1# Files on the page
2
3`native/files.one` is OneNote 2010's own section, made in a lab clone on
42026-09-29. Its second page holds two copies of `float.txt` (38 bytes) outside
5any outline: one inserted through COM `UpdatePageContent` as a page-level
6`InsertedFile` at (216, 180) and then dragged to (288, 140.4), the other
7attached with Insert > Attach File after a click on blank page, at (342, 284.4).
8
9`candidate/` is `crates/canvas/tests/attachment_insert.rs`'s second section:
10the editor places the caret on blank page at (342, 284.4) and attaches
11`float 🦀.txt` (37 bytes, with the icon OneNote stored for a text file), then
12at (126, 410.4) attaches `large.bin` (2 MiB of noise, with the canvas's
13blank-page icon) and drags it to (288, 140.4). Each step is its own edit of the
14ops the editor recorded, sealed into one appended revision. `cold/` is a fresh
15OneNote 2010 read: both files are page-level `InsertedFile`s at those
16positions, and the bytes OneNote materializes to open them
17(`read/*.attachment`) hash as written. `tools/test_attachment_floating.py`
18checks the capture without a VM. Regenerate with `CANVAS_FLOATING_EXPORT` set to
19a directory while running the test, then `tools/native_runner.py DIR COLD
20--expected-pages 1 --collect-notebook --screenshots`.
21
22## What OneNote 2010 does
23
24- **Placement.** Attached or dropped on blank page, the file becomes a child of
25 the page at the point a click there puts the caret: on the 18 pt grid from
26 the margin origin, 7 px above the pointer. It draws as a file in an outline
27 does, its 32-pixel icon at 24 points centred in a 54-point column over its
28 name without the extension, and selects as a shaded column framed in dashes,
29 without handles.
30- **Storage.** The attachment object (jcid 0x60035) carries what a paragraph's
31 file does, except LayoutAlignmentInParent; its position (0x14001c14/15),
32 LayoutAlignmentSelf 9, and the column's LayoutMaxWidth/Height (54 by about 63
33 points) with the size not set by the user. A file inserted through COM has
34 neither alignment nor size.
35- **Moves.** Dragging rewrites the position alone, on the grid; the
36 modification time stays.
37- **Payloads.** Both copies' embedded-file objects, and both icons, name one
38 payload in the file data store.
corpus/attachment-floating/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/attachment-floating/candidate/Open Notebook.onetoc2 differ
corpus/attachment-floating/candidate/files.one created
Binary files /dev/null and b/corpus/attachment-floating/candidate/files.one differ
corpus/attachment-floating/cold/commands.jsonl created+3
......@@ -0,0 +1,3 @@
1{"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}
2{"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}
3{"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}
corpus/attachment-floating/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-45bb9354", "hostname": "ONE-M6-45BB9354"}
corpus/attachment-floating/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment created
Binary files /dev/null and b/corpus/attachment-floating/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment differ
corpus/attachment-floating/cold/read/78b7cac526a295294b0a4f001cfdc3a8a30ea5dad8d18cc53022a6a5285ca58f.attachment created+1
......@@ -0,0 +1 @@
1Snowbound put this file on the page.
corpus/attachment-floating/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-45BB9354",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/attachment-floating/cold/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{6A8583F5-6FC0-4A26-A608-93D1BD212D43}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T18:39:16.000Z" color="#B49EDE"><one:Section name="files" ID="{993FE3E6-D8AC-092B-1A41-2B1D1969C115}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-09-29T18:39:16.000Z" color="#8AA8E4"><one:Page ID="{6A838D53-0497-0161-3E0F-95FC73741A4D}{1}{B0}" name="Floating files" dateTime="2026-09-29T18:31:15.000Z" lastModifiedTime="2026-09-29T18:39:05.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/attachment-floating/cold/read/page-000.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Floating files - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/attachment-floating/cold/read/page-000.png created
Binary files /dev/null and b/corpus/attachment-floating/cold/read/page-000.png differ
corpus/attachment-floating/cold/read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{6A838D53-0497-0161-3E0F-95FC73741A4D}{1}{B0}" name="Floating files" dateTime="2026-09-29T18:31:15.000Z" lastModifiedTime="2026-09-29T18:39:05.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2026-09-29T18:31:15.000Z" objectID="{993FE3E6-D8AC-092B-1A41-2B1D1969C115}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="96.23243713378906" height="13.4277172088623"/><one:OEChildren><one:OE creationTime="2026-09-29T18:31:15.000Z" lastModifiedTime="2026-09-29T18:31:15.000Z" objectID="{993FE3E6-D8AC-092B-1A41-2B1D1969C115}{24}{B0}" alignment="left"><one:T><![CDATA[Floating files]]></one:T></one:OE></one:OEChildren></one:Outline><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\e2f66eb2-07db-4c10-ab3d-8c06ad8742dd.bin" pathSource="/Users/snowbound/Documents/large.bin" preferredName="large.bin" lastModifiedTime="2022-06-18T04:26:43.000Z" objectID="{01634379-E6DF-0FE9-1F9E-D863E5BCE12A}{4}{B0}"><one:Position x="288.0" y="140.3999938964844" z="2"/><one:Size width="54.0" height="62.99999237060547"/></one:InsertedFile><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\6db07d21-03c2-4cd2-8cf3-e517e1598974.txt" pathSource="/Users/snowbound/Documents/float 🦀.txt" preferredName="float 🦀.txt" lastModifiedTime="2022-06-18T04:26:41.000Z" objectID="{01634379-E6DF-0FE9-1F9E-D863E5BCE12A}{2}{B0}"><one:Position x="342.0" y="284.3999938964844" z="1"/><one:Size width="54.0" height="63.0"/></one:InsertedFile></one:Page>
corpus/attachment-floating/cold/read/payloads.json created+18
......@@ -0,0 +1,18 @@
1[
2 {
3 "sha256": "1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a",
4 "name": "large.bin",
5 "object": "{01634379-E6DF-0FE9-1F9E-D863E5BCE12A}{4}{B0}",
6 "kind": "InsertedFile",
7 "page": "{6A838D53-0497-0161-3E0F-95FC73741A4D}{1}{B0}",
8 "bytes": 2097152
9 },
10 {
11 "sha256": "78b7cac526a295294b0a4f001cfdc3a8a30ea5dad8d18cc53022a6a5285ca58f",
12 "name": "float 🦀.txt",
13 "object": "{01634379-E6DF-0FE9-1F9E-D863E5BCE12A}{2}{B0}",
14 "kind": "InsertedFile",
15 "page": "{6A838D53-0497-0161-3E0F-95FC73741A4D}{1}{B0}",
16 "bytes": 37
17 }
18]
\ No newline at end of file
corpus/attachment-floating/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-attachments/export",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/attachment-floating/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/attachment-floating/cold/scripts/read.ps1 created+144
......@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/attachment-floating/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "46e74799daaccb7e37abf1696a1920ce47bb39bd721a2e7e15af8d0f6db7416f",
6 "mtime_ns": 1790706678169228316
7 },
8 {
9 "path": "files.one",
10 "bytes": 2106848,
11 "sha256": "cb063e9a0656899d90d4f95e469d9fec1c72bc259d44f673071d6b03e55ab241",
12 "mtime_ns": 1790706678168913736
13 }
14]
corpus/attachment-floating/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/attachment-floating/native/files.one created
Binary files /dev/null and b/corpus/attachment-floating/native/files.one differ
corpus/attachment-insert/README.md created+42
......@@ -0,0 +1,42 @@
1# Attaching files from the editor
2
3`candidate/` is `crates/canvas/tests/attachment_insert.rs`'s section: the editor
4attaches `notes 🦀.txt` (30 bytes, with the icon OneNote stored for a text file)
5at the end of "Before the file", "After the file" is typed where the caret
6lands, and `large.bin` (2 MiB of noise, with the canvas's blank-page icon, as
7a host without system icons stores it) is attached after that. Each step is its
8own edit of the ops the editor recorded, sealed into one appended revision.
9`cold/` is a fresh OneNote 2010 read: both files in place, and the bytes OneNote
10materializes to open them (`read/*.attachment`) hash as written. Opened by
11double click and saved by Save As in a lab clone, both matched too.
12`tools/test_attachment_insert.py` checks the capture without a VM. Regenerate
13with `CANVAS_ATTACHMENT_EXPORT` set to a directory while running the test, then
14`tools/native_runner.py DIR COLD --expected-pages 1 --collect-notebook
15--screenshots`.
16
17`without-icon.png` is OneNote opening a file stored without the icon: it draws
18a broken picture, so the editor always stores one.
19
20## What OneNote 2010 does
21
22Observed in a lab clone on 2026-09-29:
23
24- **Storage.** Every attachment goes into the section's own file data store,
25 whatever its size (1 KiB to 300 MiB, through COM and through Insert > Attach
26 File, on a local folder and on a share). OneNote never wrote a `_onefiles`
27 folder. Identical bytes are stored once per section: a second attachment of
28 the same file, or the same icon, references the first payload. Snowbound
29 stores each attachment's bytes anew, which OneNote reads the same.
30- **Icon.** Beside each file it stores a 32-pixel PNG of the file type's
31 system icon, drawn at 24 points, over the name without its extension.
32- **Attach File and drops.** At the caret, the paragraph splits there: the file
33 follows the text before the caret (or replaces an empty first half) and the
34 text after it follows the file with the caret, as an empty paragraph at the
35 end of one. A drop on text does the same at the drop point, after asking
36 whether to attach a copy (the default) or link to the original. Attached or
37 dropped on blank page, the file becomes a page-level object at that point.
38- **Open.** A double click or Open warns once ("Opening attachments could harm
39 your computer and data") and opens a copy from
40 `%TEMP%\OneNote\14.0\NT\N\` under the file's own name. The context menu
41 offers Open, Save As (in the source's folder, under its name), Insert as
42 Printout, Open Original and Copy Link to Original.
corpus/attachment-insert/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/attachment-insert/candidate/Open Notebook.onetoc2 differ
corpus/attachment-insert/candidate/files.one created
Binary files /dev/null and b/corpus/attachment-insert/candidate/files.one differ
corpus/attachment-insert/cold/commands.jsonl created+3
......@@ -0,0 +1,3 @@
1{"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}
2{"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}
3{"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}
corpus/attachment-insert/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-ddcbcdbf", "hostname": "ONE-M6-DDCBCDBF"}
corpus/attachment-insert/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment created
Binary files /dev/null and b/corpus/attachment-insert/cold/read/1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a.attachment differ
corpus/attachment-insert/cold/read/6762b4d4a7d68d79ade02f878b9c64dbce0b0d4830d7ef6dd36d932339bb8ed3.attachment created+1
......@@ -0,0 +1 @@
1Snowbound attached this file.
corpus/attachment-insert/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-DDCBCDBF",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/attachment-insert/cold/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{A826269C-EF84-4056-BC12-661022F3D9A3}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T17:40:55.000Z" color="#B49EDE"><one:Section name="files" ID="{3B2C3DA1-9735-0B63-2EE7-6F168478D766}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-09-29T17:40:55.000Z" color="#8AA8E4"><one:Page ID="{7AC920BC-0677-0C27-1640-BEFF46EEC119}{1}{B0}" name="Before the file" dateTime="2026-09-29T17:38:22.000Z" lastModifiedTime="2026-09-29T17:40:55.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/attachment-insert/cold/read/page-000.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Before the file - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/attachment-insert/cold/read/page-000.png created
Binary files /dev/null and b/corpus/attachment-insert/cold/read/page-000.png differ
corpus/attachment-insert/cold/read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{7AC920BC-0677-0C27-1640-BEFF46EEC119}{1}{B0}" name="Before the file" dateTime="2026-09-29T17:38:22.000Z" lastModifiedTime="2026-09-29T17:40:55.000Z" pageLevel="1" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2026-09-29T17:38:22.000Z" objectID="{3B2C3DA1-9735-0B63-2EE7-6F168478D766}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="103.4619598388672" height="166.2831420898437"/><one:OEChildren><one:OE creationTime="2026-09-29T17:38:22.000Z" lastModifiedTime="2026-09-29T17:38:22.000Z" objectID="{3B2C3DA1-9735-0B63-2EE7-6F168478D766}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before the file]]></one:T></one:OE><one:OE creationTime="2022-06-18T04:26:41.000Z" lastModifiedTime="2022-06-18T04:26:41.000Z" objectID="{0E43FDD7-2BBF-0D36-2F38-3F49329E11AD}{5}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\ad2e52ea-b4aa-4d5a-9687-f694b5a347d4.txt" pathSource="/Users/snowbound/Documents/notes 🦀.txt" preferredName="notes 🦀.txt"/></one:OE><one:OE creationTime="2022-06-18T04:26:41.000Z" lastModifiedTime="2022-06-18T04:26:42.000Z" objectID="{0E43FDD7-2BBF-0D36-2F38-3F49329E11AD}{3}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[After the file]]></one:T></one:OE><one:OE creationTime="2022-06-18T04:26:43.000Z" lastModifiedTime="2022-06-18T04:26:43.000Z" objectID="{0E43FDD7-2BBF-0D36-2F38-3F49329E11AD}{8}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\8d5146be-280a-481f-8c76-92bb915c8c80.bin" pathSource="/Users/snowbound/Documents/large.bin" preferredName="large.bin"/></one:OE><one:OE creationTime="2022-06-18T04:26:43.000Z" lastModifiedTime="2022-06-18T04:26:43.000Z" objectID="{0E43FDD7-2BBF-0D36-2F38-3F49329E11AD}{6}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/attachment-insert/cold/read/payloads.json created+18
......@@ -0,0 +1,18 @@
1[
2 {
3 "sha256": "6762b4d4a7d68d79ade02f878b9c64dbce0b0d4830d7ef6dd36d932339bb8ed3",
4 "name": "notes 🦀.txt",
5 "object": "{0E43FDD7-2BBF-0D36-2F38-3F49329E11AD}{5}{B0}",
6 "kind": "InsertedFile",
7 "page": "{7AC920BC-0677-0C27-1640-BEFF46EEC119}{1}{B0}",
8 "bytes": 30
9 },
10 {
11 "sha256": "1dec2d4ce43ad4fb609d5ec604ed9cc5a602f5930c9726e553dbcf5b4d51ee3a",
12 "name": "large.bin",
13 "object": "{0E43FDD7-2BBF-0D36-2F38-3F49329E11AD}{8}{B0}",
14 "kind": "InsertedFile",
15 "page": "{7AC920BC-0677-0C27-1640-BEFF46EEC119}{1}{B0}",
16 "bytes": 2097152
17 }
18]
\ No newline at end of file
corpus/attachment-insert/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-attachments/candidate",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/attachment-insert/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/attachment-insert/cold/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/attachment-insert/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "8e2a1137f47d89425a05053b93f0789be77f219ae2bd4d8bb0bdd1b052a93074",
6 "mtime_ns": 1790703504369465697
7 },
8 {
9 "path": "files.one",
10 "bytes": 2108416,
11 "sha256": "3d60c4a8628ec91e1b96c0a4f43391a2e78206ac2d0918dc0c9b3ef8e851c8de",
12 "mtime_ns": 1790703504369204448
13 }
14]
corpus/attachment-insert/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/attachment-insert/without-icon.png created
Binary files /dev/null and b/corpus/attachment-insert/without-icon.png differ
corpus/custom-tags/README.md created+29
......@@ -0,0 +1,29 @@
1# Customized note tags
2
3`candidate/` is the editor's output for `custom_tags_store_what_customize_tags_stores`
4in `crates/canvas/tests/custom_tags.rs`: on a section created in Rust, four tags of a
5customized list are applied through `Formatting::Tag`, each definition storing the tag's
6place in the list as its action type: "Snow check" (Green Star, 61, dark red text on Sky
7Blue), "Snow task" (Green Check Box 1, 48, once open and once checked), "Snow circle"
8(Blue Circle 2, 31) and "Snow ink" (no symbol, blue text). Definitions carry the
9NoteTagPropertyStatus bits OneNote's Customize Tags writes: hasLabel, hasFontColor and
10hasHighlightColor where set, hasIcon for a symbol.
11
12`cold/` is a fresh OneNote 2010 read: the four `TagDef` entries keep their type, symbol,
13name and colours, and the tags their completion state. `tools/test_custom_tags.py`
14checks this without a VM. Regenerate with `ONESTORE_CUSTOM_TAGS_EXPORT` set to a new
15absolute directory while running the test, then cold-open it with
16`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`.
17
18`native/shapes.one` is OneNote 2010's own work in a lab clone:
19
20- "Shapes 1-72" and "Shapes 73-143" tag a paragraph with every NoteTagShape, set through
21 COM (its definitions store NoteTagPropertyStatus 0).
22- "Custom tags" holds a tag made with New Tag (Snow check, as above), applied at the top
23 of the list (action type 0), then after moving it below To Do (a second definition,
24 action type 1), with To Do applied from both places. The UI stores status 15 and 9.
25- "Cells" tags a paragraph `cR-C` with the symbol picked from row R, column C of Modify
26 Tag's symbol gallery, which `crates/snowbound/src/tags.rs` lays out.
27
28OneNote keeps the tag list in `%APPDATA%\Microsoft\OneNote\14.0\Preferences.dat`, written
29when it exits; Snowbound keeps its own in its settings.
corpus/custom-tags/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/custom-tags/candidate/Open Notebook.onetoc2 differ
corpus/custom-tags/candidate/custom-tags.one created
Binary files /dev/null and b/corpus/custom-tags/candidate/custom-tags.one differ
corpus/custom-tags/cold/commands.jsonl created+3
......@@ -0,0 +1,3 @@
1{"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}
2{"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}
3{"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}
corpus/custom-tags/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-defae25b", "hostname": "ONE-M6-DEFAE25B"}
corpus/custom-tags/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/custom-tags/cold/notebook/Open Notebook.onetoc2 differ
corpus/custom-tags/cold/notebook/custom-tags.one created
Binary files /dev/null and b/corpus/custom-tags/cold/notebook/custom-tags.one differ
corpus/custom-tags/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-DEFAE25B",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/custom-tags/cold/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{451C75B9-B066-454B-AA65-839980D633FA}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T17:59:43.000Z" color="#B49EDE"><one:Section name="custom-tags" ID="{4453BE32-4DE3-0B80-027A-F5D2AD14B507}{1}{B0}" path="C:\one-tests\runs\capture\notebook\custom-tags.one" lastModifiedTime="2026-09-29T17:59:43.000Z" color="#8AA8E4"><one:Page ID="{93E3B613-F542-0CAA-2043-B7DF4FDCC322}{1}{B0}" name="Custom symbol and colours" dateTime="2026-09-29T17:57:38.000Z" lastModifiedTime="2026-09-29T17:59:43.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/custom-tags/cold/read/page-000.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Custom symbol and colours - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/custom-tags/cold/read/page-000.png created
Binary files /dev/null and b/corpus/custom-tags/cold/read/page-000.png differ
corpus/custom-tags/cold/read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{93E3B613-F542-0CAA-2043-B7DF4FDCC322}{1}{B0}" name="Custom symbol and colours" dateTime="2026-09-29T17:57:38.000Z" lastModifiedTime="2026-09-29T17:59:43.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="61" fontColor="#800000" highlightColor="#00CCFF" name="Snow check"/><one:TagDef index="1" type="1" symbol="48" fontColor="automatic" highlightColor="none" name="Snow task"/><one:TagDef index="2" type="2" symbol="31" fontColor="automatic" highlightColor="none" name="Snow circle"/><one:TagDef index="3" type="3" symbol="0" fontColor="#0000FF" highlightColor="none" name="Snow ink"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{4453BE32-4DE3-0B80-027A-F5D2AD14B507}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="163.1240997314453" height="67.13858032226562"/><one:OEChildren><one:OE creationTime="2026-09-29T17:57:38.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{4453BE32-4DE3-0B80-027A-F5D2AD14B507}{24}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-29T17:57:40.000Z" completionDate="2026-09-29T17:57:40.000Z"/><one:T><![CDATA[Custom symbol and colours]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{26222B6E-6954-0C04-3EB2-D2C7B252F53F}{2}{B0}" alignment="left"><one:Tag index="1" completed="false" disabled="false" creationDate="2026-09-29T17:57:40.000Z"/><one:T><![CDATA[Custom check box]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{26222B6E-6954-0C04-3EB2-D2C7B252F53F}{5}{B0}" alignment="left"><one:Tag index="1" completed="true" disabled="false" creationDate="2026-09-29T17:57:40.000Z" completionDate="2026-09-29T17:57:40.000Z"/><one:T><![CDATA[Custom check box, checked]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{26222B6E-6954-0C04-3EB2-D2C7B252F53F}{7}{B0}" alignment="left"><one:Tag index="2" completed="true" disabled="false" creationDate="2026-09-29T17:57:40.000Z" completionDate="2026-09-29T17:57:40.000Z"/><one:Tag index="3" completed="true" disabled="false" creationDate="2026-09-29T17:57:40.000Z" completionDate="2026-09-29T17:57:40.000Z"/><one:T><![CDATA[Two custom tags]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{26222B6E-6954-0C04-3EB2-D2C7B252F53F}{11}{B0}" alignment="left"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/custom-tags/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/custom-tags/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-custom-tags/candidate",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/custom-tags/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/custom-tags/cold/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/custom-tags/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,
5 "sha256": "2a6009814bda4756e9d474b02433f075adf6071d7cc0650da8502e46519dea1c",
6 "mtime_ns": 1790704660620140274
7 },
8 {
9 "path": "custom-tags.one",
10 "bytes": 8296,
11 "sha256": "91b1b0c54326b6e686b467e74fe51e080b8bd748c4d62f328072c3ace2a3c226",
12 "mtime_ns": 1790704660619982358
13 }
14]
corpus/custom-tags/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/custom-tags/native/shapes.one created
Binary files /dev/null and b/corpus/custom-tags/native/shapes.one differ
corpus/page-background/README.md+12-8
......@@ -2,12 +2,16 @@
22
33`candidate` is `backgrounds_go_on_and_off_pages_that_have_content` in
44`crates/snowbound/src/manage.rs` (`SNOWBOUND_BACKGROUND_EXPORT`), on a copy of
5`rule-lines/native/notebook`: the Page Color menu's ops give the ruled "Standard" page
6Ivy's art (`manage::art_ops`), give "Wide" the art and take it away again, and colour
7"SmallGrid" `0x00c8d8e8`, a COLORREF outside OneNote's menu.
5`rule-lines/native/notebook`: the Page Color menu's steps through the editor
6(`CanvasEditor::set_paper`), each stored as one edit. "Standard" takes Ivy's art; "Wide"
7takes it and has it taken away; "College" takes it and undoes; "MediumGrid" takes it,
8undoes and redoes; "LargeGrid" takes Ivy, then Tulips in its place, and undoes; "SmallGrid"
9is coloured `0x00c8d8e8`, a COLORREF outside OneNote's menu; "VeryLargeGrid" is coloured
10Lemon and takes Sparks' art.
811
9`cold` is its fresh OneNote 2010 read: "Standard" keeps its rule lines under one
10background image (`backgroundImage="true"`), "Wide" keeps its lines and no image, and
11"SmallGrid" reads back `color="#E8D8C8"` and shows it under its grid. `read/page-006.png`
12shows OneNote drawing the art over the rule lines. `tools/test_page_background.py`
13checks this without a VM.
12`cold` is its fresh OneNote 2010 read: every ruled page keeps its lines; "Standard",
13"MediumGrid" and "LargeGrid" show one Ivy background image (`backgroundImage="true"`),
14"Wide" and "College" none; "SmallGrid" reads back `color="#E8D8C8"` under its grid, and
15"VeryLargeGrid" `#FDFDDD` under Sparks. `read/page-003.png` shows OneNote's opaque art
16hiding the rule lines, and `read/page-005.png` its white hiding the Lemon page colour.
17`tools/test_page_background.py` checks this without a VM.
corpus/page-background/candidate/Rules.one
Binary files a/corpus/page-background/candidate/Rules.one and b/corpus/page-background/candidate/Rules.one differ
corpus/page-background/cold/commands.jsonl+1-1
......@@ -1,3 +1,3 @@
11{"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}
2{"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}
2{"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}
33{"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}
corpus/page-background/cold/machine.json+1-1
......@@ -1 +1 @@
1{"name": "m6-5ef70348", "hostname": "ONE-M6-5EF70348"}
1{"name": "m6-0ea92ebc", "hostname": "ONE-M6-0EA92EBC"}
corpus/page-background/cold/read/environment.json+1-1
......@@ -1,7 +1,7 @@
11{
22 "powershell": "5.1.14409.1005",
33 "schema": "xs2010",
4 "hostname": "ONE-M6-5EF70348",
4 "hostname": "ONE-M6-0EA92EBC",
55 "cold": true,
66 "onenote": "14.0.4763.1000"
77}
corpus/page-background/cold/read/hierarchy.xml+1-1
......@@ -1,2 +1,2 @@
11<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{4E9398E9-CFD7-43A8-9875-DD25B12F2FD4}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T08:41:31.000Z" color="#EE9597"><one:Section name="Rules" ID="{8582DE68-7D51-02A8-377A-D6E00C2FCD89}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Rules.one" lastModifiedTime="2026-09-29T08:41:31.000Z" color="#B49EDE"><one:Page ID="{31D0327B-94B7-0B5E-3E7D-671F54D55CEC}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T08:41:31.000Z" pageLevel="1"/><one:Page ID="{6A54865E-CC2F-025D-27BF-0929DFE646F9}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"/><one:Page ID="{37C93113-BD98-0DA1-0616-DB06D76B9DE7}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"/><one:Page ID="{859BC3C1-DA73-0094-187D-081E0BF4B451}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"/><one:Page ID="{5E86B14D-2EA5-0CEE-01DC-77C26A83BFD9}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"/><one:Page ID="{D343E8F3-DEB6-0F1E-1EB1-8B94E7382757}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"/><one:Page ID="{5BFB88BD-0BC5-02DF-1E44-A227794FE0A1}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"/><one:Page ID="{9E24A66D-E1BF-0540-29D9-FD682782DED2}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"/><one:Page ID="{51A21F45-2AB8-0652-01E3-91BE86AD2DC6}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"/><one:Page ID="{B41FFCB9-1864-06FB-3F3A-EA4F5FDC10B9}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"/><one:Page ID="{ED27E4E9-4094-0D2D-34A2-4C38706F92CD}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"/></one:Section></one:Notebook>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{2212F41E-1AE1-4A1B-8CF6-BFC2B788D85F}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T17:14:47.000Z" color="#EE9597"><one:Section name="Rules" ID="{5682FD55-9DBC-0B28-3629-4C30A2B59DA6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Rules.one" lastModifiedTime="2026-09-29T17:14:47.000Z" color="#B49EDE"><one:Page ID="{E2D01146-745A-02DE-3F2E-FDCFFA4F0CC3}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T17:14:47.000Z" pageLevel="1"/><one:Page ID="{B954A563-2CC2-0BDD-26EC-93F9717C16D6}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"/><one:Page ID="{E4C9122E-5D75-0421-0745-41D679F1CDC8}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"/><one:Page ID="{569BE0FC-3A9E-0914-192E-92CEA56EE47E}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"/><one:Page ID="{8D869270-CE48-056E-008F-ED12C419EFF6}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"/><one:Page ID="{0043CBCE-3E5B-069E-1FE2-114449A27778}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"/><one:Page ID="{88FBAB80-EB28-0B5F-1F17-38F7D7D5B08E}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"/><one:Page ID="{4D248550-0152-0CC0-288A-67B889188EFD}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"/><one:Page ID="{82A23C78-CA55-0FD2-00B0-0B6E28377DE9}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"/><one:Page ID="{671FDF84-F889-0F7B-3E69-709FF1464096}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"/><one:Page ID="{3E27C7D4-A079-04AD-35F1-D6E8DEF5C2E2}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/page-background/cold/read/page-000.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "None - Microsoft OneNote",
9 "title": "SmallGrid - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-000.png
Binary files a/corpus/page-background/cold/read/page-000.png and b/corpus/page-background/cold/read/page-000.png differ
corpus/page-background/cold/read/page-000.xml+1-1
......@@ -1,2 +1,2 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{31D0327B-94B7-0B5E-3E7D-671F54D55CEC}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T08:41:31.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{AF1767DC-FDC6-0BBC-0FC1-316E92D0839F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[None]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{0043CBCE-3E5B-069E-1FE2-114449A27778}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#E8D8C8"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="12.0"/><one:Vertical color="#CAEBFD" spacing="12.0"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{9E4CBF4E-A49D-00FD-2FCB-F75A0257886A}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[SmallGrid]]></one:T></one:OE></one:Title></one:Page>
corpus/page-background/cold/read/page-001.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "College - Microsoft OneNote",
9 "title": "Hidden - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-001.png
Binary files a/corpus/page-background/cold/read/page-001.png and b/corpus/page-background/cold/read/page-001.png differ
corpus/page-background/cold/read/page-001.xml+1-1
......@@ -1,2 +1,2 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{37C93113-BD98-0DA1-0616-DB06D76B9DE7}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="23.76000022888184"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:06.000Z" objectID="{FB694B2B-9D3B-0B6E-15D1-EB5B9DE960D8}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[College]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3E27C7D4-A079-04AD-35F1-D6E8DEF5C2E2}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#D4F9F2"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFFFFF" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:05:45.000Z" objectID="{F6B39D95-0451-0BED-1146-B16FB8FB1A09}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Hidden]]></one:T></one:OE></one:Title></one:Page>
corpus/page-background/cold/read/page-002.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "VeryLargeGrid - Microsoft OneNote",
9 "title": "LargeGrid - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-002.png
Binary files a/corpus/page-background/cold/read/page-002.png and b/corpus/page-background/cold/read/page-002.png differ
corpus/page-background/cold/read/page-002.xml+352-1
......@@ -1,2 +1,353 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{51A21F45-2AB8-0652-01E3-91BE86AD2DC6}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="56.69279861450195"/><one:Vertical color="#CAEBFD" spacing="56.69279861450195"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{20F7B97B-36FA-0C22-1185-45868B492B11}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[VeryLargeGrid]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{4D248550-0152-0CC0-288A-67B889188EFD}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="42.51959991455078"/><one:Vertical color="#CAEBFD" spacing="42.51959991455078"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{CA15E073-7FE8-06C1-0EE0-50F6F3E1F940}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[LargeGrid]]></one:T></one:OE></one:Title><one:Image format="jpg" backgroundImage="true" lastModifiedTime="2026-09-29T17:12:33.000Z" objectID="{8A22F7F7-C573-08F0-2797-F47A607B1A73}{5}{B0}"><one:Position x="-27.0" y="-3.599999666213989" z="0"/><one:Size width="174.5" height="640.0" isSetByUser="true"/><one:Data>/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB
3EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL
4EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU
5FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID
6BAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHB
7FVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2
8d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna
94eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1
10EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ0
114SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeI
12iYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery
138/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAOS8f+KbvwbFYamkX2nThL5V3EB8wDfdcH2wRjoc1
145GYYqpg+SqleN7NfkzGtV9jDnauup0el6nbazp8F7aSCW2nUOjjuK9KlVhXgqkHdM1TUldbHK/Cf
15/kW7r/sIXH/odeblf8F/4pfmcGB/hv1Z2leuegFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAB
16QAUAFABQAUAFABQBW1HTrbVrGazu4lntpl2PG3QisqtKFaDpzV0xNKSszmNP0uP4X+ENUZLh7y1t
17vMuIUkGCgxwme/PfjrXlU6X9l4ao+bmSu1/l95lCEcPTstlf/Mz/AIM3Dt4GM03+ta6mZsjHzFuf
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
162Fv2T06s76KQyDlCn1r207npElUAUANeNZFwwyKTVwM+4smiyV+Zf1FYyjYycexWrMgltW2XCfXFV
163HRlR3Nauk2CgAoAKACgAoAKACgAoAKACgAoAKACgAoA848AeGPGdjeLca5rb/Zh1sywmZ/qx+7+F
164fN4DC4+E1OvUaXa97/5fI48PCvHWtO/lb9Tur+2kM0F1AoeaDcPLJxvRsbgD2PAI+mOM5HtVoSco
1651Ybx6d09/ns16W03XfFrVM4vwSJptAeDWdNn1C8F5OyJcW2QoLn+Nht65714OAk5UHCvTcpczteP
166n3em9+pMISS99rd/m7fgbV94h0zwS0H9qyraPfMcPGhMSbQAEyBwAD1wM8njOK9B16WAa9vo59lo
167rW0+X46vS9iatWELczsttSh8I3WXwxcOhDK1/cEMOhG+jK2nQbX8z/M4MD/Dfqztq9g9AKACgAoA
168KAK89mkvI+VvUVm4JkuKZRkgeBskcDuKyaaIs0aoORXSai0AFABQAUAFABQAUAFABQAUAFABQAUA
169FAHK/ETxvB4J0Rpid17NlLaIdS3dvoP8B3rycyxqwdH3fie3+fyMa1eGHjzz+7uZPwl1rW/FFrf6
170vq0pMErrHbRKoVFC53ED6kDPtXJlNXEYnnrVno9F203McLVqVoe0mrX29C94l8fDw/400PRmRTDe
171g+a56qWO2PH4g5+tb4rMPq+Lp0Oj3+eiNZ1lCpCm/tXNHx14Qh8aeH5bCRhHMD5kEpH3HHTPt2P1
172rqx2EWMouns90/MK1KNem6cupzXwZL2HhJLeXAKXMqMB2IbFcGUXhh+V92cWX+7S5X3Z6LXvnqC0
173AFABQAUAFACEZFAABgUALQAUAFABQAUAFABQAUAFABQAUAFABQAUAePz+Bb/AOJ3i+61TVWey0a3
174kNvBCeJXRT2H8OTkknnnpXxywdTNMRKtV92Cdl3svyPOrYSVerzVfhWy7+fz/I9ZsrKDTrSK1tol
175gt4VCJGgwFA7V9dTpxpRUIKyR6CVlZHkfxU0ia6+JvhVkB2ztGgI7FJMt+hr5DNaUpY6lbrb8Ged
176iYOWIoyXd/hqex19kekcR8K4ll8NXQYZ/wCJhcf+h14+WJOg/wDFL8zz8F/CfqztVXaoGc47mvXW
177h6A6mAmecUALQAUAJQAtABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAGb4f/wCQWhP3jJKW/wB7
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</one:Data></one:Image></one:Page>
corpus/page-background/cold/read/page-003.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "MediumGrid - Microsoft OneNote",
9 "title": "Standard - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-003.png
Binary files a/corpus/page-background/cold/read/page-003.png and b/corpus/page-background/cold/read/page-003.png differ
corpus/page-background/cold/read/page-003.xml+352-1
......@@ -1,2 +1,353 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5BFB88BD-0BC5-02DF-1E44-A227794FE0A1}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#E0C9FF" spacing="28.34639930725098"/><one:Vertical color="#E0C9FF" spacing="28.34639930725098"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{2E8AFDA7-8F2C-01A8-3BE9-691F5CC5678B}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[MediumGrid]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{569BE0FC-3A9E-0914-192E-92CEA56EE47E}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:19.000Z" objectID="{0F8A0709-8078-09B4-029D-DBEEB7DCBF25}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Standard]]></one:T></one:OE></one:Title><one:Image format="jpg" backgroundImage="true" lastModifiedTime="2026-09-29T17:12:32.000Z" objectID="{8A22F7F7-C573-08F0-2797-F47A607B1A73}{1}{B0}"><one:Position x="-27.0" y="-3.599999666213989" z="0"/><one:Size width="174.5" height="640.0" isSetByUser="true"/><one:Data>/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB
3EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL
4EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU
5FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID
6BAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHB
7FVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2
8d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna
94eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1
10EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ0
114SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeI
12iYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery
138/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAOS8f+KbvwbFYamkX2nThL5V3EB8wDfdcH2wRjoc1
145GYYqpg+SqleN7NfkzGtV9jDnauup0el6nbazp8F7aSCW2nUOjjuK9KlVhXgqkHdM1TUldbHK/Cf
15/kW7r/sIXH/odeblf8F/4pfmcGB/hv1Z2leuegFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAB
16QAUAFABQAUAFABQBW1HTrbVrGazu4lntpl2PG3QisqtKFaDpzV0xNKSszmNP0uP4X+ENUZLh7y1t
17vMuIUkGCgxwme/PfjrXlU6X9l4ao+bmSu1/l95lCEcPTstlf/Mz/AIM3Dt4GM03+ta6mZsjHzFuf
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
162Fv2T06s76KQyDlCn1r207npElUAUANeNZFwwyKTVwM+4smiyV+Zf1FYyjYycexWrMgltW2XCfXFV
163HRlR3Nauk2CgAoAKACgAoAKACgAoAKACgAoAKACgAoA848AeGPGdjeLca5rb/Zh1sywmZ/qx+7+F
164fN4DC4+E1OvUaXa97/5fI48PCvHWtO/lb9Tur+2kM0F1AoeaDcPLJxvRsbgD2PAI+mOM5HtVoSco
1651Ybx6d09/ns16W03XfFrVM4vwSJptAeDWdNn1C8F5OyJcW2QoLn+Nht65714OAk5UHCvTcpczteP
166n3em9+pMISS99rd/m7fgbV94h0zwS0H9qyraPfMcPGhMSbQAEyBwAD1wM8njOK9B16WAa9vo59lo
167rW0+X46vS9iatWELczsttSh8I3WXwxcOhDK1/cEMOhG+jK2nQbX8z/M4MD/Dfqztq9g9AKACgAoA
168KAK89mkvI+VvUVm4JkuKZRkgeBskcDuKyaaIs0aoORXSai0AFABQAUAFABQAUAFABQAUAFABQAUA
169FAHK/ETxvB4J0Rpid17NlLaIdS3dvoP8B3rycyxqwdH3fie3+fyMa1eGHjzz+7uZPwl1rW/FFrf6
170vq0pMErrHbRKoVFC53ED6kDPtXJlNXEYnnrVno9F203McLVqVoe0mrX29C94l8fDw/400PRmRTDe
171g+a56qWO2PH4g5+tb4rMPq+Lp0Oj3+eiNZ1lCpCm/tXNHx14Qh8aeH5bCRhHMD5kEpH3HHTPt2P1
172rqx2EWMouns90/MK1KNem6cupzXwZL2HhJLeXAKXMqMB2IbFcGUXhh+V92cWX+7S5X3Z6LXvnqC0
173AFABQAUAFACEZFAABgUALQAUAFABQAUAFABQAUAFABQAUAFABQAUAePz+Bb/AOJ3i+61TVWey0a3
174kNvBCeJXRT2H8OTkknnnpXxywdTNMRKtV92Cdl3svyPOrYSVerzVfhWy7+fz/I9ZsrKDTrSK1tol
175gt4VCJGgwFA7V9dTpxpRUIKyR6CVlZHkfxU0ia6+JvhVkB2ztGgI7FJMt+hr5DNaUpY6lbrb8Ged
176iYOWIoyXd/hqex19kekcR8K4ll8NXQYZ/wCJhcf+h14+WJOg/wDFL8zz8F/CfqztVXaoGc47mvXW
177h6A6mAmecUALQAUAJQAtABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAGb4f/wCQWhP3jJKW/wB7
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</one:Data></one:Image></one:Page>
corpus/page-background/cold/read/page-004.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "Wide - Microsoft OneNote",
9 "title": "ColorRed - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-004.png
Binary files a/corpus/page-background/cold/read/page-004.png and b/corpus/page-background/cold/read/page-004.png differ
corpus/page-background/cold/read/page-004.xml+1-1
......@@ -1,2 +1,2 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5E86B14D-2EA5-0CEE-01DC-77C26A83BFD9}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="46.79999923706054"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:24.000Z" objectID="{CECBC798-72F2-0899-1721-97EFC8C976CA}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Wide]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{671FDF84-F889-0F7B-3E69-709FF1464096}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFD4D6" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{DB34D943-3109-03F2-203F-6BEA9A7CE879}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[ColorRed]]></one:T></one:OE></one:Title></one:Page>
corpus/page-background/cold/read/page-005.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "Narrow - Microsoft OneNote",
9 "title": "VeryLargeGrid - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-005.png
Binary files a/corpus/page-background/cold/read/page-005.png and b/corpus/page-background/cold/read/page-005.png differ
corpus/page-background/cold/read/page-005.xml+769-1
......@@ -1,2 +1,770 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{6A54865E-CC2F-025D-27BF-0929DFE646F9}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:54:02.000Z" objectID="{606364C5-0652-06ED-22E6-A58C8D44710B}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Narrow]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{82A23C78-CA55-0FD2-00B0-0B6E28377DE9}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#FDFDDD"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="56.69279861450195"/><one:Vertical color="#CAEBFD" spacing="56.69279861450195"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{F3F79A46-D617-05A2-10D6-DF5625D37B3E}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[VeryLargeGrid]]></one:T></one:OE></one:Title><one:Image format="jpg" backgroundImage="true" lastModifiedTime="2026-09-29T17:12:33.000Z" objectID="{8A22F7F7-C573-08F0-2797-F47A607B1A73}{7}{B0}"><one:Position x="-18.0" y="-3.599999666213989" z="0"/><one:Size width="611.4000244140625" height="791.2999877929687" isSetByUser="true"/><one:Data>/9j/4AAQSkZJRgABAgAAAQABAAD//gAbU25vd2JvdW5kIHRlbXBsYXRlIHNwYXJrc//AABEIAxgC
3ZAMBEQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4R
4DgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQU
5FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAA
6AQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgj
7QrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpz
8dHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX
92Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/
10xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEK
11FiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SF
12hoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo
136ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAPLNc/wCSlTf8B/8ARIr8OzL/AJKqfy/9No+4
14w3/IqX9faNivpjzAoAKpAOXpTAWmgCqAVaCWLTQBTAKCWOXpVIQo60wH0AFUgLEC/aIjH/GnzL7j
15uK66cfawcOq1X6oyk+R36EaJv3ewzWUI81/ItuxJbw+YxZv9Wgyx/pWlKnzu8tluTOXLot2Ru5kc
16se/as5S5m2xpcqsC9KSAWmAUCYVSEOHSqQDl61SAWqJY5asQo600QPq0Aq9asTFqkIeOlUQKtUA8
17DjNaWExaaJH1YCjrTQMdVEjh0q0Sxy1QhR1qgH1SIHDpVoTFXrViHVSAfTICrAKACgAoAKACgAoA
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
498yIF6qDxn+g/+tSzbGvD01RpfxJ6Ly8/8v+AGDoKpLnn8MTz+XTxpXjgWoOfLVcn1JhBJ/MmvyGth
4991hOI/Yrol9/s03+J9dCp7bLufv8A/JHU20ImmAY4QcsfQDrX3NGmqk7Pbr6HhzlyrQkmvDNeLL91
500UI2r6AVtUr+0rKeyVreSREafLBxCG8MF20n3lYncvqDTp1/Z1nPo9/RhKnzQ5RtxCIZSFOUPKn1H
501asq0FTnZbdPQuEuZakdZIsKoBVoJYtNAFMAxTJY5elNCFHWmA+gAqkAAc0wHU0AUwCmJjl6U0SWL
502NczZ9BXXhledzKq/dI523TP9ayqu82OKtFDKhFDh0qkA5etUgHBcqT6da0tpchsVaYCjrTRA+rQC
503r1qxMdjAzVpEjh0pkirVAOHWrEx1UiR9WAo600DHVRI4dKtEsctUIUdaoB9UiBw6VaExV61Yh1Ug
504H0yAqwCgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgDx+XV
50555bG1tmOVtnZkbPrjj8MH86/Hp4ypKlTov7DbX4H2aoxU5TXU1dO16B9bm1bUSzOv+phQZ56D2wB
506/OvVw2PpSxcsbitWtkv66L/M5KuHkqKoUvm/67mTd3b6v42acxvamXB255A8rjt3A/WvjcRWeL4j
507dVpxvbT/ALh/qj16cFRy7lve3/yR1EZEMHlpxu6mvt1Lkhyx67niNXldjazGFUgJd+6IKf4en09K
5082crx5X02JtZ3G1migqgFWgli00AUwHRsFcZGV7j2q4NReuxElcklhMDleo6g+o9a0qU3TlYmMuZX
509GDrWZQ+gB8MRmk29B1J9B61tSh7R2JlLlVxGO58gYXsPQUSfM7rYaVgqUMKYBTExy9KaJLdmNqO9
510ehhlZORz1dWkVScmuF6m7CmhDh0qkA5etUgJImCOM8r0I9q2g+V67ESV0Oki8mQjqOoPqKucOSVi
511Yy5kNHWpQh9WA+GPzHx09T6VrCPM7EydkK7bm46dh7U27slKwo6UCFWqAcOtWJjqpEj6sBR1poGO
512qiRw6VaJY5aoQo61QD6pEDh0q0Jir1qxDqpAPpkBVgFABQAUAFABQAUAFABQAUAFABQAUAFABQAU
513AFABQAUAFABQAUAFABQAUAFABQAUAFAHiNfh592WLDzftcXkbPNz8vmbduf+BcfnXRh+f2sfZ25u
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
722600DHVRI4dKtEsctUIUdaoB9UiBw6VaExV61Yh1UgH0yAqwCgAoAKACgAoAKACgAoAKACgAoAKAC
723gAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAPLNc/5KVN/wH/0SK/Dsy/5Kqfy/9No+4w3/
724ACKl/X2jYr6Y8wKACqQDl6UwFpoAqgFWgli00AUwCgllmwTfOP8AZ5rswseap6GNR2iF2++4b24p
725YiXNUY4K0Rlc5oFUgAdaYDqaAKYBTExy9KaJFpgFAmFUhDh0qkA5etUgFqiWOWrEKOtNED6tAKvW
726rExapCHjpVECrVAOHWrEx1UiR9WAo600DHVRI4dKtEsctUIUdaoB9UiBw6VaExV61Yh1UgH0yAqw
727CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAPLNc/5
728KVN/wH/0SK/Dsy/5Kqfy/wDTaPuMN/yKl/X2jYr6Y8wKACqQDl6UwFpoAqgFWgli00AUwCgll+wH
729lwSSH/OK9TCrkhKbOapq0ipnJrz27nQOpAFUgAdaYDqaAKYBTExy9KaJFpgFAmFUhDh0qkA5etUg
730FqiWOWrEKOtNED6tAKvWrExapCHjpVECrVAOHWrEx1UiR9WAo600DHVRI4dKtEsctUIUdaoB9UiB
731w6VaExV61Yh1UgH0yAqwCgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA
732CgAoAKACgAoAKAPMNbiz8Rpm/wB3/wBEivxXMYX4om/T/wBNo+1w0v8AhMS/r4jW2V9Lynn3DZRy
733hcNlHKK44JT5RXF2U1ELhsp8oXFCU+Um4uymohcXZT5QuGynyk3L8q+TZKnc/wD669KceSgo9zni
734+abZSCc15/KdFx+360cow2/WnyiuAXmnyhcXbTURXF20+ULgEp8omxQtNRFcXbT5QuLsp8ohdlPl
735EKFqlELjgtVYm4u2qsJscFq0hXF24ppCHbeKuwriheaqwmxwWqSJuOA4qrCHBaqwrihauwh+M00h
736C4zV2AcBVWFcdinYkcBVpEiqKpAOFUJjsVSRI4dKtEsVetUIdVIB9MkKsAoAKACgAoAKACgAoAKA
737CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgDzrV48+PZW+n/AKKFfkeOhfiK
738T9P/AE2j67Dy/wCE5L+viNTZX0HKcFw2UcoXDZT5QuKEo5RXDbT5QuLsp8oXDZRyiuLsp8ori7Kp
739RC4+KLfIo9TVwhzSSJlKyuWL7l1X0FdeJ1aRlT2uVgnNcfKbXHbKOULi7KfKFw2U+UVxdtPlC4u2
740nyhcNvtTsK4u2iwrhtqrBcXbRYVxdlOwrihapILihaqwhwWnYVxdtVYVx22qSFcULVWExQtVYQ7F
741VYVx2KqwriqKdhMUDmqsK44CqsIcBVWBigVSRI7FUhC4ppAOWqExQKoQ6qRI4dKoQqjmqELTQD6o
742kKoAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgDhN
743Uiz40lb6f+ixX5jjIXz2T9P/AEg+nov/AGFL+tzR8uvb5TiDy6OUA8unygL5dPlC4eXRyiuL5ftT
7445QuAj9qfKFxfL+lHKK4bKfKFx8eY2DDqKuN4u6IeugSAyOWPU0SvJ3YLRWEEdLlHcdso5QuGynyh
745cXZT5QuAWjlFcXbT5QuLt9qfKFw20+UVxdtHKFw207BcXbTsK4u2mkFxQtOwrihaqwrjttVYVxdv
746NOwriheKqwrihcGqsIXbTsA7FVYm4oFOwC4qrCHEU7CuKBVWAUDmqsIdinYBcUxCgVQhcU7CHVZI
747o6UwFHWmIdVIB1MkKoAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo
748AKACgAoAKACgDj9Qjz4rkP0/9Fivz/FQvnEn/Xwn0FKX+xpf1uXtletynLcNlHKFw2U+ULi7KOUV
749w2UcoXF2VXKFw2Ucori7afKFw20coXDbT5RXF2/WjlC4BKfKFxdlFhXDb9KdguAWnYVxdv1o5QuG
750360+ULihKOULi7fpT5RBt+lPlC4BKOUVxdlHKFxdtVyhcULxTsFxdtOwri7aqwhdtFhXFC1VguKF
7515p2FcXFVYQuKdhXFC07BcXGKqwhQKdgHAU7AAHNMQ7FOwrigVQhQKdhMXFMQuKoBR0pgKKYhaYh1
752MQVQBQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAHM
7533sWfELt9P/QK+NxEL5k3/Xwns05f7Ml/W5b2V6PKc9w2UcoXDZT5QuAjo5RXF8unyhcPLo5QuLso
7545RXDZT5QuGyjlC4uynyhcNlPlC4BKOUVx2yjlHcNvtT5RXF20coXDbRyhcNtOwXF20WC4badhXDb
7559aLALtp2ANtFhXDbTsFxdtOwXALTsK4u2nYVxdtOwrihadguLinYQbadgHAU7CALTsAoHNOwXFxT
756sJCgU7BcUCnYQYp2FcXFMQop2AXGKYC0wFFMQopgFMQ6gQVQBQAUAFABQAUAFABQAUAFABQAUAFA
757BQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAGJcxZ1dm+n/oNfOVoXxrl/Wx6UH+5t/W5Y
7582V2cpjcNlHKFw2UcoXDy6OULi+XT5QuHl0coXDZRyiuGyjlC4eXT5QuLso5RXDZRyhcNlPlC4uyj
759lC4bKOULgEp8oXF2UcoXDZRyhcAlHKK4uynyhcNlHKFw2U+ULhso5QuG2nyiuKFosABadgF207CF
76020WC4BadguLtp2FcMUWC4u2nYVxQKdguGKdhXFxRYLgBTsIXFOwC4p2AMUwFAoAMUWAXFVYQoFAA
761BzQIWmIWmAUwCgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAC
762gAoAKAM+WLN8W/z0rypwviL/ANbHXGX7uxLsrflM7hso5QuGyjlC4bKOULhso5QuGyjlC4uynyiu
763GyjlC4bKOULhso5QuGyjlC4bKOULi7KfKFw2UcoXDZRyhcNlHKFw2U+ULhsosK4uyjlC4bKOULhs
764o5QuGynyhcTbRyhcXbRYVw20+ULhtp8oXF20WC4badhXF280WC4YosAbadgFxRYAxTsAu2gQYosM
765MU7CDFFgFxTsAAUAAFAC4oEFMBaBBTAWgApgFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQA
766UAFABQAUAFABQAUAFABQAUAFABQAUARGLMm6sHC87l82lh2yr5RXDZRyhcNlHKFw2UcoXDZRyhcN
767lHKFw2UcoXDZRyhcNlHKFw2UcoXDZRyhcNlHKFw2UcoXDZRyhcNlFguLt+lFguG36UWC4badhXDZ
768SsFw2U7BcNtFguG2jlC4myiw7htosFw20WC4baLBcAtFguLt4osK4m2iwXF207BcNtFguG2kFwIw
769KYXAUWC4YosFwxRYLi4osFwxRYQYp2AMUWAKACgAoAKYC0AFABQAUAFABQAUAFABQAUAFABQAUAF
770ABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQB//9k=</one:Data></one:Image></one:Page>
corpus/page-background/cold/read/page-006.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "Standard - Microsoft OneNote",
9 "title": "MediumGrid - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-006.png
Binary files a/corpus/page-background/cold/read/page-006.png and b/corpus/page-background/cold/read/page-006.png differ
corpus/page-background/cold/read/page-006.xml+1-1
......@@ -1,5 +1,5 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{859BC3C1-DA73-0094-187D-081E0BF4B451}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:19.000Z" objectID="{DC8A2434-6095-0034-03CE-413E1946EF0A}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Standard]]></one:T></one:OE></one:Title><one:Image format="jpg" backgroundImage="true" lastModifiedTime="2026-09-29T08:39:38.000Z" objectID="{48C362B0-6114-0C90-07D0-9F6A4AEBE04B}{1}{B0}"><one:Position x="-27.0" y="-3.599999666213989" z="0"/><one:Size width="174.5" height="640.0" isSetByUser="true"/><one:Data>/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{88FBAB80-EB28-0B5F-1F17-38F7D7D5B08E}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#E0C9FF" spacing="28.34639930725098"/><one:Vertical color="#E0C9FF" spacing="28.34639930725098"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{FD8ADE9A-6FC1-0828-3ABA-F3CFF25F37A4}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[MediumGrid]]></one:T></one:OE></one:Title><one:Image format="jpg" backgroundImage="true" lastModifiedTime="2026-09-29T17:12:33.000Z" objectID="{8A22F7F7-C573-08F0-2797-F47A607B1A73}{4}{B0}"><one:Position x="-27.0" y="-3.599999666213989" z="0"/><one:Size width="174.5" height="640.0" isSetByUser="true"/><one:Data>/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB
33EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL
44EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU
55FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID
corpus/page-background/cold/read/page-007.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "LargeGrid - Microsoft OneNote",
9 "title": "Wide - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-007.png
Binary files a/corpus/page-background/cold/read/page-007.png and b/corpus/page-background/cold/read/page-007.png differ
corpus/page-background/cold/read/page-007.xml+1-1
......@@ -1,2 +1,2 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9E24A66D-E1BF-0540-29D9-FD682782DED2}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="42.51959991455078"/><one:Vertical color="#CAEBFD" spacing="42.51959991455078"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{1915C34E-9F05-0F41-0FB3-CA265D7BA96F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[LargeGrid]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8D869270-CE48-056E-008F-ED12C419EFF6}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="46.79999923706054"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:24.000Z" objectID="{1DCBE4A5-921F-0119-1672-0D3F665326E5}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Wide]]></one:T></one:OE></one:Title></one:Page>
corpus/page-background/cold/read/page-008.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "ColorRed - Microsoft OneNote",
9 "title": "Narrow - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-008.png
Binary files a/corpus/page-background/cold/read/page-008.png and b/corpus/page-background/cold/read/page-008.png differ
corpus/page-background/cold/read/page-008.xml+1-1
......@@ -1,2 +1,2 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B41FFCB9-1864-06FB-3F3A-EA4F5FDC10B9}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFD4D6" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{0834FA7E-D1E4-0A72-216C-F13A34E6B856}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[ColorRed]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B954A563-2CC2-0BDD-26EC-93F9717C16D6}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:54:02.000Z" objectID="{B36347F8-E6BF-0F6D-23B5-3F5C23DE2124}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Narrow]]></one:T></one:OE></one:Title></one:Page>
corpus/page-background/cold/read/page-009.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "SmallGrid - Microsoft OneNote",
9 "title": "None - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-009.png
Binary files a/corpus/page-background/cold/read/page-009.png and b/corpus/page-background/cold/read/page-009.png differ
corpus/page-background/cold/read/page-009.xml+1-1
......@@ -1,2 +1,2 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D343E8F3-DEB6-0F1E-1EB1-8B94E7382757}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#E8D8C8"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="12.0"/><one:Vertical color="#CAEBFD" spacing="12.0"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{4D4C9C73-4470-097D-2E98-6D8AACCDD845}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[SmallGrid]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E2D01146-745A-02DE-3F2E-FDCFFA4F0CC3}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T17:14:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{7C1744E1-1D2B-023C-0E92-ABBE3C4AD3B0}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[None]]></one:T></one:OE></one:Title></one:Page>
corpus/page-background/cold/read/page-010.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "Hidden - Microsoft OneNote",
9 "title": "College - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
corpus/page-background/cold/read/page-010.png
Binary files a/corpus/page-background/cold/read/page-010.png and b/corpus/page-background/cold/read/page-010.png differ
corpus/page-background/cold/read/page-010.xml+1-1
......@@ -1,2 +1,2 @@
11<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{ED27E4E9-4094-0D2D-34A2-4C38706F92CD}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#D4F9F2"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFFFFF" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:05:45.000Z" objectID="{25B3BEA8-E4BC-026D-1015-2BBF16614A26}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Hidden]]></one:T></one:OE></one:Title></one:Page>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E4C9122E-5D75-0421-0745-41D679F1CDC8}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="23.76000022888184"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:06.000Z" objectID="{28696816-7DD6-02EE-1482-718B337330F7}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[College]]></one:T></one:OE></one:Title></one:Page>
corpus/page-background/cold/run.json+1-1
......@@ -1,5 +1,5 @@
11{
2 "notebook": "/private/tmp/snowbound-page-background/candidate",
2 "notebook": "/private/tmp/snowbound-art-undo/candidate",
33 "expected_pages": 11,
44 "author": null,
55 "author_timeout_seconds": 600,
corpus/page-background/cold/source.json+3-3
......@@ -7,8 +7,8 @@
77 },
88 {
99 "path": "Rules.one",
10 "bytes": 132704,
11 "sha256": "a513e56274ae794cf558da34dca7189225159d7eb6ab7d2256531fd7a2a55b44",
12 "mtime_ns": 1790671178905073494
10 "bytes": 324016,
11 "sha256": "53c49a8bd35b4aee2ec840ef79fd7bdb6e6b4bc3cb3885d56cccbac145389a8b",
12 "mtime_ns": 1790701954004992686
1313 }
1414]
crates/canvas/assets/file.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#eef1f5"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#f4f6f9"/><stop offset="1" stop-color="#d3dae3"/></linearGradient>
5 </defs>
6 <path d="M3.5 2.25L10 2.25L13.5 5.75L13.5 15.25L3.5 15.25Z" fill="#000000" fill-opacity="0.12"/>
7 <path d="M3.5 1.5L10 1.5L13.5 5L13.5 14.5L3.5 14.5Z" fill="url(#g0)" stroke="#a3acb8" stroke-width="0.8" stroke-linejoin="round"/>
8 <path d="M10 1.5L10 5L13.5 5Z" fill="url(#g1)" stroke="#a3acb8" stroke-width="0.8" stroke-linejoin="round"/>
9</svg>
crates/canvas/assets/tags/arrow-down-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8eaee9"/><stop offset="1" stop-color="#4b78c9"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
5 </defs>
6 <path d="M8 15.2L1.4 8.4L5.4 8.4L5.4 0.8L10.6 0.8L10.6 8.4L14.6 8.4Z" fill="#3d62ad"/>
7 <path d="M8 13.8L3.1 8.9L6 8.9L6 1.5L10 1.5L10 8.9L12.9 8.9Z" fill="url(#g0)"/>
8 <path d="M8 13.8L3.1 8.9L6 8.9L6 1.5L10 1.5L10 8.9L12.9 8.9Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-down-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a4da8c"/><stop offset="1" stop-color="#5aa83c"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
5 </defs>
6 <path d="M8 15.2L1.4 8.4L5.4 8.4L5.4 0.8L10.6 0.8L10.6 8.4L14.6 8.4Z" fill="#3f8a2c"/>
7 <path d="M8 13.8L3.1 8.9L6 8.9L6 1.5L10 1.5L10 8.9L12.9 8.9Z" fill="url(#g0)"/>
8 <path d="M8 13.8L3.1 8.9L6 8.9L6 1.5L10 1.5L10 8.9L12.9 8.9Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-down-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f7d66e"/><stop offset="1" stop-color="#e1a81f"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>
5 </defs>
6 <path d="M8 15.2L1.4 8.4L5.4 8.4L5.4 0.8L10.6 0.8L10.6 8.4L14.6 8.4Z" fill="#b0801a"/>
7 <path d="M8 13.8L3.1 8.9L6 8.9L6 1.5L10 1.5L10 8.9L12.9 8.9Z" fill="url(#g0)"/>
8 <path d="M8 13.8L3.1 8.9L6 8.9L6 1.5L10 1.5L10 8.9L12.9 8.9Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-left-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8eaee9"/><stop offset="1" stop-color="#4b78c9"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
5 </defs>
6 <path d="M0.8 8L7.6 1.4L7.6 5.4L15.2 5.4L15.2 10.6L7.6 10.6L7.6 14.6Z" fill="#3d62ad"/>
7 <path d="M2.2 8L7.1 3.1L7.1 6L14.5 6L14.5 10L7.1 10L7.1 12.9Z" fill="url(#g0)"/>
8 <path d="M2.2 8L7.1 3.1L7.1 6L14.5 6L14.5 10L7.1 10L7.1 12.9Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-left-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a4da8c"/><stop offset="1" stop-color="#5aa83c"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
5 </defs>
6 <path d="M0.8 8L7.6 1.4L7.6 5.4L15.2 5.4L15.2 10.6L7.6 10.6L7.6 14.6Z" fill="#3f8a2c"/>
7 <path d="M2.2 8L7.1 3.1L7.1 6L14.5 6L14.5 10L7.1 10L7.1 12.9Z" fill="url(#g0)"/>
8 <path d="M2.2 8L7.1 3.1L7.1 6L14.5 6L14.5 10L7.1 10L7.1 12.9Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-left-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f7d66e"/><stop offset="1" stop-color="#e1a81f"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>
5 </defs>
6 <path d="M0.8 8L7.6 1.4L7.6 5.4L15.2 5.4L15.2 10.6L7.6 10.6L7.6 14.6Z" fill="#b0801a"/>
7 <path d="M2.2 8L7.1 3.1L7.1 6L14.5 6L14.5 10L7.1 10L7.1 12.9Z" fill="url(#g0)"/>
8 <path d="M2.2 8L7.1 3.1L7.1 6L14.5 6L14.5 10L7.1 10L7.1 12.9Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-right-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8eaee9"/><stop offset="1" stop-color="#4b78c9"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
5 </defs>
6 <path d="M15.2 8L8.4 14.6L8.4 10.6L0.8 10.6L0.8 5.4L8.4 5.4L8.4 1.4Z" fill="#3d62ad"/>
7 <path d="M13.8 8L8.9 12.9L8.9 10L1.5 10L1.5 6L8.9 6L8.9 3.1Z" fill="url(#g0)"/>
8 <path d="M13.8 8L8.9 12.9L8.9 10L1.5 10L1.5 6L8.9 6L8.9 3.1Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-right-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a4da8c"/><stop offset="1" stop-color="#5aa83c"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
5 </defs>
6 <path d="M15.2 8L8.4 14.6L8.4 10.6L0.8 10.6L0.8 5.4L8.4 5.4L8.4 1.4Z" fill="#3f8a2c"/>
7 <path d="M13.8 8L8.9 12.9L8.9 10L1.5 10L1.5 6L8.9 6L8.9 3.1Z" fill="url(#g0)"/>
8 <path d="M13.8 8L8.9 12.9L8.9 10L1.5 10L1.5 6L8.9 6L8.9 3.1Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-right-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f7d66e"/><stop offset="1" stop-color="#e1a81f"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>
5 </defs>
6 <path d="M15.2 8L8.4 14.6L8.4 10.6L0.8 10.6L0.8 5.4L8.4 5.4L8.4 1.4Z" fill="#b0801a"/>
7 <path d="M13.8 8L8.9 12.9L8.9 10L1.5 10L1.5 6L8.9 6L8.9 3.1Z" fill="url(#g0)"/>
8 <path d="M13.8 8L8.9 12.9L8.9 10L1.5 10L1.5 6L8.9 6L8.9 3.1Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-up-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8eaee9"/><stop offset="1" stop-color="#4b78c9"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
5 </defs>
6 <path d="M8 0.8L14.6 7.6L10.6 7.6L10.6 15.2L5.4 15.2L5.4 7.6L1.4 7.6Z" fill="#3d62ad"/>
7 <path d="M8 2.2L12.9 7.1L10 7.1L10 14.5L6 14.5L6 7.1L3.1 7.1Z" fill="url(#g0)"/>
8 <path d="M8 2.2L12.9 7.1L10 7.1L10 14.5L6 14.5L6 7.1L3.1 7.1Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-up-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a4da8c"/><stop offset="1" stop-color="#5aa83c"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
5 </defs>
6 <path d="M8 0.8L14.6 7.6L10.6 7.6L10.6 15.2L5.4 15.2L5.4 7.6L1.4 7.6Z" fill="#3f8a2c"/>
7 <path d="M8 2.2L12.9 7.1L10 7.1L10 14.5L6 14.5L6 7.1L3.1 7.1Z" fill="url(#g0)"/>
8 <path d="M8 2.2L12.9 7.1L10 7.1L10 14.5L6 14.5L6 7.1L3.1 7.1Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/arrow-up-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f7d66e"/><stop offset="1" stop-color="#e1a81f"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>
5 </defs>
6 <path d="M8 0.8L14.6 7.6L10.6 7.6L10.6 15.2L5.4 15.2L5.4 7.6L1.4 7.6Z" fill="#b0801a"/>
7 <path d="M8 2.2L12.9 7.1L10 7.1L10 14.5L6 14.5L6 7.1L3.1 7.1Z" fill="url(#g0)"/>
8 <path d="M8 2.2L12.9 7.1L10 7.1L10 14.5L6 14.5L6 7.1L3.1 7.1Z" fill="url(#g1)" fill-opacity="0.35"/>
9</svg>
crates/canvas/assets/tags/bell.svg created+15
......@@ -0,0 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2.4" x2="0" y2="12.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff0a0"/><stop offset="1" stop-color="#d8a010"/></linearGradient>
4 </defs>
5 <path d="M1 2.5L4 5" fill="none" stroke="#f0c020" stroke-width="0.7"/>
6 <path d="M15 2.5L12 5" fill="none" stroke="#f0c020" stroke-width="0.7"/>
7 <path d="M0.6 10L3.4 10" fill="none" stroke="#f0c020" stroke-width="0.7"/>
8 <path d="M15.4 10L12.6 10" fill="none" stroke="#f0c020" stroke-width="0.7"/>
9 <path d="M2 15L4.4 13" fill="none" stroke="#f0c020" stroke-width="0.7"/>
10 <path d="M14 15L11.6 13" fill="none" stroke="#f0c020" stroke-width="0.7"/>
11 <path d="M8 2.4C10.4 2.4 11.2 4.4 11.4 6.8C11.6 9.2 12.4 10.4 13.6 11.2V12.4H2.4V11.2C3.6 10.4 4.4 9.2 4.6 6.8C4.8 4.4 5.6 2.4 8 2.4Z" fill="url(#g0)"/>
12 <path d="M8 2.4C10.4 2.4 11.2 4.4 11.4 6.8C11.6 9.2 12.4 10.4 13.6 11.2V12.4H2.4V11.2C3.6 10.4 4.4 9.2 4.6 6.8C4.8 4.4 5.6 2.4 8 2.4Z" fill="none" stroke="#a87810" stroke-width="0.5"/>
13 <path d="M6.8 13.4A1.2 1.2 0 1 1 9.2 13.4A1.2 1.2 0 1 1 6.8 13.4Z" fill="#a87810"/>
14 <path d="M7.2 1.8A0.8 0.8 0 1 1 8.8 1.8A0.8 0.8 0 1 1 7.2 1.8Z" fill="#a87810"/>
15</svg>
crates/canvas/assets/tags/binoculars.svg created+14
......@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="5.6" x2="0" y2="14.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d4dcf0"/><stop offset="1" stop-color="#6a78b0"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1.4" x2="0" y2="6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e4eaf8"/><stop offset="1" stop-color="#7a88bc"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="6.2" x2="0" y2="10" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b4c0e0"/><stop offset="1" stop-color="#6a78b0"/></linearGradient>
6 </defs>
7 <path d="M2.2 5.6H5.8A1.2 1.2 0 0 1 7 6.8V13.4A1.2 1.2 0 0 1 5.8 14.6H2.2A1.2 1.2 0 0 1 1 13.4V6.8A1.2 1.2 0 0 1 2.2 5.6Z" fill="url(#g0)"/>
8 <path d="M2.8 1.4H5.2A0.8 0.8 0 0 1 6 2.2V5.2A0.8 0.8 0 0 1 5.2 6H2.8A0.8 0.8 0 0 1 2 5.2V2.2A0.8 0.8 0 0 1 2.8 1.4Z" fill="url(#g1)"/>
9 <path d="M1.6 12.4H6.4V14H1.6Z" fill="#2a3470" fill-opacity="0.6"/>
10 <path d="M10.2 5.6H13.8A1.2 1.2 0 0 1 15 6.8V13.4A1.2 1.2 0 0 1 13.8 14.6H10.2A1.2 1.2 0 0 1 9 13.4V6.8A1.2 1.2 0 0 1 10.2 5.6Z" fill="url(#g0)"/>
11 <path d="M10.8 1.4H13.2A0.8 0.8 0 0 1 14 2.2V5.2A0.8 0.8 0 0 1 13.2 6H10.8A0.8 0.8 0 0 1 10 5.2V2.2A0.8 0.8 0 0 1 10.8 1.4Z" fill="url(#g1)"/>
12 <path d="M9.6 12.4H14.4V14H9.6Z" fill="#2a3470" fill-opacity="0.6"/>
13 <path d="M6.6 6.2H9.4V10H6.6Z" fill="url(#g2)"/>
14</svg>
crates/canvas/assets/tags/calendar.svg created+25
......@@ -0,0 +1,25 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.5" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e4eaf4"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1.5" x2="0" y2="4.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8fb1ec"/><stop offset="1" stop-color="#4d78c8"/></linearGradient>
5 </defs>
6 <path d="M1 1.5H15V15H1Z" fill="#7d8fb0"/>
7 <path d="M1.6 2.1H14.4V14.4H1.6Z" fill="url(#g0)"/>
8 <path d="M1.6 2.1H14.4V4.5H1.6Z" fill="url(#g1)"/>
9 <path d="M2.4 0.9V2.7" fill="none" stroke="#6c7890" stroke-width="0.7"/>
10 <path d="M4.64 0.9V2.7" fill="none" stroke="#6c7890" stroke-width="0.7"/>
11 <path d="M6.88 0.9V2.7" fill="none" stroke="#6c7890" stroke-width="0.7"/>
12 <path d="M9.12 0.9V2.7" fill="none" stroke="#6c7890" stroke-width="0.7"/>
13 <path d="M11.36 0.9V2.7" fill="none" stroke="#6c7890" stroke-width="0.7"/>
14 <path d="M13.6 0.9V2.7" fill="none" stroke="#6c7890" stroke-width="0.7"/>
15 <path d="M1.8 6.65H14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
16 <path d="M1.8 8.8H14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
17 <path d="M1.8 10.96H14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
18 <path d="M1.8 13.11H14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
19 <path d="M3.33 4.5V14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
20 <path d="M5.67 4.5V14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
21 <path d="M8 4.5V14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
22 <path d="M10.33 4.5V14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
23 <path d="M12.67 4.5V14.2" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
24 <path d="M9.6 6.6H12V8.9H9.6Z" fill="none" stroke="#e0442c" stroke-width="0.8"/>
25</svg>
crates/canvas/assets/tags/car.svg created+13
......@@ -0,0 +1,13 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="3" x2="0" y2="12" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e04040"/><stop offset="1" stop-color="#8a0a0a"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="4.2" x2="0" y2="7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e0ecff"/><stop offset="1" stop-color="#8aa4d0"/></linearGradient>
5 </defs>
6 <path d="M1 10.4C1 8.4 2 7.6 3 7.2L4.4 4.2C4.8 3.4 5.4 3 6.4 3H9.6C10.6 3 11.2 3.4 11.6 4.2L13 7.2C14 7.6 15 8.4 15 10.4V12H1Z" fill="url(#g0)"/>
7 <path d="M5.2 4.2H10.8L11.8 7H4.2Z" fill="url(#g1)"/>
8 <path d="M1.4 11.6H3.8V14H1.4Z" fill="#2a2a2a"/>
9 <path d="M12.2 11.6H14.6V14H12.2Z" fill="#2a2a2a"/>
10 <path d="M2 9.2A1.4 0.9 0 1 1 4.8 9.2A1.4 0.9 0 1 1 2 9.2Z" fill="#fff8d0"/>
11 <path d="M11.2 9.2A1.4 0.9 0 1 1 14 9.2A1.4 0.9 0 1 1 11.2 9.2Z" fill="#fff8d0"/>
12 <path d="M5.6 10.2H10.4V11.2H5.6Z" fill="#c0c4cc"/>
13</svg>
crates/canvas/assets/tags/checkbox-green.svg created+24
......@@ -0,0 +1,24 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#a6d596"/>
5 <stop offset="1" stop-color="#6cb055"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#ffffff"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#98cf84"/>
13 <stop offset="1" stop-color="#5ea347"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#e2f3da"/>
18 </linearGradient>
19 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>
23 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
24</svg>
crates/canvas/assets/tags/checkbox-yellow.svg created+24
......@@ -0,0 +1,24 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#f6d993"/>
5 <stop offset="1" stop-color="#e0ad3e"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#ffffff"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#f3d182"/>
13 <stop offset="1" stop-color="#d9a133"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#fcf1d8"/>
18 </linearGradient>
19 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>
23 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
24</svg>
crates/canvas/assets/tags/circle-1-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0c8f2"/><stop offset="1" stop-color="#5c86d1"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#3d62ad"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.6 5.6L8.4 4.5V11.6" fill="none" stroke="#1b2c55" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-1-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c0e6ae"/><stop offset="1" stop-color="#6cb450"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#3f8a2c"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.6 5.6L8.4 4.5V11.6" fill="none" stroke="#1d4414" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-1-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fae397"/><stop offset="1" stop-color="#e6b433"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#b0801a"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.6 5.6L8.4 4.5V11.6" fill="none" stroke="#4a3408" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-2-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0c8f2"/><stop offset="1" stop-color="#5c86d1"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#3d62ad"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.1 6.1C6.3 4.9 7.2 4.4 8.1 4.4C9.2 4.4 10 5.1 10 6.1C10 7.2 9.1 8 6.2 11.4H10.2" fill="none" stroke="#1b2c55" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-2-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c0e6ae"/><stop offset="1" stop-color="#6cb450"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#3f8a2c"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.1 6.1C6.3 4.9 7.2 4.4 8.1 4.4C9.2 4.4 10 5.1 10 6.1C10 7.2 9.1 8 6.2 11.4H10.2" fill="none" stroke="#1d4414" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-2-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fae397"/><stop offset="1" stop-color="#e6b433"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#b0801a"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.1 6.1C6.3 4.9 7.2 4.4 8.1 4.4C9.2 4.4 10 5.1 10 6.1C10 7.2 9.1 8 6.2 11.4H10.2" fill="none" stroke="#4a3408" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-3-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0c8f2"/><stop offset="1" stop-color="#5c86d1"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#3d62ad"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.1 5.2C6.6 4.6 7.3 4.4 8 4.4C9.1 4.4 9.9 5 9.9 5.9C9.9 6.9 9.1 7.6 7.7 7.6C9.3 7.6 10.2 8.4 10.2 9.6C10.2 10.8 9.2 11.6 8 11.6C7.1 11.6 6.3 11.3 5.8 10.6" fill="none" stroke="#1b2c55" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-3-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c0e6ae"/><stop offset="1" stop-color="#6cb450"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#3f8a2c"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.1 5.2C6.6 4.6 7.3 4.4 8 4.4C9.1 4.4 9.9 5 9.9 5.9C9.9 6.9 9.1 7.6 7.7 7.6C9.3 7.6 10.2 8.4 10.2 9.6C10.2 10.8 9.2 11.6 8 11.6C7.1 11.6 6.3 11.3 5.8 10.6" fill="none" stroke="#1d4414" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-3-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fae397"/><stop offset="1" stop-color="#e6b433"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#b0801a"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M6.1 5.2C6.6 4.6 7.3 4.4 8 4.4C9.1 4.4 9.9 5 9.9 5.9C9.9 6.9 9.1 7.6 7.7 7.6C9.3 7.6 10.2 8.4 10.2 9.6C10.2 10.8 9.2 11.6 8 11.6C7.1 11.6 6.3 11.3 5.8 10.6" fill="none" stroke="#4a3408" stroke-width="1.15"/>
9</svg>
crates/canvas/assets/tags/circle-blue.svg created+8
......@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0c8f2"/><stop offset="1" stop-color="#5c86d1"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#3d62ad"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8</svg>
crates/canvas/assets/tags/circle-green.svg created+8
......@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c0e6ae"/><stop offset="1" stop-color="#6cb450"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#3f8a2c"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8</svg>
crates/canvas/assets/tags/circle-yellow.svg created+8
......@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fae397"/><stop offset="1" stop-color="#e6b433"/></linearGradient>
4 </defs>
5 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="#b0801a"/>
6 <path d="M1.7 8A6.3 6.3 0 1 1 14.3 8A6.3 6.3 0 1 1 1.7 8Z" fill="url(#g0)"/>
7 <path d="M3.66 5.34A4.34 2.8 0 1 1 12.34 5.34A4.34 2.8 0 1 1 3.66 5.34Z" fill="#ffffff" fill-opacity="0.45"/>
8</svg>
crates/canvas/assets/tags/clock.svg created+21
......@@ -0,0 +1,21 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c8ccd4"/><stop offset="1" stop-color="#7a808c"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8ecf2"/></linearGradient>
5 </defs>
6 <path d="M1 8A7 7 0 1 1 15 8A7 7 0 1 1 1 8Z" fill="url(#g0)"/>
7 <path d="M1.9 8A6.1 6.1 0 1 1 14.1 8A6.1 6.1 0 1 1 1.9 8Z" fill="url(#g1)"/>
8 <path d="M12.9 8A0.5 0.5 0 1 1 13.9 8A0.5 0.5 0 1 1 12.9 8Z" fill="#4a5060"/>
9 <path d="M12.33 10.7A0.35 0.35 0 1 1 13.03 10.7A0.35 0.35 0 1 1 12.33 10.7Z" fill="#4a5060"/>
10 <path d="M10.35 12.68A0.35 0.35 0 1 1 11.05 12.68A0.35 0.35 0 1 1 10.35 12.68Z" fill="#4a5060"/>
11 <path d="M7.5 13.4A0.5 0.5 0 1 1 8.5 13.4A0.5 0.5 0 1 1 7.5 13.4Z" fill="#4a5060"/>
12 <path d="M4.95 12.68A0.35 0.35 0 1 1 5.65 12.68A0.35 0.35 0 1 1 4.95 12.68Z" fill="#4a5060"/>
13 <path d="M2.97 10.7A0.35 0.35 0 1 1 3.67 10.7A0.35 0.35 0 1 1 2.97 10.7Z" fill="#4a5060"/>
14 <path d="M2.1 8A0.5 0.5 0 1 1 3.1 8A0.5 0.5 0 1 1 2.1 8Z" fill="#4a5060"/>
15 <path d="M2.97 5.3A0.35 0.35 0 1 1 3.67 5.3A0.35 0.35 0 1 1 2.97 5.3Z" fill="#4a5060"/>
16 <path d="M4.95 3.32A0.35 0.35 0 1 1 5.65 3.32A0.35 0.35 0 1 1 4.95 3.32Z" fill="#4a5060"/>
17 <path d="M7.5 2.6A0.5 0.5 0 1 1 8.5 2.6A0.5 0.5 0 1 1 7.5 2.6Z" fill="#4a5060"/>
18 <path d="M10.35 3.32A0.35 0.35 0 1 1 11.05 3.32A0.35 0.35 0 1 1 10.35 3.32Z" fill="#4a5060"/>
19 <path d="M12.33 5.3A0.35 0.35 0 1 1 13.03 5.3A0.35 0.35 0 1 1 12.33 5.3Z" fill="#4a5060"/>
20 <path d="M8 3.8V8L10.94 6.25" fill="none" stroke="#20242c" stroke-width="0.84"/>
21</svg>
crates/canvas/assets/tags/cloud.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2" x2="0" y2="12" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#b4c8ec"/></linearGradient>
4 </defs>
5 <path d="M3.4 11.2C1.6 11.2 0.6 10 0.8 8.6C1 7.2 2.2 6.4 3.4 6.6C3.4 4.4 5.2 2.8 7.2 3C8.4 1.6 10.8 1.6 12 3C13.8 3.2 15 4.6 14.8 6.4C15.6 7.2 15.6 8.8 14.8 9.8C14 10.8 12.8 11.2 11.6 11C10.6 12 8.8 12.2 7.6 11.4C6.4 12 4.6 12 3.4 11.2Z" fill="url(#g0)"/>
6 <path d="M3.4 11.2C1.6 11.2 0.6 10 0.8 8.6C1 7.2 2.2 6.4 3.4 6.6C3.4 4.4 5.2 2.8 7.2 3C8.4 1.6 10.8 1.6 12 3C13.8 3.2 15 4.6 14.8 6.4C15.6 7.2 15.6 8.8 14.8 9.8C14 10.8 12.8 11.2 11.6 11C10.6 12 8.8 12.2 7.6 11.4C6.4 12 4.6 12 3.4 11.2Z" fill="none" stroke="#7a94c4" stroke-width="0.5"/>
7 <path d="M3.5 13.2A0.9 0.9 0 1 1 5.3 13.2A0.9 0.9 0 1 1 3.5 13.2Z" fill="#9ab0dc"/>
8 <path d="M1.8 15A0.6 0.6 0 1 1 3 15A0.6 0.6 0 1 1 1.8 15Z" fill="#9ab0dc"/>
9</svg>
crates/canvas/assets/tags/coins.svg created+30
......@@ -0,0 +1,30 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="12" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e4eaf4"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="3.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8fb1ec"/><stop offset="1" stop-color="#4d78c8"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="13" x2="0" y2="14.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe070"/><stop offset="1" stop-color="#d09a18"/></linearGradient>
6 <linearGradient id="g3" x1="0" y1="11.4" x2="0" y2="13.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe070"/><stop offset="1" stop-color="#d09a18"/></linearGradient>
7 <linearGradient id="g4" x1="0" y1="9.8" x2="0" y2="11.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe070"/><stop offset="1" stop-color="#d09a18"/></linearGradient>
8 <linearGradient id="g5" x1="0" y1="8.2" x2="0" y2="10" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe070"/><stop offset="1" stop-color="#d09a18"/></linearGradient>
9 <linearGradient id="g6" x1="0" y1="6.6" x2="0" y2="8.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe070"/><stop offset="1" stop-color="#d09a18"/></linearGradient>
10 </defs>
11 <path d="M0.6 1H11.6V12H0.6Z" fill="#7a8aac"/>
12 <path d="M1.1 1.5H11.1V11.5H1.1Z" fill="url(#g0)"/>
13 <path d="M1.1 1.5H11.1V3.2H1.1Z" fill="url(#g1)"/>
14 <path d="M2.2 4.1H3.2V5.1H2.2Z" fill="#6a7a9a"/>
15 <path d="M4.2 4.6H9.6" fill="none" stroke="#9aa8c4" stroke-width="0.6"/>
16 <path d="M2.2 6.5H3.2V7.5H2.2Z" fill="#6a7a9a"/>
17 <path d="M4.2 7H9.6" fill="none" stroke="#9aa8c4" stroke-width="0.6"/>
18 <path d="M2.2 8.9H3.2V9.9H2.2Z" fill="#6a7a9a"/>
19 <path d="M4.2 9.4H9.6" fill="none" stroke="#9aa8c4" stroke-width="0.6"/>
20 <path d="M8.2 14.2A3.6 1.2 0 1 1 15.4 14.2A3.6 1.2 0 1 1 8.2 14.2Z" fill="#8a6010"/>
21 <path d="M8.5 13.9A3.3 0.9 0 1 1 15.1 13.9A3.3 0.9 0 1 1 8.5 13.9Z" fill="url(#g2)"/>
22 <path d="M8.2 12.6A3.6 1.2 0 1 1 15.4 12.6A3.6 1.2 0 1 1 8.2 12.6Z" fill="#8a6010"/>
23 <path d="M8.5 12.3A3.3 0.9 0 1 1 15.1 12.3A3.3 0.9 0 1 1 8.5 12.3Z" fill="url(#g3)"/>
24 <path d="M8.2 11A3.6 1.2 0 1 1 15.4 11A3.6 1.2 0 1 1 8.2 11Z" fill="#8a6010"/>
25 <path d="M8.5 10.7A3.3 0.9 0 1 1 15.1 10.7A3.3 0.9 0 1 1 8.5 10.7Z" fill="url(#g4)"/>
26 <path d="M8.2 9.4A3.6 1.2 0 1 1 15.4 9.4A3.6 1.2 0 1 1 8.2 9.4Z" fill="#8a6010"/>
27 <path d="M8.5 9.1A3.3 0.9 0 1 1 15.1 9.1A3.3 0.9 0 1 1 8.5 9.1Z" fill="url(#g5)"/>
28 <path d="M8.2 7.8A3.6 1.2 0 1 1 15.4 7.8A3.6 1.2 0 1 1 8.2 7.8Z" fill="#8a6010"/>
29 <path d="M8.5 7.5A3.3 0.9 0 1 1 15.1 7.5A3.3 0.9 0 1 1 8.5 7.5Z" fill="url(#g6)"/>
30</svg>
crates/canvas/assets/tags/date.svg created+43
......@@ -0,0 +1,43 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.2" x2="0" y2="13.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e4eaf4"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1.2" x2="0" y2="4.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8fb1ec"/><stop offset="1" stop-color="#4d78c8"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="7.4" x2="0" y2="15.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c8ccd4"/><stop offset="1" stop-color="#7a808c"/></linearGradient>
6 <linearGradient id="g3" x1="0" y1="7.4" x2="0" y2="15.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8ecf2"/></linearGradient>
7 </defs>
8 <path d="M1 1.2H13.8V13.4H1Z" fill="#7d8fb0"/>
9 <path d="M1.6 1.8H13.2V12.8H1.6Z" fill="url(#g0)"/>
10 <path d="M1.6 1.8H13.2V4.2H1.6Z" fill="url(#g1)"/>
11 <path d="M2.4 0.6V2.4" fill="none" stroke="#6c7890" stroke-width="0.7"/>
12 <path d="M4.4 0.6V2.4" fill="none" stroke="#6c7890" stroke-width="0.7"/>
13 <path d="M6.4 0.6V2.4" fill="none" stroke="#6c7890" stroke-width="0.7"/>
14 <path d="M8.4 0.6V2.4" fill="none" stroke="#6c7890" stroke-width="0.7"/>
15 <path d="M10.4 0.6V2.4" fill="none" stroke="#6c7890" stroke-width="0.7"/>
16 <path d="M12.4 0.6V2.4" fill="none" stroke="#6c7890" stroke-width="0.7"/>
17 <path d="M1.8 6.07H13" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
18 <path d="M1.8 7.94H13" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
19 <path d="M1.8 9.81H13" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
20 <path d="M1.8 11.68H13" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
21 <path d="M3.13 4.2V12.6" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
22 <path d="M5.27 4.2V12.6" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
23 <path d="M7.4 4.2V12.6" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
24 <path d="M9.53 4.2V12.6" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
25 <path d="M11.67 4.2V12.6" fill="none" stroke="#b4c2dc" stroke-width="0.5"/>
26 <path d="M3.8 5H9V11.8H3.8Z" fill="#ffffff" fill-opacity="0.85"/>
27 <path d="M8.2 5.4H5.2L4.8 8C5.4 7.4 6.2 7.2 6.8 7.4C8.2 7.8 8.4 9.8 7.6 10.8C6.8 11.8 5 11.6 4.4 10.8" fill="none" stroke="#3a4050" stroke-width="1.1"/>
28 <path d="M7.4 11.6A4.2 4.2 0 1 1 15.8 11.6A4.2 4.2 0 1 1 7.4 11.6Z" fill="url(#g2)"/>
29 <path d="M8.3 11.6A3.3 3.3 0 1 1 14.9 11.6A3.3 3.3 0 1 1 8.3 11.6Z" fill="url(#g3)"/>
30 <path d="M13.7 11.6A0.5 0.5 0 1 1 14.7 11.6A0.5 0.5 0 1 1 13.7 11.6Z" fill="#4a5060"/>
31 <path d="M13.5 12.9A0.35 0.35 0 1 1 14.2 12.9A0.35 0.35 0 1 1 13.5 12.9Z" fill="#4a5060"/>
32 <path d="M12.55 13.85A0.35 0.35 0 1 1 13.25 13.85A0.35 0.35 0 1 1 12.55 13.85Z" fill="#4a5060"/>
33 <path d="M11.1 14.2A0.5 0.5 0 1 1 12.1 14.2A0.5 0.5 0 1 1 11.1 14.2Z" fill="#4a5060"/>
34 <path d="M9.95 13.85A0.35 0.35 0 1 1 10.65 13.85A0.35 0.35 0 1 1 9.95 13.85Z" fill="#4a5060"/>
35 <path d="M9 12.9A0.35 0.35 0 1 1 9.7 12.9A0.35 0.35 0 1 1 9 12.9Z" fill="#4a5060"/>
36 <path d="M8.5 11.6A0.5 0.5 0 1 1 9.5 11.6A0.5 0.5 0 1 1 8.5 11.6Z" fill="#4a5060"/>
37 <path d="M9 10.3A0.35 0.35 0 1 1 9.7 10.3A0.35 0.35 0 1 1 9 10.3Z" fill="#4a5060"/>
38 <path d="M9.95 9.35A0.35 0.35 0 1 1 10.65 9.35A0.35 0.35 0 1 1 9.95 9.35Z" fill="#4a5060"/>
39 <path d="M11.1 9A0.5 0.5 0 1 1 12.1 9A0.5 0.5 0 1 1 11.1 9Z" fill="#4a5060"/>
40 <path d="M12.55 9.35A0.35 0.35 0 1 1 13.25 9.35A0.35 0.35 0 1 1 12.55 9.35Z" fill="#4a5060"/>
41 <path d="M13.5 10.3A0.35 0.35 0 1 1 14.2 10.3A0.35 0.35 0 1 1 13.5 10.3Z" fill="#4a5060"/>
42 <path d="M11.6 9.08V11.6L13.36 10.55" fill="none" stroke="#20242c" stroke-width="0.6"/>
43</svg>
crates/canvas/assets/tags/dollar.svg created+5
......@@ -0,0 +1,5 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 0.8V15.2" fill="none" stroke="#3a8a3a" stroke-width="1.2"/>
3 <path d="M11.6 4.2C11 2.8 9.6 2.2 8 2.2C5.8 2.2 4.4 3.2 4.4 4.8C4.4 8.2 11.8 7.2 11.8 11C11.8 12.8 10.2 13.8 8 13.8C6.2 13.8 4.8 13 4.2 11.6" fill="none" stroke="#3a8a3a" stroke-width="2.2"/>
4 <path d="M11.6 4.2C11 2.8 9.6 2.2 8 2.2C5.8 2.2 4.4 3.2 4.4 4.8C4.4 8.2 11.8 7.2 11.8 11C11.8 12.8 10.2 13.8 8 13.8C6.2 13.8 4.8 13 4.2 11.6" fill="none" stroke="#8ad87a" stroke-width="0.8"/>
5</svg>
crates/canvas/assets/tags/envelope-open.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2.9" x2="0" y2="13.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f4f4ee"/><stop offset="1" stop-color="#dcdcd0"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="7.4" x2="0" y2="13.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8ecf4"/></linearGradient>
5 </defs>
6 <path d="M1 7.2L8 2.2L15 7.2V14.2H1Z" fill="#8a96a8"/>
7 <path d="M1.6 7.4L8 2.9L14.4 7.4V13.6H1.6Z" fill="url(#g0)"/>
8 <path d="M3 5H13V11H3Z" fill="#ffffff"/>
9 <path d="M1.6 7.4L8 11.6L14.4 7.4V13.6H1.6Z" fill="url(#g1)"/>
10 <path d="M1.6 13.6L6.6 9.8M14.4 13.6L9.4 9.8" fill="none" stroke="#aab4c4" stroke-width="0.5"/>
11</svg>
crates/canvas/assets/tags/envelope.svg created+8
......@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="4" x2="0" y2="12.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff4c0"/><stop offset="1" stop-color="#f6dc88"/></linearGradient>
4 </defs>
5 <path d="M0.8 3.4H15.2V12.8H0.8Z" fill="#c0a050"/>
6 <path d="M1.4 4H14.6V12.2H1.4Z" fill="url(#g0)"/>
7 <path d="M1.4 4L8 9L14.6 4M1.4 12.2L6.4 7.8M14.6 12.2L9.6 7.8" fill="none" stroke="#c8a860" stroke-width="0.6"/>
8</svg>
crates/canvas/assets/tags/flag-next-week.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fbcbc7"/><stop offset="1" stop-color="#ecb8b5"/></linearGradient>
4 </defs>
5 <path d="M3.4 2.2C6.4 0.2 9 3.6 14.6 1.6C13.3 3.8 13.7 5.3 15.4 6.8C10.4 8.8 7.8 5.4 5 7.2Z" fill="url(#g0)"/>
6 <path d="M2.8 1.6L7.4 15" fill="none" stroke="#2a3a6a" stroke-width="1.3"/>
7</svg>
crates/canvas/assets/tags/flag-no-date.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fde1df"/><stop offset="1" stop-color="#f4d6d5"/></linearGradient>
4 </defs>
5 <path d="M3.4 2.2C6.4 0.2 9 3.6 14.6 1.6C13.3 3.8 13.7 5.3 15.4 6.8C10.4 8.8 7.8 5.4 5 7.2Z" fill="url(#g0)"/>
6 <path d="M2.8 1.6L7.4 15" fill="none" stroke="#2a3a6a" stroke-width="1.3"/>
7</svg>
crates/canvas/assets/tags/flag-this-week.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fab4ae"/><stop offset="1" stop-color="#e49a96"/></linearGradient>
4 </defs>
5 <path d="M3.4 2.2C6.4 0.2 9 3.6 14.6 1.6C13.3 3.8 13.7 5.3 15.4 6.8C10.4 8.8 7.8 5.4 5 7.2Z" fill="url(#g0)"/>
6 <path d="M2.8 1.6L7.4 15" fill="none" stroke="#2a3a6a" stroke-width="1.3"/>
7</svg>
crates/canvas/assets/tags/flag-today.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f46a5e"/><stop offset="1" stop-color="#c8342c"/></linearGradient>
4 </defs>
5 <path d="M3.4 2.2C6.4 0.2 9 3.6 14.6 1.6C13.3 3.8 13.7 5.3 15.4 6.8C10.4 8.8 7.8 5.4 5 7.2Z" fill="url(#g0)"/>
6 <path d="M2.8 1.6L7.4 15" fill="none" stroke="#2a3a6a" stroke-width="1.3"/>
7</svg>
crates/canvas/assets/tags/flag-tomorrow.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f7978e"/><stop offset="1" stop-color="#d8716b"/></linearGradient>
4 </defs>
5 <path d="M3.4 2.2C6.4 0.2 9 3.6 14.6 1.6C13.3 3.8 13.7 5.3 15.4 6.8C10.4 8.8 7.8 5.4 5 7.2Z" fill="url(#g0)"/>
6 <path d="M2.8 1.6L7.4 15" fill="none" stroke="#2a3a6a" stroke-width="1.3"/>
7</svg>
crates/canvas/assets/tags/follow-up.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7aa4f0"/><stop offset="1" stop-color="#2450b0"/></linearGradient>
4 </defs>
5 <path d="M3.4 2.2C6.4 0.2 9 3.6 14.6 1.6C13.3 3.8 13.7 5.3 15.4 6.8C10.4 8.8 7.8 5.4 5 7.2Z" fill="url(#g0)"/>
6 <path d="M2.8 1.6L7.4 15" fill="none" stroke="#2a3a6a" stroke-width="1.3"/>
7</svg>
crates/canvas/assets/tags/frown.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff9a6a"/><stop offset="1" stop-color="#d8401a"/></linearGradient>
4 </defs>
5 <path d="M0.6 8A7.4 7.4 0 1 1 15.4 8A7.4 7.4 0 1 1 0.6 8Z" fill="#b83010"/>
6 <path d="M1.2 8A6.8 6.8 0 1 1 14.8 8A6.8 6.8 0 1 1 1.2 8Z" fill="url(#g0)"/>
7 <path d="M3.8 4.6A4.2 2.6 0 1 1 12.2 4.6A4.2 2.6 0 1 1 3.8 4.6Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M5 6.8A0.8 1.1 0 1 1 6.6 6.8A0.8 1.1 0 1 1 5 6.8Z" fill="#2a2008"/>
9 <path d="M9.4 6.8A0.8 1.1 0 1 1 11 6.8A0.8 1.1 0 1 1 9.4 6.8Z" fill="#2a2008"/>
10 <path d="M4.8 12.2C6.4 10.4 9.6 10.4 11.2 12.2" fill="none" stroke="#2a2008" stroke-width="0.9"/>
11</svg>
crates/canvas/assets/tags/globe.svg created+12
......@@ -0,0 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.2" x2="0" y2="14.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6aa4ff"/><stop offset="1" stop-color="#1e54b8"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="2.6" x2="0" y2="11.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ad86a"/><stop offset="1" stop-color="#2e8a2a"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="8.6" x2="0" y2="14.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ad86a"/><stop offset="1" stop-color="#2e8a2a"/></linearGradient>
6 </defs>
7 <path d="M0.6 8A7.4 7.4 0 1 1 15.4 8A7.4 7.4 0 1 1 0.6 8Z" fill="#1e4a9a"/>
8 <path d="M1.2 8A6.8 6.8 0 1 1 14.8 8A6.8 6.8 0 1 1 1.2 8Z" fill="url(#g0)"/>
9 <path d="M3 5C4.4 3 6.6 2.4 8 3C7.6 4 8.6 4.6 9.6 4.4C10.4 5.4 9.4 6.6 8 6.6C7.2 7.6 7.8 8.8 6.6 9.4C5.2 10 4.6 11.6 3.4 11.2C2.2 9.4 1.8 7 3 5Z" fill="url(#g1)"/>
10 <path d="M10.4 9C12 8.6 13.8 9.4 14.2 10.6C13.4 12.6 11.8 14 10 14.4C9.4 13 10.2 12 9.6 10.8C9.4 10 9.8 9.2 10.4 9Z" fill="url(#g2)"/>
11 <path d="M3.2 3.8A3.8 2 0 1 1 10.8 3.8A3.8 2 0 1 1 3.2 3.8Z" fill="#ffffff" fill-opacity="0.35"/>
12</svg>
crates/canvas/assets/tags/green-square.svg created+19
......@@ -0,0 +1,19 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#7ea25a"/>
5 <stop offset="1" stop-color="#6a8e48"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#b0d88c"/>
9 <stop offset="1" stop-color="#b4dc92"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#8cb568"/>
13 <stop offset="1" stop-color="#86ae62"/>
14 </linearGradient>
15 </defs>
16 <path d="M3 3H13V13H3Z" fill="url(#layer0)"/>
17 <path d="M3 3H12V12H3Z" fill="url(#layer1)"/>
18 <path d="M4 4H12V12H4Z" fill="url(#layer2)"/>
19</svg>
crates/canvas/assets/tags/heart.svg created+8
......@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2" x2="0" y2="13.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff6a6a"/><stop offset="1" stop-color="#c80a0a"/></linearGradient>
4 </defs>
5 <path d="M8 15C5.6 12.6 0.8 9.6 0.8 5.4C0.8 2.8 2.6 1.2 4.6 1.2C6.2 1.2 7.4 2.2 8 3.4C8.6 2.2 9.8 1.2 11.4 1.2C13.4 1.2 15.2 2.8 15.2 5.4C15.2 9.6 10.4 12.6 8 15Z" fill="#6a0808"/>
6 <path d="M8 13.8C5.8 11.6 1.6 9 1.6 5.4C1.6 3.4 3 2 4.6 2C6.2 2 7.4 3.2 8 4.8C8.6 3.2 9.8 2 11.4 2C13 2 14.4 3.4 14.4 5.4C14.4 9 10.2 11.6 8 13.8Z" fill="url(#g0)"/>
7 <path d="M2.8 4.6A1.8 1.2 0 1 1 6.4 4.6A1.8 1.2 0 1 1 2.8 4.6Z" fill="#ffffff" fill-opacity="0.5"/>
8</svg>
crates/canvas/assets/tags/im-contact.svg created+10
......@@ -0,0 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="8.4" x2="0" y2="15.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ae07a"/><stop offset="1" stop-color="#1e8a3a"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="0.8" x2="0" y2="8.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9ae88a"/><stop offset="1" stop-color="#2a9a2a"/></linearGradient>
5 </defs>
6 <path d="M1 14.6C1.4 10.6 4.4 8.4 8 8.4C11.6 8.4 14.6 10.6 15 14.6C12.8 15.4 10.4 15.6 8 15.6C5.6 15.6 3.2 15.4 1 14.6Z" fill="url(#g0)"/>
7 <path d="M8 9.2C10 9.2 12.2 10 13.4 11.6C12 11 10 10.6 8 10.6Z" fill="#1e5ab8" fill-opacity="0.9"/>
8 <path d="M4.6 4.6A3.8 3.8 0 1 1 12.2 4.6A3.8 3.8 0 1 1 4.6 4.6Z" fill="url(#g1)"/>
9 <path d="M5.6 3.2A2 1.2 0 1 1 9.6 3.2A2 1.2 0 1 1 5.6 3.2Z" fill="#ffffff" fill-opacity="0.5"/>
10</svg>
crates/canvas/assets/tags/key.svg created+8
......@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.8" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff0a0"/><stop offset="1" stop-color="#c89414"/></linearGradient>
4 </defs>
5 <path d="M5.4 0.8H10.6V6.2H9V13.2L7.8 15H7.4L6.4 13.6L7.2 12.6L6.4 11.8L7.2 11L6.4 10.2L7 9.4V6.2H5.4Z" fill="url(#g0)"/>
6 <path d="M5.4 0.8H10.6V6.2H9V13.2L7.8 15H7.4L6.4 13.6L7.2 12.6L6.4 11.8L7.2 11L6.4 10.2L7 9.4V6.2H5.4Z" fill="none" stroke="#9a7010" stroke-width="0.5"/>
7 <path d="M7 2.2H9V3H7Z" fill="#9a7010"/>
8</svg>
crates/canvas/assets/tags/laptop.svg created+10
......@@ -0,0 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2.4" x2="0" y2="9.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a8c8ff"/><stop offset="1" stop-color="#3a6ad8"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="8.6" x2="0" y2="14.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e0e4f0"/><stop offset="1" stop-color="#8a90b0"/></linearGradient>
5 </defs>
6 <path d="M3.4 1.6L13.6 3.4L12.4 10.4L2.2 8.6Z" fill="#5a6070"/>
7 <path d="M4 2.4L12.8 4L11.8 9.6L3 8Z" fill="url(#g0)"/>
8 <path d="M2.2 8.6L12.4 10.4L15.2 12.4L4.6 14.6L0.8 11.2Z" fill="url(#g1)"/>
9 <path d="M3 10.4L11.8 11.8L9.2 12.8L3.6 11.8Z" fill="#9aa2c0"/>
10</svg>
crates/canvas/assets/tags/lightning.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.8" x2="0" y2="15.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff4a0"/><stop offset="1" stop-color="#e0a818"/></linearGradient>
4 </defs>
5 <path d="M8 0.8H14L10.2 6.2H13.2L4.8 15.2L7 8.6H3.6Z" fill="url(#g0)"/>
6 <path d="M8 0.8H14L10.2 6.2H13.2L4.8 15.2L7 8.6H3.6Z" fill="none" stroke="#b88410" stroke-width="0.5"/>
7</svg>
crates/canvas/assets/tags/mark-arrow-blue.svg created+6
......@@ -0,0 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6fb0ff"/><stop offset="1" stop-color="#1e5ab8"/></linearGradient>
4 </defs>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>
6</svg>
crates/canvas/assets/tags/mark-arrow-green.svg created+6
......@@ -0,0 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ee07a"/><stop offset="1" stop-color="#2e8a1e"/></linearGradient>
4 </defs>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>
6</svg>
crates/canvas/assets/tags/mark-arrow-yellow.svg created+6
......@@ -0,0 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe07a"/><stop offset="1" stop-color="#c28a12"/></linearGradient>
4 </defs>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>
6</svg>
crates/canvas/assets/tags/mark-arrow.svg deleted-6
......@@ -1,6 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6fb0ff"/><stop offset="1" stop-color="#1e5ab8"/></linearGradient>
4 </defs>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>
6</svg>
crates/canvas/assets/tags/mark-client.svg deleted-9
......@@ -1,9 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff1b0"/><stop offset="1" stop-color="#e0a82e"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b58a3a"/><stop offset="1" stop-color="#8a6420"/></linearGradient>
5 </defs>
6 <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/>
7 <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>
9</svg>
crates/canvas/assets/tags/mark-exclamation-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8e6ff"/><stop offset="1" stop-color="#4b78c9"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#3d62ad"/><stop offset="1" stop-color="#1e4494"/></linearGradient>
5 </defs>
6 <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/>
7 <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>
9</svg>
crates/canvas/assets/tags/mark-exclamation-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8f5c8"/><stop offset="1" stop-color="#5aa83c"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4a8a2e"/><stop offset="1" stop-color="#2d6a1a"/></linearGradient>
5 </defs>
6 <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/>
7 <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>
9</svg>
crates/canvas/assets/tags/mark-exclamation-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff1b0"/><stop offset="1" stop-color="#e0a82e"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b58a3a"/><stop offset="1" stop-color="#8a6420"/></linearGradient>
5 </defs>
6 <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/>
7 <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>
9</svg>
crates/canvas/assets/tags/mark-flag-blue.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae8"/><stop offset="1" stop-color="#2450b0"/></linearGradient>
4 </defs>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>
6 <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/mark-flag-green.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7fe07a"/><stop offset="1" stop-color="#2a9a25"/></linearGradient>
4 </defs>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>
6 <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/mark-flag-yellow.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffd27a"/><stop offset="1" stop-color="#e08a1a"/></linearGradient>
4 </defs>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>
6 <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/mark-manager.svg deleted-9
......@@ -1,9 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f5d45c"/><stop offset="1" stop-color="#b8901e"/></linearGradient>
5 </defs>
6 <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/>
7 <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>
9</svg>
crates/canvas/assets/tags/mark-one-blue.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
7</svg>
crates/canvas/assets/tags/mark-one-green.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
7</svg>
crates/canvas/assets/tags/mark-one-yellow.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
7</svg>
crates/canvas/assets/tags/mark-one.svg deleted-7
......@@ -1,7 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
7</svg>
crates/canvas/assets/tags/mark-person-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae0"/><stop offset="1" stop-color="#1e4494"/></linearGradient>
5 </defs>
6 <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/>
7 <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>
9</svg>
crates/canvas/assets/tags/mark-person-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#86d070"/><stop offset="1" stop-color="#2e7a1e"/></linearGradient>
5 </defs>
6 <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/>
7 <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>
9</svg>
crates/canvas/assets/tags/mark-person-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f5d45c"/><stop offset="1" stop-color="#b8901e"/></linearGradient>
5 </defs>
6 <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/>
7 <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>
9</svg>
crates/canvas/assets/tags/mark-person.svg deleted-9
......@@ -1,9 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae0"/><stop offset="1" stop-color="#1e4494"/></linearGradient>
5 </defs>
6 <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/>
7 <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>
9</svg>
crates/canvas/assets/tags/mark-star-blue.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d3e1fa"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
4 </defs>
5 <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#3d62ad"/>
6 <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/mark-star-green.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dcf2d0"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
4 </defs>
5 <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#3f8a2c"/>
6 <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/mark-star-yellow.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcf0c0"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>
4 </defs>
5 <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#b0801a"/>
6 <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/mark-three-blue.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>
6 <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/>
7</svg>
crates/canvas/assets/tags/mark-three-green.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>
6 <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/>
7</svg>
crates/canvas/assets/tags/mark-three-yellow.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>
6 <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/>
7</svg>
crates/canvas/assets/tags/mark-two-blue.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/>
7</svg>
crates/canvas/assets/tags/mark-two-green.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/>
7</svg>
crates/canvas/assets/tags/mark-two-yellow.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/>
7</svg>
crates/canvas/assets/tags/mark-two.svg deleted-7
......@@ -1,7 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/>
7</svg>
crates/canvas/assets/tags/mobile.svg created+19
......@@ -0,0 +1,19 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.6" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa4b4"/><stop offset="1" stop-color="#5a6474"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1.2" x2="0" y2="14.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f0f2f6"/><stop offset="1" stop-color="#c4cad4"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="2.2" x2="0" y2="7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ab4ff"/><stop offset="1" stop-color="#2e62d0"/></linearGradient>
6 </defs>
7 <path d="M5.4 0.6H10.6A1.4 1.4 0 0 1 12 2V14A1.4 1.4 0 0 1 10.6 15.4H5.4A1.4 1.4 0 0 1 4 14V2A1.4 1.4 0 0 1 5.4 0.6Z" fill="url(#g0)"/>
8 <path d="M5.6 1.2H10.4A1 1 0 0 1 11.4 2.2V13.8A1 1 0 0 1 10.4 14.8H5.6A1 1 0 0 1 4.6 13.8V2.2A1 1 0 0 1 5.6 1.2Z" fill="url(#g1)"/>
9 <path d="M5.4 2.2H10.6V7H5.4Z" fill="url(#g2)"/>
10 <path d="M5.6 8.4H6.8V9.4H5.6Z" fill="#7a8494"/>
11 <path d="M7.4 8.4H8.6V9.4H7.4Z" fill="#7a8494"/>
12 <path d="M9.2 8.4H10.4V9.4H9.2Z" fill="#7a8494"/>
13 <path d="M5.6 10.3H6.8V11.3H5.6Z" fill="#7a8494"/>
14 <path d="M7.4 10.3H8.6V11.3H7.4Z" fill="#7a8494"/>
15 <path d="M9.2 10.3H10.4V11.3H9.2Z" fill="#7a8494"/>
16 <path d="M5.6 12.2H6.8V13.2H5.6Z" fill="#7a8494"/>
17 <path d="M7.4 12.2H8.6V13.2H7.4Z" fill="#7a8494"/>
18 <path d="M9.2 12.2H10.4V13.2H9.2Z" fill="#7a8494"/>
19</svg>
crates/canvas/assets/tags/notebook.svg created+32
......@@ -0,0 +1,32 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.4" x2="0" y2="14.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9ad88a"/><stop offset="1" stop-color="#4a9a4a"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="6.6" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c8ccd4"/><stop offset="1" stop-color="#7a808c"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="6.6" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8ecf2"/></linearGradient>
6 </defs>
7 <path d="M3.4 0.8H15.2V15.2H3.4Z" fill="#3a7a3a"/>
8 <path d="M4 1.4H14.6V14.6H4Z" fill="url(#g0)"/>
9 <path d="M6.4 3H12.8V7.2H6.4Z" fill="#3a7a3a" fill-opacity="0.35"/>
10 <path d="M2.8 2H4.8" fill="none" stroke="#4a4a4a" stroke-width="0.7"/>
11 <path d="M2.8 4H4.8" fill="none" stroke="#4a4a4a" stroke-width="0.7"/>
12 <path d="M2.8 6H4.8" fill="none" stroke="#4a4a4a" stroke-width="0.7"/>
13 <path d="M2.8 8H4.8" fill="none" stroke="#4a4a4a" stroke-width="0.7"/>
14 <path d="M2.8 10H4.8" fill="none" stroke="#4a4a4a" stroke-width="0.7"/>
15 <path d="M2.8 12H4.8" fill="none" stroke="#4a4a4a" stroke-width="0.7"/>
16 <path d="M2.8 14H4.8" fill="none" stroke="#4a4a4a" stroke-width="0.7"/>
17 <path d="M0.6 11A4.4 4.4 0 1 1 9.4 11A4.4 4.4 0 1 1 0.6 11Z" fill="url(#g1)"/>
18 <path d="M1.5 11A3.5 3.5 0 1 1 8.5 11A3.5 3.5 0 1 1 1.5 11Z" fill="url(#g2)"/>
19 <path d="M7.3 11A0.5 0.5 0 1 1 8.3 11A0.5 0.5 0 1 1 7.3 11Z" fill="#4a5060"/>
20 <path d="M7.07 12.4A0.35 0.35 0 1 1 7.77 12.4A0.35 0.35 0 1 1 7.07 12.4Z" fill="#4a5060"/>
21 <path d="M6.05 13.42A0.35 0.35 0 1 1 6.75 13.42A0.35 0.35 0 1 1 6.05 13.42Z" fill="#4a5060"/>
22 <path d="M4.5 13.8A0.5 0.5 0 1 1 5.5 13.8A0.5 0.5 0 1 1 4.5 13.8Z" fill="#4a5060"/>
23 <path d="M3.25 13.42A0.35 0.35 0 1 1 3.95 13.42A0.35 0.35 0 1 1 3.25 13.42Z" fill="#4a5060"/>
24 <path d="M2.23 12.4A0.35 0.35 0 1 1 2.93 12.4A0.35 0.35 0 1 1 2.23 12.4Z" fill="#4a5060"/>
25 <path d="M1.7 11A0.5 0.5 0 1 1 2.7 11A0.5 0.5 0 1 1 1.7 11Z" fill="#4a5060"/>
26 <path d="M2.23 9.6A0.35 0.35 0 1 1 2.93 9.6A0.35 0.35 0 1 1 2.23 9.6Z" fill="#4a5060"/>
27 <path d="M3.25 8.58A0.35 0.35 0 1 1 3.95 8.58A0.35 0.35 0 1 1 3.25 8.58Z" fill="#4a5060"/>
28 <path d="M4.5 8.2A0.5 0.5 0 1 1 5.5 8.2A0.5 0.5 0 1 1 4.5 8.2Z" fill="#4a5060"/>
29 <path d="M6.05 8.58A0.35 0.35 0 1 1 6.75 8.58A0.35 0.35 0 1 1 6.05 8.58Z" fill="#4a5060"/>
30 <path d="M7.07 9.6A0.35 0.35 0 1 1 7.77 9.6A0.35 0.35 0 1 1 7.07 9.6Z" fill="#4a5060"/>
31 <path d="M5 8.36V11L6.85 9.9" fill="none" stroke="#20242c" stroke-width="0.6"/>
32</svg>
crates/canvas/assets/tags/orange-square.svg created+19
......@@ -0,0 +1,19 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#e07a4e"/>
5 <stop offset="1" stop-color="#c4643a"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffb08a"/>
9 <stop offset="1" stop-color="#ffb492"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#ff8c5a"/>
13 <stop offset="1" stop-color="#f7844f"/>
14 </linearGradient>
15 </defs>
16 <path d="M3 3H13V13H3Z" fill="url(#layer0)"/>
17 <path d="M3 3H12V12H3Z" fill="url(#layer1)"/>
18 <path d="M4 4H12V12H4Z" fill="url(#layer2)"/>
19</svg>
crates/canvas/assets/tags/paper.svg created+14
......@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.4" x2="0" y2="14.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8ecf4"/></linearGradient>
4 </defs>
5 <path d="M2.6 0.8H10.4L13.6 4V15.2H2.6Z" fill="#7a8494"/>
6 <path d="M3.2 1.4H10.1L13 4.3V14.6H3.2Z" fill="url(#g0)"/>
7 <path d="M10.1 1.4V4.3H13Z" fill="#c4cad6"/>
8 <path d="M4.6 5.4H11.6" fill="none" stroke="#8a9ab8" stroke-width="0.6"/>
9 <path d="M4.6 7H11.6" fill="none" stroke="#8a9ab8" stroke-width="0.6"/>
10 <path d="M4.6 8.6H11.6" fill="none" stroke="#8a9ab8" stroke-width="0.6"/>
11 <path d="M4.6 10.2H11.6" fill="none" stroke="#8a9ab8" stroke-width="0.6"/>
12 <path d="M4.6 11.8H11.6" fill="none" stroke="#8a9ab8" stroke-width="0.6"/>
13 <path d="M4.6 13.4H11.6" fill="none" stroke="#8a9ab8" stroke-width="0.6"/>
14</svg>
crates/canvas/assets/tags/paperclip.svg created+4
......@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M10.2 5.4V12.6C10.2 14.8 5.8 14.8 5.8 12.6V3.4C5.8 1 9.6 1 9.6 3.4V11.4C9.6 12.6 7.6 12.6 7.6 11.4V5.2" fill="none" stroke="#5c6470" stroke-width="1.3"/>
3 <path d="M10.2 5.4V12.6C10.2 14.8 5.8 14.8 5.8 12.6V3.4C5.8 1 9.6 1 9.6 3.4V11.4C9.6 12.6 7.6 12.6 7.6 11.4V5.2" fill="none" stroke="#d8dce4" stroke-width="0.5"/>
4</svg>
crates/canvas/assets/tags/people.svg created+12
......@@ -0,0 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.4" x2="0" y2="6.34" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8dadf"/><stop offset="1" stop-color="#7a7f88"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="6.53" x2="0" y2="11.47" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f4a0a0"/><stop offset="1" stop-color="#c04848"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="4.6" x2="0" y2="9.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8dadf"/><stop offset="1" stop-color="#7a7f88"/></linearGradient>
6 <linearGradient id="g3" x1="0" y1="10" x2="0" y2="15.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9ab8f0"/><stop offset="1" stop-color="#3a64b8"/></linearGradient>
7 </defs>
8 <path d="M7.93 3.87A2.47 2.47 0 1 1 12.87 3.87A2.47 2.47 0 1 1 7.93 3.87Z" fill="url(#g0)"/>
9 <path d="M10.4 6.53C13.63 6.53 14.58 7.86 14.58 9.95V11.47H6.22V9.95C6.22 7.86 7.17 6.53 10.4 6.53Z" fill="url(#g1)"/>
10 <path d="M3 7.2A2.6 2.6 0 1 1 8.2 7.2A2.6 2.6 0 1 1 3 7.2Z" fill="url(#g2)"/>
11 <path d="M5.6 10C9 10 10 11.4 10 13.6V15.2H1.2V13.6C1.2 11.4 2.2 10 5.6 10Z" fill="url(#g3)"/>
12</svg>
crates/canvas/assets/tags/person.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="4.8" x2="0" y2="10" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8dadf"/><stop offset="1" stop-color="#8e939c"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="10.2" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c4d4f0"/><stop offset="1" stop-color="#7c94c4"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="0.6" x2="0" y2="6.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8a7a"/><stop offset="1" stop-color="#d0301a"/></linearGradient>
6 </defs>
7 <path d="M3.8 7.4A2.6 2.6 0 1 1 9 7.4A2.6 2.6 0 1 1 3.8 7.4Z" fill="url(#g0)"/>
8 <path d="M6.4 10.2C9.8 10.2 10.8 11.6 10.8 13.8V15.4H2V13.8C2 11.6 3 10.2 6.4 10.2Z" fill="url(#g1)"/>
9 <path d="M12.2 0.6H14.2L13.6 6.4H12.8Z" fill="url(#g2)"/>
10 <path d="M12.3 8A0.9 0.9 0 1 1 14.1 8A0.9 0.9 0 1 1 12.3 8Z" fill="#d0301a"/>
11</svg>
crates/canvas/assets/tags/phone-clock.svg created+26
......@@ -0,0 +1,26 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.6" x2="0" y2="7.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e4e8ee"/><stop offset="1" stop-color="#8c94a0"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="5.8" x2="0" y2="10" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8dce4"/><stop offset="1" stop-color="#8c94a0"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="5.4" x2="0" y2="15.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c8ccd4"/><stop offset="1" stop-color="#7a808c"/></linearGradient>
6 <linearGradient id="g3" x1="0" y1="5.4" x2="0" y2="15.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8ecf2"/></linearGradient>
7 </defs>
8 <path d="M2 7.4C1.4 5 2.6 3 5.4 2.2C8 1.4 11.4 1.6 13.4 2.8C14.8 3.6 15 5 14.4 6L12.6 6.4L11.8 4.8C10 4.2 7.6 4.4 6 5L5.6 6.8Z" fill="url(#g0)"/>
9 <path d="M2 7.4C1.4 5 2.6 3 5.4 2.2C8 1.4 11.4 1.6 13.4 2.8C14.8 3.6 15 5 14.4 6L12.6 6.4L11.8 4.8C10 4.2 7.6 4.4 6 5L5.6 6.8Z" fill="none" stroke="#5a6270" stroke-width="0.5"/>
10 <path d="M3.6 7.4L6 5.8H10L12.4 7.4V10H3.6Z" fill="url(#g1)"/>
11 <path d="M5 10.6A5.2 5.2 0 1 1 15.4 10.6A5.2 5.2 0 1 1 5 10.6Z" fill="url(#g2)"/>
12 <path d="M5.9 10.6A4.3 4.3 0 1 1 14.5 10.6A4.3 4.3 0 1 1 5.9 10.6Z" fill="url(#g3)"/>
13 <path d="M13.3 10.6A0.5 0.5 0 1 1 14.3 10.6A0.5 0.5 0 1 1 13.3 10.6Z" fill="#4a5060"/>
14 <path d="M12.97 12.4A0.35 0.35 0 1 1 13.67 12.4A0.35 0.35 0 1 1 12.97 12.4Z" fill="#4a5060"/>
15 <path d="M11.65 13.72A0.35 0.35 0 1 1 12.35 13.72A0.35 0.35 0 1 1 11.65 13.72Z" fill="#4a5060"/>
16 <path d="M9.7 14.2A0.5 0.5 0 1 1 10.7 14.2A0.5 0.5 0 1 1 9.7 14.2Z" fill="#4a5060"/>
17 <path d="M8.05 13.72A0.35 0.35 0 1 1 8.75 13.72A0.35 0.35 0 1 1 8.05 13.72Z" fill="#4a5060"/>
18 <path d="M6.73 12.4A0.35 0.35 0 1 1 7.43 12.4A0.35 0.35 0 1 1 6.73 12.4Z" fill="#4a5060"/>
19 <path d="M6.1 10.6A0.5 0.5 0 1 1 7.1 10.6A0.5 0.5 0 1 1 6.1 10.6Z" fill="#4a5060"/>
20 <path d="M6.73 8.8A0.35 0.35 0 1 1 7.43 8.8A0.35 0.35 0 1 1 6.73 8.8Z" fill="#4a5060"/>
21 <path d="M8.05 7.48A0.35 0.35 0 1 1 8.75 7.48A0.35 0.35 0 1 1 8.05 7.48Z" fill="#4a5060"/>
22 <path d="M9.7 7A0.5 0.5 0 1 1 10.7 7A0.5 0.5 0 1 1 9.7 7Z" fill="#4a5060"/>
23 <path d="M11.65 7.48A0.35 0.35 0 1 1 12.35 7.48A0.35 0.35 0 1 1 11.65 7.48Z" fill="#4a5060"/>
24 <path d="M12.97 8.8A0.35 0.35 0 1 1 13.67 8.8A0.35 0.35 0 1 1 12.97 8.8Z" fill="#4a5060"/>
25 <path d="M10.2 7.48V10.6L12.38 9.3" fill="none" stroke="#20242c" stroke-width="0.62"/>
26</svg>
crates/canvas/assets/tags/pink-square.svg created+19
......@@ -0,0 +1,19 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#b13a9f"/>
5 <stop offset="1" stop-color="#962d86"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#e690d9"/>
9 <stop offset="1" stop-color="#e894dc"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#cb45b7"/>
13 <stop offset="1" stop-color="#c23eae"/>
14 </linearGradient>
15 </defs>
16 <path d="M3 3H13V13H3Z" fill="url(#layer0)"/>
17 <path d="M3 3H12V12H3Z" fill="url(#layer1)"/>
18 <path d="M4 4H12V12H4Z" fill="url(#layer2)"/>
19</svg>
crates/canvas/assets/tags/plane.svg created+6
......@@ -0,0 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.4" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b4d0ff"/><stop offset="1" stop-color="#3a6ad0"/></linearGradient>
4 </defs>
5 <path d="M15.2 1C14.4 0.4 13 0.8 12 1.8L9.4 4.4L2.2 2.6L0.8 4L6.6 7.2L3.8 10L1.6 9.6L0.6 10.6L3.4 12.6L5.4 15.4L6.4 14.4L6 12.2L8.8 9.4L12 15.2L13.4 13.8L11.6 6.6L14.2 4C15.2 3 15.6 1.6 15.2 1Z" fill="url(#g0)"/>
6</svg>
crates/canvas/assets/tags/presentation.svg created+15
......@@ -0,0 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2.8" x2="0" y2="10.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e4e8f0"/></linearGradient>
4 </defs>
5 <path d="M0.8 1.2H15.2V2.4H0.8Z" fill="#7a808c"/>
6 <path d="M1.6 2.4H14.4V10.6H1.6Z" fill="#9aa0ac"/>
7 <path d="M2.2 2.8H13.8V10.2H2.2Z" fill="url(#g0)"/>
8 <path d="M8 10.6V13.6M8 13.6L5 15.4M8 13.6L11 15.4" fill="none" stroke="#7a808c" stroke-width="0.9"/>
9 <path d="M3.2 6.4A2.4 2.4 0 1 1 8 6.4A2.4 2.4 0 1 1 3.2 6.4Z" fill="#3a6ad8"/>
10 <path d="M5.6 6.4V4A2.4 2.4 0 0 1 7.9 5.8Z" fill="#e04040"/>
11 <path d="M5.6 6.4L7.9 5.8A2.4 2.4 0 0 1 7.4 8Z" fill="#f0c020"/>
12 <path d="M9.4 4.6H12.6" fill="none" stroke="#8a92a4" stroke-width="0.6"/>
13 <path d="M9.4 6.2H12.6" fill="none" stroke="#8a92a4" stroke-width="0.6"/>
14 <path d="M9.4 7.8H12.6" fill="none" stroke="#8a92a4" stroke-width="0.6"/>
15</svg>
crates/canvas/assets/tags/pushpin.svg created+8
......@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.8" x2="0" y2="12" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a8c8f8"/><stop offset="1" stop-color="#2e62c4"/></linearGradient>
4 </defs>
5 <path d="M6.4 9.6L1.4 14.8" fill="none" stroke="#6a6a6a" stroke-width="1"/>
6 <path d="M4.2 7.8L8.4 12L9.8 10.6C9.6 9.6 9.8 8.6 10.6 7.8L12 6.4C12.9 6.8 14.2 6.6 14.8 5.8C15.6 4.8 15.2 3.2 14 2C12.8 0.8 11.2 0.4 10.2 1.2C9.4 1.8 9.2 3.1 9.6 4L8.2 5.4C7.4 6.2 6.4 6.4 5.4 6.2Z" fill="url(#g0)"/>
7 <path d="M10.7 3.2A1.6 1.1 0 1 1 13.9 3.2A1.6 1.1 0 1 1 10.7 3.2Z" fill="#ffffff" fill-opacity="0.6"/>
8</svg>
crates/canvas/assets/tags/question-balloon.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.2" x2="0" y2="14.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff2b0"/><stop offset="1" stop-color="#e2b43a"/></linearGradient>
4 </defs>
5 <path d="M8 1.2C12.2 1.2 15 3.6 15 6.8C15 10 12.2 12.3 8.4 12.3C7.6 12.3 6.9 12.2 6.2 12L2.4 14.8L3.4 11.2C1.8 10.1 1 8.6 1 6.8C1 3.6 3.8 1.2 8 1.2Z" fill="url(#g0)"/>
6 <path d="M8 1.2C12.2 1.2 15 3.6 15 6.8C15 10 12.2 12.3 8.4 12.3C7.6 12.3 6.9 12.2 6.2 12L2.4 14.8L3.4 11.2C1.8 10.1 1 8.6 1 6.8C1 3.6 3.8 1.2 8 1.2Z" fill="none" stroke="#9a7a28" stroke-width="0.6"/>
7 <path d="M6.2 5.2C6.2 3.4 9.8 3.4 9.8 5.2C9.8 6.6 8 6.6 8 8.2" fill="none" stroke="#2a2a2a" stroke-width="1.1"/>
8 <path d="M7.25 10.1A0.75 0.75 0 1 1 8.75 10.1A0.75 0.75 0 1 1 7.25 10.1Z" fill="#2a2a2a"/>
9</svg>
crates/canvas/assets/tags/quote.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.4" x2="0" y2="14.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9ab8f4"/><stop offset="1" stop-color="#3a64c4"/></linearGradient>
4 </defs>
5 <path d="M4.2 2.2C5.6 1.4 7 1.4 7.6 2.2C8.2 3.2 8.2 5.6 8 7.4C7.6 10.4 5.4 13 2.4 14.2L2 13.4C4 11.6 5 9.8 5.2 8.4C3.2 8.2 1.6 7.4 1.4 6.4C1.4 4 2.6 2.8 4.2 2.2Z" fill="url(#g0)"/>
6 <path d="M11.8 2.2C13.2 1.4 14.6 1.4 15.2 2.2C15.8 3.2 15.8 5.6 15.6 7.4C15.2 10.4 13 13 10 14.2L9.6 13.4C11.6 11.6 12.6 9.8 12.8 8.4C10.8 8.2 9.2 7.4 9 6.4C9 4 10.2 2.8 11.8 2.2Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/research.svg created+14
......@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2" x2="0" y2="14.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b4b4e8"/><stop offset="1" stop-color="#5a5aa8"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1.2" x2="0" y2="14.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8c870"/><stop offset="1" stop-color="#a88420"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="3.2" x2="0" y2="14.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe8a0"/><stop offset="1" stop-color="#c89c30"/></linearGradient>
6 <linearGradient id="g3" x1="0" y1="7.9" x2="0" y2="12.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8f4ff"/><stop offset="1" stop-color="#9ac0ec"/></linearGradient>
7 </defs>
8 <path d="M1 2H5.6V14.4H1Z" fill="url(#g0)"/>
9 <path d="M5.6 1.2H10V14.4H5.6Z" fill="url(#g1)"/>
10 <path d="M9.2 3.2H12.4V14.4H9.2Z" fill="url(#g2)"/>
11 <path d="M12.4 12.4L15.2 15.2" fill="none" stroke="#4a4a4a" stroke-width="1.6"/>
12 <path d="M7.2 10.4A3.2 3.2 0 1 1 13.6 10.4A3.2 3.2 0 1 1 7.2 10.4Z" fill="#5a6070"/>
13 <path d="M7.9 10.4A2.5 2.5 0 1 1 12.9 10.4A2.5 2.5 0 1 1 7.9 10.4Z" fill="url(#g3)"/>
14</svg>
crates/canvas/assets/tags/ribbon.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="8" x2="0" y2="15.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff7a5a"/><stop offset="1" stop-color="#d02810"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="1" x2="0" y2="10" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8a6a"/><stop offset="1" stop-color="#e03a1a"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="3.3" x2="0" y2="7.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff4b0"/><stop offset="1" stop-color="#e8c040"/></linearGradient>
6 </defs>
7 <path d="M5 8L2 14.6L4.4 13.8L5.6 15.6L8 10ZM11 8L14 14.6L11.6 13.8L10.4 15.6L8 10Z" fill="url(#g0)"/>
8 <path d="M3.2 5.6A4.8 4.8 0 1 1 12.8 5.6A4.8 4.8 0 1 1 3.2 5.6Z" fill="#c83218"/>
9 <path d="M3.9 5.6A4.1 4.1 0 1 1 12.1 5.6A4.1 4.1 0 1 1 3.9 5.6Z" fill="url(#g1)"/>
10 <path d="M5.7 5.6A2.3 2.3 0 1 1 10.3 5.6A2.3 2.3 0 1 1 5.7 5.6Z" fill="url(#g2)"/>
11</svg>
crates/canvas/assets/tags/rose.svg created+10
......@@ -0,0 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="7" x2="0" y2="12" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ae07a"/><stop offset="1" stop-color="#2a8a2a"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="0.6" x2="0" y2="8.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e83030"/><stop offset="1" stop-color="#8a0808"/></linearGradient>
5 </defs>
6 <path d="M2 15L10.2 5.6" fill="none" stroke="#2a8a2a" stroke-width="1"/>
7 <path d="M4.6 11.8C2.6 11.6 2 9 3.4 7C4.6 8.4 5 10.2 4.6 11.8Z" fill="url(#g0)"/>
8 <path d="M7.6 4.4A3.8 3.8 0 1 1 15.2 4.4A3.8 3.8 0 1 1 7.6 4.4Z" fill="url(#g1)"/>
9 <path d="M9.6 3.4C10.6 2.4 12.6 2.6 13 4.2C13.4 5.8 11.6 6.6 10.6 5.6C9.8 4.8 10.8 3.8 11.6 4.4" fill="none" stroke="#6a0404" stroke-width="0.6"/>
10</svg>
crates/canvas/assets/tags/schedule.svg created+30
......@@ -0,0 +1,30 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="4" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e4eaf4"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="4" x2="0" y2="6.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8fb1ec"/><stop offset="1" stop-color="#4d78c8"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="0.9" x2="0" y2="8.3" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c8ccd4"/><stop offset="1" stop-color="#7a808c"/></linearGradient>
6 <linearGradient id="g3" x1="0" y1="0.9" x2="0" y2="8.3" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8ecf2"/></linearGradient>
7 </defs>
8 <path d="M4 4H15.4V15H4Z" fill="#7a8aac"/>
9 <path d="M4.5 4.5H14.9V14.5H4.5Z" fill="url(#g0)"/>
10 <path d="M4.5 4.5H14.9V6.2H4.5Z" fill="url(#g1)"/>
11 <path d="M6.2 8H8M9.4 8H13.4" fill="none" stroke="#8a9ab8" stroke-width="0.8"/>
12 <path d="M6.2 10.4H8M9.4 10.4H13.4" fill="none" stroke="#8a9ab8" stroke-width="0.8"/>
13 <path d="M6.2 12.8H8M9.4 12.8H13.4" fill="none" stroke="#8a9ab8" stroke-width="0.8"/>
14 <path d="M0.4 4.6A4.2 4.2 0 1 1 8.8 4.6A4.2 4.2 0 1 1 0.4 4.6Z" fill="#d03030"/>
15 <path d="M0.9 4.6A3.7 3.7 0 1 1 8.3 4.6A3.7 3.7 0 1 1 0.9 4.6Z" fill="url(#g2)"/>
16 <path d="M1.8 4.6A2.8 2.8 0 1 1 7.4 4.6A2.8 2.8 0 1 1 1.8 4.6Z" fill="url(#g3)"/>
17 <path d="M6.2 4.6A0.5 0.5 0 1 1 7.2 4.6A0.5 0.5 0 1 1 6.2 4.6Z" fill="#4a5060"/>
18 <path d="M6.07 5.65A0.35 0.35 0 1 1 6.77 5.65A0.35 0.35 0 1 1 6.07 5.65Z" fill="#4a5060"/>
19 <path d="M5.3 6.42A0.35 0.35 0 1 1 6 6.42A0.35 0.35 0 1 1 5.3 6.42Z" fill="#4a5060"/>
20 <path d="M4.1 6.7A0.5 0.5 0 1 1 5.1 6.7A0.5 0.5 0 1 1 4.1 6.7Z" fill="#4a5060"/>
21 <path d="M3.2 6.42A0.35 0.35 0 1 1 3.9 6.42A0.35 0.35 0 1 1 3.2 6.42Z" fill="#4a5060"/>
22 <path d="M2.43 5.65A0.35 0.35 0 1 1 3.13 5.65A0.35 0.35 0 1 1 2.43 5.65Z" fill="#4a5060"/>
23 <path d="M2 4.6A0.5 0.5 0 1 1 3 4.6A0.5 0.5 0 1 1 2 4.6Z" fill="#4a5060"/>
24 <path d="M2.43 3.55A0.35 0.35 0 1 1 3.13 3.55A0.35 0.35 0 1 1 2.43 3.55Z" fill="#4a5060"/>
25 <path d="M3.2 2.78A0.35 0.35 0 1 1 3.9 2.78A0.35 0.35 0 1 1 3.2 2.78Z" fill="#4a5060"/>
26 <path d="M4.1 2.5A0.5 0.5 0 1 1 5.1 2.5A0.5 0.5 0 1 1 4.1 2.5Z" fill="#4a5060"/>
27 <path d="M5.3 2.78A0.35 0.35 0 1 1 6 2.78A0.35 0.35 0 1 1 5.3 2.78Z" fill="#4a5060"/>
28 <path d="M6.07 3.55A0.35 0.35 0 1 1 6.77 3.55A0.35 0.35 0 1 1 6.07 3.55Z" fill="#4a5060"/>
29 <path d="M4.6 2.38V4.6L6.15 3.67" fill="none" stroke="#20242c" stroke-width="0.6"/>
30</svg>
crates/canvas/assets/tags/smiley.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff27a"/><stop offset="1" stop-color="#e8b400"/></linearGradient>
4 </defs>
5 <path d="M0.6 8A7.4 7.4 0 1 1 15.4 8A7.4 7.4 0 1 1 0.6 8Z" fill="#b88a00"/>
6 <path d="M1.2 8A6.8 6.8 0 1 1 14.8 8A6.8 6.8 0 1 1 1.2 8Z" fill="url(#g0)"/>
7 <path d="M3.8 4.6A4.2 2.6 0 1 1 12.2 4.6A4.2 2.6 0 1 1 3.8 4.6Z" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M5 6.8A0.8 1.1 0 1 1 6.6 6.8A0.8 1.1 0 1 1 5 6.8Z" fill="#2a2008"/>
9 <path d="M9.4 6.8A0.8 1.1 0 1 1 11 6.8A0.8 1.1 0 1 1 9.4 6.8Z" fill="#2a2008"/>
10 <path d="M4.4 9.6C5.8 12.6 10.2 12.6 11.6 9.6" fill="none" stroke="#2a2008" stroke-width="0.9"/>
11</svg>
crates/canvas/assets/tags/solid-target-blue.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.5" x2="0" y2="14.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8eaee9"/><stop offset="1" stop-color="#4b78c9"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="4.8" x2="0" y2="11.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d3e1fa"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
5 </defs>
6 <path d="M0.8 8A7.2 7.2 0 1 1 15.2 8A7.2 7.2 0 1 1 0.8 8Z" fill="#3d62ad"/>
7 <path d="M1.5 8A6.5 6.5 0 1 1 14.5 8A6.5 6.5 0 1 1 1.5 8Z" fill="url(#g0)"/>
8 <path d="M3.6 8A4.4 4.4 0 1 1 12.4 8A4.4 4.4 0 1 1 3.6 8Z" fill="#ffffff"/>
9 <path d="M4.8 8A3.2 3.2 0 1 1 11.2 8A3.2 3.2 0 1 1 4.8 8Z" fill="url(#g1)"/>
10 <path d="M4.4 4.1A3.6 1.8 0 1 1 11.6 4.1A3.6 1.8 0 1 1 4.4 4.1Z" fill="#ffffff" fill-opacity="0.35"/>
11</svg>
crates/canvas/assets/tags/solid-target-green.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.5" x2="0" y2="14.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a4da8c"/><stop offset="1" stop-color="#5aa83c"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="4.8" x2="0" y2="11.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dcf2d0"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
5 </defs>
6 <path d="M0.8 8A7.2 7.2 0 1 1 15.2 8A7.2 7.2 0 1 1 0.8 8Z" fill="#3f8a2c"/>
7 <path d="M1.5 8A6.5 6.5 0 1 1 14.5 8A6.5 6.5 0 1 1 1.5 8Z" fill="url(#g0)"/>
8 <path d="M3.6 8A4.4 4.4 0 1 1 12.4 8A4.4 4.4 0 1 1 3.6 8Z" fill="#ffffff"/>
9 <path d="M4.8 8A3.2 3.2 0 1 1 11.2 8A3.2 3.2 0 1 1 4.8 8Z" fill="url(#g1)"/>
10 <path d="M4.4 4.1A3.6 1.8 0 1 1 11.6 4.1A3.6 1.8 0 1 1 4.4 4.1Z" fill="#ffffff" fill-opacity="0.35"/>
11</svg>
crates/canvas/assets/tags/solid-target-yellow.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.5" x2="0" y2="14.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f7d66e"/><stop offset="1" stop-color="#e1a81f"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="4.8" x2="0" y2="11.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcf0c0"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>
5 </defs>
6 <path d="M0.8 8A7.2 7.2 0 1 1 15.2 8A7.2 7.2 0 1 1 0.8 8Z" fill="#b0801a"/>
7 <path d="M1.5 8A6.5 6.5 0 1 1 14.5 8A6.5 6.5 0 1 1 1.5 8Z" fill="url(#g0)"/>
8 <path d="M3.6 8A4.4 4.4 0 1 1 12.4 8A4.4 4.4 0 1 1 3.6 8Z" fill="#ffffff"/>
9 <path d="M4.8 8A3.2 3.2 0 1 1 11.2 8A3.2 3.2 0 1 1 4.8 8Z" fill="url(#g1)"/>
10 <path d="M4.4 4.1A3.6 1.8 0 1 1 11.6 4.1A3.6 1.8 0 1 1 4.4 4.1Z" fill="#ffffff" fill-opacity="0.35"/>
11</svg>
crates/canvas/assets/tags/star-blue.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2" x2="0" y2="14" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d3e1fa"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
4 </defs>
5 <path d="M8 0.8L10.32 5.5L15.51 6.26L11.76 9.92L12.64 15.09L8 12.65L3.36 15.09L4.24 9.92L0.49 6.26L5.68 5.5Z" fill="#3d62ad"/>
6 <path d="M8 2.2L9.94 6.13L14.28 6.76L11.14 9.82L11.88 14.14L8 12.1L4.12 14.14L4.86 9.82L1.72 6.76L6.06 6.13Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/star-green.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="2" x2="0" y2="14" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dcf2d0"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
4 </defs>
5 <path d="M8 0.8L10.32 5.5L15.51 6.26L11.76 9.92L12.64 15.09L8 12.65L3.36 15.09L4.24 9.92L0.49 6.26L5.68 5.5Z" fill="#3f8a2c"/>
6 <path d="M8 2.2L9.94 6.13L14.28 6.76L11.14 9.82L11.88 14.14L8 12.1L4.12 14.14L4.86 9.82L1.72 6.76L6.06 6.13Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/star8-blue.svg created+4
......@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 1.2V14.8M1.2 8H14.8M3.2 3.2L12.8 12.8M12.8 3.2L3.2 12.8" fill="none" stroke="#4b78c9" stroke-width="1"/>
3 <path d="M6.6 8A1.4 1.4 0 1 1 9.4 8A1.4 1.4 0 1 1 6.6 8Z" fill="#3d62ad"/>
4</svg>
crates/canvas/assets/tags/star8-yellow.svg created+4
......@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 1.2V14.8M1.2 8H14.8M3.2 3.2L12.8 12.8M12.8 3.2L3.2 12.8" fill="none" stroke="#e1a81f" stroke-width="1"/>
3 <path d="M6.6 8A1.4 1.4 0 1 1 9.4 8A1.4 1.4 0 1 1 6.6 8Z" fill="#b0801a"/>
4</svg>
crates/canvas/assets/tags/sun-blue.svg created+16
......@@ -0,0 +1,16 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="4.1" x2="0" y2="11.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0c8f2"/><stop offset="1" stop-color="#5c86d1"/></linearGradient>
4 </defs>
5 <path d="M12.4 8L15.4 8" fill="none" stroke="#4b78c9" stroke-width="0.9"/>
6 <path d="M11.11 11.11L13.23 13.23" fill="none" stroke="#4b78c9" stroke-width="0.9"/>
7 <path d="M8 12.4L8 15.4" fill="none" stroke="#4b78c9" stroke-width="0.9"/>
8 <path d="M4.89 11.11L2.77 13.23" fill="none" stroke="#4b78c9" stroke-width="0.9"/>
9 <path d="M3.6 8L0.6 8" fill="none" stroke="#4b78c9" stroke-width="0.9"/>
10 <path d="M4.89 4.89L2.77 2.77" fill="none" stroke="#4b78c9" stroke-width="0.9"/>
11 <path d="M8 3.6L8 0.6" fill="none" stroke="#4b78c9" stroke-width="0.9"/>
12 <path d="M11.11 4.89L13.23 2.77" fill="none" stroke="#4b78c9" stroke-width="0.9"/>
13 <path d="M4.1 8A3.9 3.9 0 1 1 11.9 8A3.9 3.9 0 1 1 4.1 8Z" fill="#3d62ad"/>
14 <path d="M4.8 8A3.2 3.2 0 1 1 11.2 8A3.2 3.2 0 1 1 4.8 8Z" fill="url(#g0)"/>
15 <path d="M5.58 6.52A2.42 1.56 0 1 1 10.42 6.52A2.42 1.56 0 1 1 5.58 6.52Z" fill="#ffffff" fill-opacity="0.45"/>
16</svg>
crates/canvas/assets/tags/sun-green.svg created+16
......@@ -0,0 +1,16 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="4.1" x2="0" y2="11.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c0e6ae"/><stop offset="1" stop-color="#6cb450"/></linearGradient>
4 </defs>
5 <path d="M12.4 8L15.4 8" fill="none" stroke="#5aa83c" stroke-width="0.9"/>
6 <path d="M11.11 11.11L13.23 13.23" fill="none" stroke="#5aa83c" stroke-width="0.9"/>
7 <path d="M8 12.4L8 15.4" fill="none" stroke="#5aa83c" stroke-width="0.9"/>
8 <path d="M4.89 11.11L2.77 13.23" fill="none" stroke="#5aa83c" stroke-width="0.9"/>
9 <path d="M3.6 8L0.6 8" fill="none" stroke="#5aa83c" stroke-width="0.9"/>
10 <path d="M4.89 4.89L2.77 2.77" fill="none" stroke="#5aa83c" stroke-width="0.9"/>
11 <path d="M8 3.6L8 0.6" fill="none" stroke="#5aa83c" stroke-width="0.9"/>
12 <path d="M11.11 4.89L13.23 2.77" fill="none" stroke="#5aa83c" stroke-width="0.9"/>
13 <path d="M4.1 8A3.9 3.9 0 1 1 11.9 8A3.9 3.9 0 1 1 4.1 8Z" fill="#3f8a2c"/>
14 <path d="M4.8 8A3.2 3.2 0 1 1 11.2 8A3.2 3.2 0 1 1 4.8 8Z" fill="url(#g0)"/>
15 <path d="M5.58 6.52A2.42 1.56 0 1 1 10.42 6.52A2.42 1.56 0 1 1 5.58 6.52Z" fill="#ffffff" fill-opacity="0.45"/>
16</svg>
crates/canvas/assets/tags/sun-yellow.svg created+16
......@@ -0,0 +1,16 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="4.1" x2="0" y2="11.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fae397"/><stop offset="1" stop-color="#e6b433"/></linearGradient>
4 </defs>
5 <path d="M12.4 8L15.4 8" fill="none" stroke="#e1a81f" stroke-width="0.9"/>
6 <path d="M11.11 11.11L13.23 13.23" fill="none" stroke="#e1a81f" stroke-width="0.9"/>
7 <path d="M8 12.4L8 15.4" fill="none" stroke="#e1a81f" stroke-width="0.9"/>
8 <path d="M4.89 11.11L2.77 13.23" fill="none" stroke="#e1a81f" stroke-width="0.9"/>
9 <path d="M3.6 8L0.6 8" fill="none" stroke="#e1a81f" stroke-width="0.9"/>
10 <path d="M4.89 4.89L2.77 2.77" fill="none" stroke="#e1a81f" stroke-width="0.9"/>
11 <path d="M8 3.6L8 0.6" fill="none" stroke="#e1a81f" stroke-width="0.9"/>
12 <path d="M11.11 4.89L13.23 2.77" fill="none" stroke="#e1a81f" stroke-width="0.9"/>
13 <path d="M4.1 8A3.9 3.9 0 1 1 11.9 8A3.9 3.9 0 1 1 4.1 8Z" fill="#b0801a"/>
14 <path d="M4.8 8A3.2 3.2 0 1 1 11.2 8A3.2 3.2 0 1 1 4.8 8Z" fill="url(#g0)"/>
15 <path d="M5.58 6.52A2.42 1.56 0 1 1 10.42 6.52A2.42 1.56 0 1 1 5.58 6.52Z" fill="#ffffff" fill-opacity="0.45"/>
16</svg>
crates/canvas/assets/tags/sunflower.svg created+31
......@@ -0,0 +1,31 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="5.4" x2="0" y2="10.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="7.57" x2="0" y2="12.77" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
5 <linearGradient id="g2" x1="0" y1="9.31" x2="0" y2="14.51" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
6 <linearGradient id="g3" x1="0" y1="10.27" x2="0" y2="15.47" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
7 <linearGradient id="g4" x1="0" y1="3.23" x2="0" y2="8.43" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
8 <linearGradient id="g5" x1="0" y1="1.49" x2="0" y2="6.69" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
9 <linearGradient id="g6" x1="0" y1="0.53" x2="0" y2="5.73" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
10 <linearGradient id="g7" x1="0" y1="0.53" x2="0" y2="5.73" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
11 <linearGradient id="g8" x1="0" y1="1.49" x2="0" y2="6.69" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
12 <linearGradient id="g9" x1="0" y1="3.23" x2="0" y2="8.43" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#e0a010"/></linearGradient>
13 <linearGradient id="g10" x1="0" y1="5.2" x2="0" y2="10.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8a5a20"/><stop offset="1" stop-color="#4a2a08"/></linearGradient>
14 </defs>
15 <path d="M15.6 8L13 9.3L10.4 8L13 6.7Z" fill="url(#g0)"/>
16 <path d="M14.85 11.3L11.94 11.34L10.16 9.04L13.07 9Z" fill="url(#g1)"/>
17 <path d="M12.74 13.94L10.1 12.72L9.5 9.88L12.13 11.1Z" fill="url(#g2)"/>
18 <path d="M9.69 15.41L7.85 13.16L8.53 10.34L10.38 12.59Z" fill="url(#g3)"/>
19 <path d="M6.31 15.41L5.62 12.59L7.47 10.34L8.15 13.16Z" fill="url(#g3)"/>
20 <path d="M3.26 13.94L3.87 11.1L6.5 9.88L5.9 12.72Z" fill="url(#g2)"/>
21 <path d="M1.15 11.3L2.93 9L5.84 9.04L4.06 11.34Z" fill="url(#g1)"/>
22 <path d="M0.4 8L3 6.7L5.6 8L3 9.3Z" fill="url(#g0)"/>
23 <path d="M1.15 4.7L4.06 4.66L5.84 6.96L2.93 7Z" fill="url(#g4)"/>
24 <path d="M3.26 2.06L5.9 3.28L6.5 6.12L3.87 4.9Z" fill="url(#g5)"/>
25 <path d="M6.31 0.59L8.15 2.84L7.47 5.66L5.62 3.41Z" fill="url(#g6)"/>
26 <path d="M9.69 0.59L10.38 3.41L8.53 5.66L7.85 2.84Z" fill="url(#g7)"/>
27 <path d="M12.74 2.06L12.13 4.9L9.5 6.12L10.1 3.28Z" fill="url(#g8)"/>
28 <path d="M14.85 4.7L13.07 7L10.16 6.96L11.94 4.66Z" fill="url(#g9)"/>
29 <path d="M4.4 8A3.6 3.6 0 1 1 11.6 8A3.6 3.6 0 1 1 4.4 8Z" fill="#3a2408"/>
30 <path d="M5.2 8A2.8 2.8 0 1 1 10.8 8A2.8 2.8 0 1 1 5.2 8Z" fill="url(#g10)"/>
31</svg>
crates/canvas/assets/tags/target-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.5" x2="0" y2="14.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d3e1fa"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
4 </defs>
5 <path d="M0.8 8A7.2 7.2 0 1 1 15.2 8A7.2 7.2 0 1 1 0.8 8Z" fill="#3d62ad"/>
6 <path d="M1.5 8A6.5 6.5 0 1 1 14.5 8A6.5 6.5 0 1 1 1.5 8Z" fill="url(#g0)"/>
7 <path d="M4.6 8A3.4 3.4 0 1 1 11.4 8A3.4 3.4 0 1 1 4.6 8Z" fill="#3d62ad"/>
8 <path d="M5.3 8A2.7 2.7 0 1 1 10.7 8A2.7 2.7 0 1 1 5.3 8Z" fill="#ffffff"/>
9</svg>
crates/canvas/assets/tags/target-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.5" x2="0" y2="14.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dcf2d0"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
4 </defs>
5 <path d="M0.8 8A7.2 7.2 0 1 1 15.2 8A7.2 7.2 0 1 1 0.8 8Z" fill="#3f8a2c"/>
6 <path d="M1.5 8A6.5 6.5 0 1 1 14.5 8A6.5 6.5 0 1 1 1.5 8Z" fill="url(#g0)"/>
7 <path d="M4.6 8A3.4 3.4 0 1 1 11.4 8A3.4 3.4 0 1 1 4.6 8Z" fill="#3f8a2c"/>
8 <path d="M5.3 8A2.7 2.7 0 1 1 10.7 8A2.7 2.7 0 1 1 5.3 8Z" fill="#ffffff"/>
9</svg>
crates/canvas/assets/tags/target-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.5" x2="0" y2="14.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcf0c0"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>
4 </defs>
5 <path d="M0.8 8A7.2 7.2 0 1 1 15.2 8A7.2 7.2 0 1 1 0.8 8Z" fill="#b0801a"/>
6 <path d="M1.5 8A6.5 6.5 0 1 1 14.5 8A6.5 6.5 0 1 1 1.5 8Z" fill="url(#g0)"/>
7 <path d="M4.6 8A3.4 3.4 0 1 1 11.4 8A3.4 3.4 0 1 1 4.6 8Z" fill="#b0801a"/>
8 <path d="M5.3 8A2.7 2.7 0 1 1 10.7 8A2.7 2.7 0 1 1 5.3 8Z" fill="#ffffff"/>
9</svg>
crates/canvas/assets/tags/tick-blue.svg created+6
......@@ -0,0 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.3" x2="0" y2="14.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8eaee9"/><stop offset="1" stop-color="#3d62ad"/></linearGradient>
4 </defs>
5 <path d="M1.6 8.6L3.7 7.2L6.4 10.6L12.8 1.3L14.6 2.5L6.6 14.4Z" fill="url(#g0)"/>
6</svg>
crates/canvas/assets/tags/tick-green.svg created+6
......@@ -0,0 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.3" x2="0" y2="14.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a4da8c"/><stop offset="1" stop-color="#3f8a2c"/></linearGradient>
4 </defs>
5 <path d="M1.6 8.6L3.7 7.2L6.4 10.6L12.8 1.3L14.6 2.5L6.6 14.4Z" fill="url(#g0)"/>
6</svg>
crates/canvas/assets/tags/tick-yellow.svg created+6
......@@ -0,0 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.3" x2="0" y2="14.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f7d66e"/><stop offset="1" stop-color="#b0801a"/></linearGradient>
4 </defs>
5 <path d="M1.6 8.6L3.7 7.2L6.4 10.6L12.8 1.3L14.6 2.5L6.6 14.4Z" fill="url(#g0)"/>
6</svg>
crates/canvas/assets/tags/triangle-blue.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="3" x2="0" y2="14" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d3e1fa"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>
4 </defs>
5 <path d="M8 1.2L15.3 14.4L0.7 14.4Z" fill="#3d62ad"/>
6 <path d="M8 2.9L13.9 13.6L2.1 13.6Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/triangle-green.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="3" x2="0" y2="14" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dcf2d0"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>
4 </defs>
5 <path d="M8 1.2L15.3 14.4L0.7 14.4Z" fill="#3f8a2c"/>
6 <path d="M8 2.9L13.9 13.6L2.1 13.6Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/triangle-yellow.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="3" x2="0" y2="14" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcf0c0"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>
4 </defs>
5 <path d="M8 1.2L15.3 14.4L0.7 14.4Z" fill="#b0801a"/>
6 <path d="M8 2.9L13.9 13.6L2.1 13.6Z" fill="url(#g0)"/>
7</svg>
crates/canvas/assets/tags/umbrella-blue.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.6" x2="0" y2="8.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d3e1fa"/><stop offset="1" stop-color="#4b78c9"/></linearGradient>
4 </defs>
5 <path d="M0.8 8.2C1 4.3 4.1 1.6 8 1.6C11.9 1.6 15 4.3 15.2 8.2C14 7.4 12.7 7.4 11.6 8.2C10.4 7.4 9.2 7.4 8 8.2C6.8 7.4 5.6 7.4 4.4 8.2C3.3 7.4 2 7.4 0.8 8.2Z" fill="url(#g0)"/>
6 <path d="M0.8 8.2C1 4.3 4.1 1.6 8 1.6C11.9 1.6 15 4.3 15.2 8.2C14 7.4 12.7 7.4 11.6 8.2C10.4 7.4 9.2 7.4 8 8.2C6.8 7.4 5.6 7.4 4.4 8.2C3.3 7.4 2 7.4 0.8 8.2Z" fill="none" stroke="#3d62ad" stroke-width="0.6"/>
7 <path d="M8 1.6V0.6" fill="none" stroke="#3d62ad" stroke-width="0.8"/>
8 <path d="M8 8.1V13.3C8 15.1 5.6 15.2 5.4 13.6" fill="none" stroke="#3d62ad" stroke-width="1.1"/>
9</svg>
crates/canvas/assets/tags/umbrella-green.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.6" x2="0" y2="8.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dcf2d0"/><stop offset="1" stop-color="#5aa83c"/></linearGradient>
4 </defs>
5 <path d="M0.8 8.2C1 4.3 4.1 1.6 8 1.6C11.9 1.6 15 4.3 15.2 8.2C14 7.4 12.7 7.4 11.6 8.2C10.4 7.4 9.2 7.4 8 8.2C6.8 7.4 5.6 7.4 4.4 8.2C3.3 7.4 2 7.4 0.8 8.2Z" fill="url(#g0)"/>
6 <path d="M0.8 8.2C1 4.3 4.1 1.6 8 1.6C11.9 1.6 15 4.3 15.2 8.2C14 7.4 12.7 7.4 11.6 8.2C10.4 7.4 9.2 7.4 8 8.2C6.8 7.4 5.6 7.4 4.4 8.2C3.3 7.4 2 7.4 0.8 8.2Z" fill="none" stroke="#3f8a2c" stroke-width="0.6"/>
7 <path d="M8 1.6V0.6" fill="none" stroke="#3f8a2c" stroke-width="0.8"/>
8 <path d="M8 8.1V13.3C8 15.1 5.6 15.2 5.4 13.6" fill="none" stroke="#3f8a2c" stroke-width="1.1"/>
9</svg>
crates/canvas/assets/tags/umbrella-yellow.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1.6" x2="0" y2="8.2" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcf0c0"/><stop offset="1" stop-color="#e1a81f"/></linearGradient>
4 </defs>
5 <path d="M0.8 8.2C1 4.3 4.1 1.6 8 1.6C11.9 1.6 15 4.3 15.2 8.2C14 7.4 12.7 7.4 11.6 8.2C10.4 7.4 9.2 7.4 8 8.2C6.8 7.4 5.6 7.4 4.4 8.2C3.3 7.4 2 7.4 0.8 8.2Z" fill="url(#g0)"/>
6 <path d="M0.8 8.2C1 4.3 4.1 1.6 8 1.6C11.9 1.6 15 4.3 15.2 8.2C14 7.4 12.7 7.4 11.6 8.2C10.4 7.4 9.2 7.4 8 8.2C6.8 7.4 5.6 7.4 4.4 8.2C3.3 7.4 2 7.4 0.8 8.2Z" fill="none" stroke="#b0801a" stroke-width="0.6"/>
7 <path d="M8 1.6V0.6" fill="none" stroke="#b0801a" stroke-width="0.8"/>
8 <path d="M8 8.1V13.3C8 15.1 5.6 15.2 5.4 13.6" fill="none" stroke="#b0801a" stroke-width="1.1"/>
9</svg>
crates/canvas/assets/tags/x-blue.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.8 2.8L13.2 13.2M13.2 2.8L2.8 13.2" fill="none" stroke="#4b78c9" stroke-width="1.7"/>
3</svg>
crates/canvas/assets/tags/x-dots-blue.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3.8 3.8L12.2 12.2M12.2 3.8L3.8 12.2" fill="none" stroke="#4b78c9" stroke-width="1.7"/>
3 <path d="M6.9 2.4A1.1 1.1 0 1 1 9.1 2.4A1.1 1.1 0 1 1 6.9 2.4Z" fill="#4b78c9"/>
4 <path d="M6.9 13.6A1.1 1.1 0 1 1 9.1 13.6A1.1 1.1 0 1 1 6.9 13.6Z" fill="#4b78c9"/>
5 <path d="M1.3 8A1.1 1.1 0 1 1 3.5 8A1.1 1.1 0 1 1 1.3 8Z" fill="#4b78c9"/>
6 <path d="M12.5 8A1.1 1.1 0 1 1 14.7 8A1.1 1.1 0 1 1 12.5 8Z" fill="#4b78c9"/>
7</svg>
crates/canvas/assets/tags/x-dots-green.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3.8 3.8L12.2 12.2M12.2 3.8L3.8 12.2" fill="none" stroke="#5aa83c" stroke-width="1.7"/>
3 <path d="M6.9 2.4A1.1 1.1 0 1 1 9.1 2.4A1.1 1.1 0 1 1 6.9 2.4Z" fill="#5aa83c"/>
4 <path d="M6.9 13.6A1.1 1.1 0 1 1 9.1 13.6A1.1 1.1 0 1 1 6.9 13.6Z" fill="#5aa83c"/>
5 <path d="M1.3 8A1.1 1.1 0 1 1 3.5 8A1.1 1.1 0 1 1 1.3 8Z" fill="#5aa83c"/>
6 <path d="M12.5 8A1.1 1.1 0 1 1 14.7 8A1.1 1.1 0 1 1 12.5 8Z" fill="#5aa83c"/>
7</svg>
crates/canvas/assets/tags/x-dots-yellow.svg created+7
......@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3.8 3.8L12.2 12.2M12.2 3.8L3.8 12.2" fill="none" stroke="#e1a81f" stroke-width="1.7"/>
3 <path d="M6.9 2.4A1.1 1.1 0 1 1 9.1 2.4A1.1 1.1 0 1 1 6.9 2.4Z" fill="#e1a81f"/>
4 <path d="M6.9 13.6A1.1 1.1 0 1 1 9.1 13.6A1.1 1.1 0 1 1 6.9 13.6Z" fill="#e1a81f"/>
5 <path d="M1.3 8A1.1 1.1 0 1 1 3.5 8A1.1 1.1 0 1 1 1.3 8Z" fill="#e1a81f"/>
6 <path d="M12.5 8A1.1 1.1 0 1 1 14.7 8A1.1 1.1 0 1 1 12.5 8Z" fill="#e1a81f"/>
7</svg>
crates/canvas/assets/tags/x-green.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.8 2.8L13.2 13.2M13.2 2.8L2.8 13.2" fill="none" stroke="#5aa83c" stroke-width="1.7"/>
3</svg>
crates/canvas/assets/tags/x-yellow.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.8 2.8L13.2 13.2M13.2 2.8L2.8 13.2" fill="none" stroke="#e1a81f" stroke-width="1.7"/>
3</svg>
crates/canvas/src/bin/page_probe.rs+3
......@@ -65,6 +65,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
6565 PageObject::Image(image) => objects.push(json!({"kind": "image", "id": image.id,
6666 "layout": image.layout, "bytes": image.bytes.as_ref().map(|b| b.len()),
6767 "alt": image.alt, "background": image.background})),
68 PageObject::Attachment(file) => objects.push(json!({"kind": "file", "id": file.id,
69 "layout": file.layout, "filename": file.filename,
70 "bytes": file.bytes.as_ref().map(|b| b.len())})),
6871 PageObject::Ink(ink) => objects.push(json!({"kind": "ink", "id": ink.id,
6972 "bounds": ink.bounds(), "strokes": ink.strokes.len(), "groups": ink.groups.len()})),
7073 PageObject::Unsupported(object) => objects.push(json!({"kind": "unsupported",
crates/canvas/src/editor.rs+616-76
......@@ -117,6 +117,9 @@ pub struct CanvasEditor {
117117 /// What Select All widened to, holding until the selection is moved or set, and while the
118118 /// focused outline and its selection stay as they were then.
119119 whole: Option<(Whole, ExGuid, Selection)>,
120 /// The outline, caret and undo depth a run of typing left, which typing there joins as
121 /// OneNote 2010 does until the caret moves or another kind of edit comes between.
122 typing: Option<(ExGuid, TextPosition, usize)>,
120123}
121124
122125/// Imported page state the editable content does not carry.
......@@ -804,6 +807,12 @@ enum History {
804807 index: usize,
805808 image: Option<Box<onestore::page::Image>>,
806809 },
810 /// Restores the file on the page at `index` in paint order, or removes the one there
811 /// when `None`.
812 File {
813 index: usize,
814 file: Option<Box<page::Content>>,
815 },
807816 Remove {
808817 outline: onestore::ExGuid,
809818 focus: RestoreFocus,
......@@ -860,6 +869,7 @@ impl CanvasEditor {
860869 ops: Ok(Vec::new()),
861870 stored: None,
862871 whole: None,
872 typing: None,
863873 })
864874 }
865875
......@@ -877,6 +887,7 @@ impl CanvasEditor {
877887 .iter()
878888 .filter_map(|object| match object {
879889 page::Content::ReadOnly(object) => Some(object.rect()[2]),
890 page::Content::File { .. } => object.file().map(|(_, rect)| rect[2]),
880891 page::Content::Ink(ink) => page::ink_bounds(ink).map(|b| b[2]),
881892 _ => None,
882893 })
......@@ -1047,7 +1058,9 @@ impl CanvasEditor {
10471058 let reaches_one = |history: &History| match history {
10481059 History::Date(_) => date_changed,
10491060 History::Paper { .. } => false,
1050 History::Image { .. } | History::Picture { .. } => objects_changed,
1061 History::Image { .. } | History::Picture { .. } | History::File { .. } => {
1062 objects_changed
1063 }
10511064 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),
10521065 History::Text { outline, change } => {
10531066 changed.contains(outline)
......@@ -1070,10 +1083,15 @@ impl CanvasEditor {
10701083 History::Group { entries, .. } => entries.iter().any(reaches_one),
10711084 history => reaches_one(history),
10721085 };
1086 // A typing run goes on while the change leaves its undo step alone.
1087 let run = self
1088 .typing
1089 .filter(|_| self.undo.last().is_some_and(|last| !reaches(last)));
10731090 fresh.undo = std::mem::take(&mut self.undo)
10741091 .into_iter()
10751092 .filter(|history| !reaches(history))
10761093 .collect();
1094 fresh.typing = run.map(|(outline, caret, _)| (outline, caret, fresh.undo.len()));
10771095 fresh.redo = std::mem::take(&mut self.redo)
10781096 .into_iter()
10791097 .filter(|history| !reaches(history))
......@@ -1108,6 +1126,10 @@ impl CanvasEditor {
11081126 objects.push(PageObject::Image(image.clone()));
11091127 continue;
11101128 }
1129 page::Content::File { source, .. } => {
1130 objects.push(PageObject::Attachment(source.clone()));
1131 continue;
1132 }
11111133 page::Content::Ink(ink) => {
11121134 objects.push(PageObject::Ink(ink.clone()));
11131135 continue;
......@@ -1423,6 +1445,7 @@ impl CanvasEditor {
14231445 ops: Ok(Vec::new()),
14241446 stored: None,
14251447 whole: None,
1448 typing: None,
14261449 })
14271450 }
14281451
......@@ -1455,23 +1478,99 @@ impl CanvasEditor {
14551478 Ok(true)
14561479 }
14571480
1458 /// Gives the page `color` (View, Page Color) and `rule_lines` (View, Rule Lines) as one
1459 /// undo step; false when it had them.
1481 /// Gives the page `color` (View, Page Color), `rule_lines` (View, Rule Lines) and, when
1482 /// given, `art` in place of its background pictures, as one undo step; false when it had
1483 /// them.
14601484 pub fn set_paper(
14611485 &mut self,
14621486 color: Option<u32>,
14631487 rule_lines: Option<onestore::page::RuleLines>,
1488 art: Option<Vec<onestore::page::Image>>,
14641489 ) -> bool {
1465 if (color, rule_lines) == (self.header.color, self.header.rule_lines) {
1490 let backgrounds: Vec<usize> = (0..self.objects.len())
1491 .filter(|&index| {
1492 self.objects[index]
1493 .picture()
1494 .is_some_and(|image| image.background)
1495 })
1496 .collect();
1497 let art = art.filter(|art| {
1498 art.len() != backgrounds.len()
1499 || art.iter().zip(&backgrounds).any(|(new, &index)| {
1500 let shown = self.objects[index].picture().unwrap();
1501 (&new.layout, new.size, &new.bytes) != (&shown.layout, shown.size, &shown.bytes)
1502 })
1503 });
1504 let paper = (color, rule_lines) != (self.header.color, self.header.rule_lines);
1505 if !paper && art.is_none() {
14661506 return false;
14671507 }
14681508 self.finish_composition();
1469 let before = self.paper(color, rule_lines);
1470 self.undo.push(before);
1509 let mut entries = Vec::new();
1510 if paper {
1511 entries.push(self.paper(color, rule_lines));
1512 }
1513 if let Some(art) = art {
1514 for index in backgrounds.into_iter().rev() {
1515 let image = self.remove_picture(index);
1516 entries.push(History::Picture {
1517 index,
1518 image: Some(Box::new(image)),
1519 });
1520 }
1521 // Art lies under everything else on the page.
1522 for image in art.into_iter().rev() {
1523 self.add_picture(0, image);
1524 entries.push(History::Picture {
1525 index: 0,
1526 image: None,
1527 });
1528 }
1529 }
1530 self.undo.push(match entries.len() {
1531 1 => entries.pop().unwrap(),
1532 _ => History::Group {
1533 entries,
1534 page: false,
1535 },
1536 });
14711537 self.redo.clear();
14721538 true
14731539 }
14741540
1541 /// Puts page-level picture `image` at `index` in paint order, recording its op.
1542 fn add_picture(&mut self, index: usize, image: onestore::page::Image) {
1543 let object = PageObject::Image(image.clone());
1544 self.objects.insert(index, page::Content::Image(image));
1545 let before = self.successor(object.id());
1546 self.record(Ok(vec![PageOp::Add { object, before }]));
1547 }
1548
1549 /// Takes the page-level picture at `index` in paint order off the page, recording its op.
1550 fn remove_picture(&mut self, index: usize) -> onestore::page::Image {
1551 let image = self.objects[index].picture().unwrap().clone();
1552 self.objects.remove(index);
1553 self.record(Ok(vec![PageOp::Delete { object: image.id }]));
1554 image
1555 }
1556
1557 /// Puts a file on the page, `file` being its `Content::File`, at `index` in paint order,
1558 /// recording its op.
1559 fn add_file(&mut self, index: usize, file: page::Content) {
1560 let object = PageObject::Attachment(file.file().unwrap().0.clone());
1561 self.objects.insert(index, file);
1562 let before = self.successor(object.id());
1563 self.record(Ok(vec![PageOp::Add { object, before }]));
1564 }
1565
1566 /// Takes the file at `index` in paint order off the page, recording its op.
1567 fn remove_file(&mut self, index: usize) -> page::Content {
1568 let file = self.objects.remove(index);
1569 let object = file.file().unwrap().0.id;
1570 self.record(Ok(vec![PageOp::Delete { object }]));
1571 file
1572 }
1573
14751574 /// Shows `color` and `rule_lines`, recording the ops that store them; the history entry
14761575 /// restoring what the page had.
14771576 fn paper(
......@@ -1754,9 +1853,12 @@ impl CanvasEditor {
17541853 Ok(())
17551854 }
17561855
1757 /// Origin and size of a picture the user can select; OneNote passes clicks through
1758 /// backgrounds.
1856 /// Origin and size of a picture or file on the page the user can select; OneNote passes
1857 /// clicks through backgrounds.
17591858 pub fn image_placement(&self, id: ExGuid) -> Option<([f32; 2], [f32; 2])> {
1859 if let Some((_, [x0, y0, x1, y1])) = self.page_file(id) {
1860 return Some(([x0, y0], [x1 - x0, y1 - y0]));
1861 }
17601862 if let Some(image) = self.image(id) {
17611863 let layout = &image.layout;
17621864 return Some((
......@@ -1776,20 +1878,50 @@ impl CanvasEditor {
17761878 self.outline_picture(id).is_some()
17771879 }
17781880
1779 /// A picture held as a paragraph: its outline, the paragraph's container and index there,
1780 /// and the paragraph.
1881 /// A picture or file held as a paragraph: its outline, the paragraph's container and index
1882 /// there, and the paragraph.
17811883 fn outline_picture(
17821884 &self,
17831885 id: ExGuid,
17841886 ) -> Option<(ExGuid, Option<ExGuid>, usize, &PageParagraph)> {
17851887 self.outlines.iter().find_map(|outline| {
17861888 descendants(outline.document.nodes(), None).find_map(|(container, index, node)| {
1787 matches!(&node.content, onestore::page::ParagraphContent::Image(image) if image.id == id)
1788 .then_some((outline.id, container, index, node))
1889 holds(&node.content, id).then_some((outline.id, container, index, node))
17891890 })
17901891 })
17911892 }
17921893
1894 /// The file a paragraph holds or the page shows, which the host opens and saves.
1895 pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> {
1896 if let Some((file, _)) = self.page_file(id) {
1897 return Some(file);
1898 }
1899 match &self.outline_picture(id)?.3.content {
1900 ParagraphContent::Attachment(file) => Some(file),
1901 _ => None,
1902 }
1903 }
1904
1905 /// A file on the page and where its column lies, in page coordinates.
1906 fn page_file(&self, id: ExGuid) -> Option<(&onestore::page::Attachment, [f32; 4])> {
1907 self.objects
1908 .iter()
1909 .filter_map(page::Content::file)
1910 .find(|(file, _)| file.id == id)
1911 }
1912
1913 /// Where a file draws with its name, in page coordinates.
1914 pub fn attachment_rect(&self, id: ExGuid) -> Option<[f32; 4]> {
1915 if let Some((_, rect)) = self.page_file(id) {
1916 return Some(rect);
1917 }
1918 let (outline, ..) = self.outline_picture(id)?;
1919 let outline = self.outlines.iter().find(|item| item.id == outline)?;
1920 let [x0, y0, x1, y1] = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds();
1921 let [x, y] = outline.origin();
1922 Some([x + x0, y + y0, x + x1, y + y1])
1923 }
1924
17931925 /// Replaces a picture paragraph through the outline's text history.
17941926 fn edit_outline_picture(
17951927 &mut self,
......@@ -1827,8 +1959,8 @@ impl CanvasEditor {
18271959 })
18281960 }
18291961
1830 /// Moves a picture, and resizes it when `size` differs from the stored size. A picture in
1831 /// an outline's flow keeps its place.
1962 /// Moves a picture or a file on the page, and resizes a picture when `size` differs from
1963 /// the stored size. A picture in an outline's flow keeps its place.
18321964 pub fn place_image(
18331965 &mut self,
18341966 engine: &mut TextEngine,
......@@ -1844,7 +1976,7 @@ impl CanvasEditor {
18441976 if let Some((.., node)) = self.outline_picture(id) {
18451977 let mut node = node.clone();
18461978 let onestore::page::ParagraphContent::Image(image) = &mut node.content else {
1847 unreachable!()
1979 return Ok(());
18481980 };
18491981 if crate::outline::image_size(image) == Some(size) {
18501982 return Ok(());
......@@ -1853,6 +1985,25 @@ impl CanvasEditor {
18531985 image.layout.width_set_by_user = Some(true);
18541986 return self.edit_outline_picture(engine, id, Some(node));
18551987 }
1988 if let Some((file, _)) = self.page_file(id) {
1989 let previous = [file.layout.x, file.layout.y];
1990 if previous == origin.map(Some) {
1991 return Ok(());
1992 }
1993 self.finish_composition();
1994 let layout = self.object_layout_mut(id).unwrap();
1995 [layout.x, layout.y] = origin.map(Some);
1996 self.undo.push(History::Position {
1997 object: id,
1998 position: previous,
1999 });
2000 self.redo.clear();
2001 self.record(self.placement_ops(&Placement {
2002 id,
2003 position: previous,
2004 }));
2005 return Ok(());
2006 }
18562007 let image = self.image(id).ok_or(EditError::InvalidRange)?;
18572008 let mut layout = image.layout.clone();
18582009 [layout.x, layout.y] = origin.map(Some);
......@@ -1897,9 +2048,7 @@ impl CanvasEditor {
18972048 let mut passed = false;
18982049 for (_, _, node) in descendants(source.document.nodes(), None) {
18992050 match &node.content {
1900 onestore::page::ParagraphContent::Image(image) if image.id == id => {
1901 passed = true
1902 }
2051 content if holds(content, id) => passed = true,
19032052 onestore::page::ParagraphContent::Text(text) => {
19042053 let end = text.text.utf16_offset(text.text.text().len())?;
19052054 if passed {
......@@ -1925,7 +2074,10 @@ impl CanvasEditor {
19252074 let index = self
19262075 .objects
19272076 .iter()
1928 .position(|object| matches!(object, page::Content::Image(image) if image.id == id))
2077 .position(|object| {
2078 matches!(object, page::Content::Image(image) if image.id == id)
2079 || object.file().is_some_and(|(file, _)| file.id == id)
2080 })
19292081 .ok_or(EditError::InvalidRange)?;
19302082 // An emptied outline keeps its slot for undo after leaving `outlines`.
19312083 let editable = |object: &page::Content| match object {
......@@ -1989,10 +2141,95 @@ impl CanvasEditor {
19892141 )
19902142 }
19912143
2144 /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the
2145 /// caret's paragraph splits there, the file takes the place of an empty first half, and the
2146 /// caret starts the second. A caret on blank page puts the file on the page there
2147 /// (`corpus/attachment-floating`).
2148 pub fn insert_attachment(
2149 &mut self,
2150 engine: &mut TextEngine,
2151 mut file: onestore::page::Attachment,
2152 ) -> Result<(), EditorError> {
2153 if let Focus::Caret { outline, .. } = &self.active {
2154 [file.layout.x, file.layout.y] = outline.origin().map(Some);
2155 let layout = Box::new(crate::outline::page_file(engine, &file)?);
2156 let [x0, y0, x1, y1] = layout.bounds();
2157 [file.layout.max_width, file.layout.max_height] = [Some(x1 - x0), Some(y1 - y0)];
2158 file.layout.width_set_by_user = Some(false);
2159 self.finish_composition();
2160 let index = self.objects.len();
2161 self.add_file(
2162 index,
2163 page::Content::File {
2164 source: file,
2165 layout,
2166 },
2167 );
2168 self.undo.push(History::File { index, file: None });
2169 self.redo.clear();
2170 return Ok(());
2171 }
2172 let [anchor, focus] = self.selection().positions;
2173 let (start, end) = (anchor.min(focus), anchor.max(focus));
2174 let mut format = self.typing_format(start)?;
2175 link::unlinked(&mut format);
2176 let mut edit = self
2177 .active_outline()
2178 .document
2179 .replace(start..end, vec![Paragraph::new(String::new(), format); 2])?;
2180 let [head, tail, ..] = &mut edit.replacement[..] else {
2181 unreachable!("a split holds both halves")
2182 };
2183 let node = PageParagraph {
2184 id: onestore::page::text::new_id()?,
2185 content: ParagraphContent::Attachment(file),
2186 style: None,
2187 lists: Vec::new(),
2188 tags: Vec::new(),
2189 collapsed: false,
2190 ..head.clone()
2191 };
2192 let empty = head.text().unwrap().text.text().is_empty();
2193 if empty {
2194 tail.tags = std::mem::take(&mut head.tags);
2195 tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags);
2196 }
2197 edit.replacement.insert(1, node);
2198 if empty {
2199 edit.replacement.remove(0);
2200 }
2201 let caret = TextPosition {
2202 paragraph: start.paragraph + usize::from(!empty),
2203 offset: 0,
2204 };
2205 self.commit(
2206 engine,
2207 edit,
2208 Selection {
2209 positions: [caret; 2],
2210 affinities: [Affinity::Upstream; 2],
2211 },
2212 )
2213 }
2214
19922215 pub fn remove_image(&mut self, engine: &mut TextEngine, id: ExGuid) -> Result<(), EditorError> {
19932216 if self.outline_picture(id).is_some() {
19942217 return self.edit_outline_picture(engine, id, None);
19952218 }
2219 if let Some(index) = self
2220 .objects
2221 .iter()
2222 .position(|object| object.file().is_some_and(|(file, _)| file.id == id))
2223 {
2224 self.finish_composition();
2225 let file = self.remove_file(index);
2226 self.undo.push(History::File {
2227 index,
2228 file: Some(Box::new(file)),
2229 });
2230 self.redo.clear();
2231 return Ok(());
2232 }
19962233 let index = self
19972234 .objects
19982235 .iter()
......@@ -2001,15 +2238,12 @@ impl CanvasEditor {
20012238 })
20022239 .ok_or(EditError::InvalidRange)?;
20032240 self.finish_composition();
2004 let page::Content::Image(image) = self.objects.remove(index) else {
2005 unreachable!()
2006 };
2241 let image = self.remove_picture(index);
20072242 self.undo.push(History::Picture {
20082243 index,
20092244 image: Some(Box::new(image)),
20102245 });
20112246 self.redo.clear();
2012 self.record(Ok(vec![PageOp::Delete { object: id }]));
20132247 Ok(())
20142248 }
20152249
......@@ -2227,6 +2461,55 @@ impl CanvasEditor {
22272461 result
22282462 }
22292463
2464 /// Runs `edit`, typing or a backspace at a caret, as part of the typing run the last undo
2465 /// step holds if the caret is where that run left it and nothing came between; OneNote 2010
2466 /// undoes a run whole however long it pauses.
2467 fn typed<T>(
2468 &mut self,
2469 edit: impl FnOnce(&mut Self) -> Result<T, EditorError>,
2470 ) -> Result<T, EditorError> {
2471 let [anchor, focus] = self.selection().positions;
2472 let caret = match self.marked_range() {
2473 Some(marked) => Some(marked.start),
2474 None => (anchor == focus).then_some(focus),
2475 };
2476 let joins = self.typing.take().is_some_and(|(outline, at, depth)| {
2477 outline == self.active_outline().id
2478 && caret == Some(at)
2479 && depth == self.undo.len()
2480 && self.pending.is_none()
2481 });
2482 let depth = self.undo.len() - usize::from(joins);
2483 let result = edit(self);
2484 let (mut run, mut page) = (Vec::new(), false);
2485 for entry in self.undo.split_off(depth) {
2486 match entry {
2487 History::Group {
2488 entries,
2489 page: selected,
2490 } => {
2491 page |= selected;
2492 if run.is_empty() {
2493 run = entries;
2494 } else {
2495 run.extend(entries);
2496 }
2497 }
2498 entry => run.push(entry),
2499 }
2500 }
2501 match run.len() {
2502 0 => return result,
2503 1 if !page => self.undo.extend(run),
2504 _ => self.undo.push(History::Group { entries: run, page }),
2505 }
2506 if result.is_ok() {
2507 let caret = self.selection().positions[1];
2508 self.typing = Some((self.active_outline().id, caret, self.undo.len()));
2509 }
2510 result
2511 }
2512
22302513 /// Removes the page selection's outlines, last first, but its last when `keep`, whose
22312514 /// text stays selected to be replaced. Deleting the page selection leaves a caret where
22322515 /// its first outline began, as OneNote 2010's does.
......@@ -2375,6 +2658,7 @@ impl CanvasEditor {
23752658 ) -> Result<(), EditorError> {
23762659 self.finish_composition();
23772660 self.whole = None;
2661 self.typing = None;
23782662 if !extend && movement == Movement::Right && self.leave_equation()? {
23792663 return Ok(());
23802664 }
......@@ -2842,12 +3126,21 @@ impl CanvasEditor {
28423126 )
28433127 }
28443128
2845 /// Types `text` at the selection; a space ends a typed URL, which becomes a link.
3129 /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line
3130 /// of text joins the typing run before it.
28463131 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
3132 if text.is_empty() || text.contains('\n') {
3133 self.insert_text(engine, text)
3134 } else {
3135 self.typed(|editor| editor.insert_text(engine, text))
3136 }
3137 }
3138
3139 fn insert_text(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
28473140 if self.page_selected() {
28483141 return self.grouped(|editor| {
28493142 editor.remove_page(engine, true)?;
2850 editor.insert(engine, text)
3143 editor.insert_text(engine, text)
28513144 });
28523145 }
28533146 self.leave_link_code()?;
......@@ -3137,7 +3430,8 @@ impl CanvasEditor {
31373430 let selection = self.active_outline().selection;
31383431 let [anchor, focus] = selection.positions;
31393432 let mut range = anchor.min(focus)..anchor.max(focus);
3140 if range.is_empty() {
3433 let at_caret = range.is_empty();
3434 if at_caret {
31413435 let outline = self.active_outline();
31423436 let paragraph = outline.paragraph_layout(focus.paragraph)?;
31433437 let cursor = paragraph.cursor(focus.offset, selection.affinities[1])?;
......@@ -3213,7 +3507,11 @@ impl CanvasEditor {
32133507 onestore::page::text::Affinity::Upstream,
32143508 )?;
32153509 }
3216 self.delete_range(engine, range)
3510 if backward && at_caret {
3511 self.typed(|editor| editor.delete_range(engine, range))
3512 } else {
3513 self.delete_range(engine, range)
3514 }
32173515 }
32183516
32193517 pub fn delete_to(
......@@ -3226,19 +3524,26 @@ impl CanvasEditor {
32263524 }
32273525 self.finish_composition();
32283526 let original = self.selection();
3229 let preferred_x = self.preferred_x;
3230 if original.positions[0] == original.positions[1] {
3527 let (preferred_x, typing) = (self.preferred_x, self.typing);
3528 let [caret, focus] = original.positions;
3529 let collapsed = caret == focus;
3530 if collapsed {
32313531 let result = self.move_selection(engine, movement, true);
32323532 if let Err(error) = result {
32333533 self.active_outline_mut().selection = original;
3234 self.preferred_x = preferred_x;
3534 (self.preferred_x, self.typing) = (preferred_x, typing);
32353535 return Err(error);
32363536 }
32373537 }
32383538 let [anchor, focus] = self.selection().positions;
32393539 self.active_outline_mut().selection = original;
3240 self.preferred_x = preferred_x;
3241 self.delete_range(engine, anchor.min(focus)..anchor.max(focus))
3540 (self.preferred_x, self.typing) = (preferred_x, typing);
3541 let range = anchor.min(focus)..anchor.max(focus);
3542 if collapsed && range.end == caret && range.start.paragraph == caret.paragraph {
3543 self.typed(|editor| editor.delete_range(engine, range))
3544 } else {
3545 self.delete_range(engine, range)
3546 }
32423547 }
32433548
32443549 fn delete_range(
......@@ -3326,6 +3631,7 @@ impl CanvasEditor {
33263631 history: History,
33273632 ) -> Result<History, (History, EditorError)> {
33283633 self.pending = None;
3634 self.typing = None;
33293635 let valid = match &history {
33303636 History::Date(date) => self
33313637 .date
......@@ -3363,9 +3669,20 @@ impl CanvasEditor {
33633669 index,
33643670 image: Some(_),
33653671 } => *index <= self.objects.len(),
3366 History::Picture { index, image: None } => {
3367 matches!(self.objects.get(*index), Some(page::Content::Image(_)))
3368 }
3672 History::Picture { index, image: None } => self
3673 .objects
3674 .get(*index)
3675 .and_then(page::Content::picture)
3676 .is_some(),
3677 History::File {
3678 index,
3679 file: Some(_),
3680 } => *index <= self.objects.len(),
3681 History::File { index, file: None } => self
3682 .objects
3683 .get(*index)
3684 .and_then(page::Content::file)
3685 .is_some(),
33693686 History::Remove { outline, focus } => {
33703687 self.outlines.iter().any(|item| item.id == *outline)
33713688 && match focus {
......@@ -3584,24 +3901,20 @@ impl CanvasEditor {
35843901 index,
35853902 image: match image {
35863903 Some(image) => {
3587 self.objects.insert(index, page::Content::Image(*image));
3588 let page::Content::Image(image) = &self.objects[index] else {
3589 unreachable!()
3590 };
3591 let ops = vec![PageOp::Add {
3592 object: PageObject::Image(image.clone()),
3593 before: self.successor(image.id),
3594 }];
3595 self.record(Ok(ops));
3904 self.add_picture(index, *image);
35963905 None
35973906 }
3598 None => match self.objects.remove(index) {
3599 page::Content::Image(image) => {
3600 self.record(Ok(vec![PageOp::Delete { object: image.id }]));
3601 Some(Box::new(image))
3602 }
3603 _ => unreachable!(),
3604 },
3907 None => Some(Box::new(self.remove_picture(index))),
3908 },
3909 },
3910 History::File { index, file } => History::File {
3911 index,
3912 file: match file {
3913 Some(file) => {
3914 self.add_file(index, *file);
3915 None
3916 }
3917 None => Some(Box::new(self.remove_file(index))),
36053918 },
36063919 },
36073920 History::Remove { outline, focus } => {
......@@ -3827,9 +4140,17 @@ impl CanvasEditor {
38274140 .count()
38284141 .try_into()
38294142 .map_err(|_| EditError::TextTooLong)?;
3830 self.compose(engine, text, end..end)?;
3831 self.finish_composition();
3832 Ok(())
4143 let lines = text.contains('\n');
4144 let commit = |editor: &mut Self| {
4145 editor.compose(engine, text, end..end)?;
4146 editor.finish_composition();
4147 Ok(())
4148 };
4149 if lines {
4150 commit(self)
4151 } else {
4152 self.typed(commit)
4153 }
38334154 } else {
38344155 self.insert(engine, &text)
38354156 }
......@@ -4128,6 +4449,15 @@ impl CanvasEditor {
41284449/// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same
41294450/// paragraph, past what changed in its text when it lies after it; at the start of the
41304451/// paragraph now at its place when that one is gone.
4452/// Whether `content` is picture or file `id`.
4453fn holds(content: &ParagraphContent, id: ExGuid) -> bool {
4454 match content {
4455 ParagraphContent::Image(image) => image.id == id,
4456 ParagraphContent::Attachment(file) => file.id == id,
4457 _ => false,
4458 }
4459}
4460
41314461fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition {
41324462 let found = old.leaf(position.paragraph).and_then(|(_, _, node)| {
41334463 let paragraph = new
......@@ -5065,22 +5395,74 @@ mod tests {
50655395 editor.active_outline().shaped.paragraphs.len(),
50665396 fresh.paragraphs.len()
50675397 );
5068 editor.undo(&mut engine).unwrap();
5069 assert_eq!(
5070 editor
5071 .active_outline()
5072 .document
5073 .paragraphs()
5074 .last()
5075 .unwrap()
5076 .text(),
5077 "Lasta"
5078 );
5398 // The committed composition and the typing after it are one run.
50795399 editor.undo(&mut engine).unwrap();
50805400 assert_eq!(editor.outlines()[0].document, source);
50815401 assert_eq!(editor.active_outline().document, draft);
50825402 }
50835403
5404 /// OneNote 2010's undo steps (Windows 7 lab, 2026-09-29): typing, backspaces and word
5405 /// backspaces within a paragraph are one step however long the pauses; a caret move, Enter,
5406 /// a backspace joining paragraphs, Delete, paste and a format chosen at the caret each end it.
5407 #[test]
5408 fn typing_runs_undo_whole_until_the_caret_moves_or_another_edit() {
5409 let mut engine = TextEngine::default();
5410 let source =
5411 TextDocument::new(vec![Paragraph::new("Base".into(), Format::default())]).unwrap();
5412 let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap();
5413 let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
5414 let text = |editor: &CanvasEditor| editor.active_outline().shown_text();
5415 editor.select(at(0, 4)).unwrap();
5416 editor.insert(&mut engine, " quick").unwrap();
5417 editor.insert(&mut engine, " brown").unwrap();
5418 editor.delete(&mut engine, true).unwrap();
5419 editor.delete_to(&mut engine, Movement::WordLeft).unwrap();
5420 editor.commit_text(&mut engine, "fox".into()).unwrap();
5421 editor.select(editor.selection()).unwrap();
5422 editor.insert(&mut engine, " jumps").unwrap();
5423 assert_eq!(text(&editor), "Base quick fox jumps");
5424 assert!(editor.undo(&mut engine).unwrap());
5425 assert_eq!(text(&editor), "Base");
5426 assert_eq!(editor.selection().positions, [at(0, 4).positions[0]; 2]);
5427 assert!(!editor.can_undo());
5428 assert!(editor.redo(&mut engine).unwrap());
5429 assert_eq!(text(&editor), "Base quick fox jumps");
5430
5431 let mut shown = vec!["Base".to_owned(), text(&editor)];
5432 for movement in [Movement::Left, Movement::Right] {
5433 editor.move_selection(&mut engine, movement, false).unwrap();
5434 }
5435 editor.insert(&mut engine, "!").unwrap();
5436 editor.insert(&mut engine, "?").unwrap();
5437 shown.push(text(&editor));
5438 editor.enter(&mut engine, false).unwrap();
5439 shown.push(text(&editor));
5440 editor.insert(&mut engine, "ab").unwrap();
5441 shown.push(text(&editor));
5442 editor
5443 .move_selection(&mut engine, Movement::Left, false)
5444 .unwrap();
5445 editor.delete(&mut engine, false).unwrap();
5446 shown.push(text(&editor));
5447 editor.insert(&mut engine, "c").unwrap();
5448 shown.push(text(&editor));
5449 editor
5450 .format(&mut engine, Formatting::Toggle(Toggle::Bold))
5451 .unwrap();
5452 editor.insert(&mut engine, "d").unwrap();
5453 editor.insert(&mut engine, "e").unwrap();
5454 shown.push(text(&editor));
5455 editor.select(at(1, 0)).unwrap();
5456 editor.delete(&mut engine, true).unwrap();
5457 shown.push(text(&editor));
5458 editor.insert(&mut engine, "f").unwrap();
5459 assert_eq!(text(&editor), "Base quick fox jumps!?facde");
5460 while editor.undo(&mut engine).unwrap() {
5461 assert_eq!(text(&editor), shown.pop().unwrap());
5462 }
5463 assert!(shown.is_empty());
5464 }
5465
50845466 #[test]
50855467 fn table_horizontal_arrows_follow_reading_order() {
50865468 let mut engine = TextEngine::default();
......@@ -5659,6 +6041,80 @@ mod tests {
56596041 assert_eq!(ids(&editor), [background_id]);
56606042 }
56616043
6044 #[test]
6045 fn art_and_paper_change_together_as_one_step_under_the_page() {
6046 use onestore::page::{Image, Page, PageObject};
6047 let mut engine = TextEngine::default();
6048 let image = |background, bytes: &[u8]| Image {
6049 size: None,
6050 id: onestore::page::text::new_id().unwrap(),
6051 layout: onestore::document::Layout {
6052 x: Some(-27.0),
6053 y: Some(-3.6),
6054 max_width: Some(174.5),
6055 max_height: Some(640.0),
6056 ..Default::default()
6057 },
6058 bytes: Some(std::sync::Arc::from(bytes)),
6059 alt: None,
6060 background,
6061 };
6062 let [old, picture] = [image(true, b"old art"), image(false, b"picture")];
6063 let mut editor = CanvasEditor::from_page(
6064 Page {
6065 title: String::new(),
6066 identity: None,
6067 created: None,
6068 margin_origin: [36.0, 14.4],
6069 color: None,
6070 rule_lines: None,
6071 definitions: BTreeMap::new(),
6072 objects: vec![
6073 PageObject::Image(old.clone()),
6074 PageObject::Image(picture.clone()),
6075 ],
6076 },
6077 &mut engine,
6078 )
6079 .unwrap();
6080 let ids = |editor: &CanvasEditor| {
6081 let page = editor.page().unwrap();
6082 let ids: Vec<_> = page.objects.iter().map(PageObject::id).collect();
6083 (ids, page.color)
6084 };
6085 let art = || [image(true, b"art one"), image(true, b"art two")];
6086 let [one, two] = art();
6087 let blue = Some(0x00fef5ed);
6088 assert!(editor.set_paper(blue, None, Some(vec![one.clone(), two.clone()])));
6089 let applied = (vec![one.id, two.id, picture.id], blue);
6090 assert_eq!(ids(&editor), applied);
6091 assert_eq!(
6092 editor.take_ops().unwrap(),
6093 [
6094 PageOp::Color(blue),
6095 PageOp::Delete { object: old.id },
6096 PageOp::Add {
6097 object: PageObject::Image(two.clone()),
6098 before: Some(picture.id),
6099 },
6100 PageOp::Add {
6101 object: PageObject::Image(one.clone()),
6102 before: Some(two.id),
6103 },
6104 ]
6105 );
6106 // The same art again, under fresh identities, changes nothing.
6107 assert!(!editor.set_paper(blue, None, Some(art().into())));
6108 assert!(editor.undo(&mut engine).unwrap());
6109 assert_eq!(ids(&editor), (vec![old.id, picture.id], None));
6110 assert_eq!(editor.take_ops().unwrap().len(), 4);
6111 assert!(!editor.can_undo());
6112 assert!(editor.redo(&mut engine).unwrap());
6113 assert_eq!(ids(&editor), applied);
6114 assert!(editor.set_paper(blue, None, Some(Vec::new())));
6115 assert_eq!(ids(&editor), (vec![picture.id], blue));
6116 }
6117
56626118 #[test]
56636119 fn arrows_leave_a_picture_for_the_neighbouring_outlines_in_page_order() {
56646120 use onestore::page::{Image, Page, PageObject};
......@@ -5858,6 +6314,94 @@ mod tests {
58586314 );
58596315 }
58606316
6317 /// OneNote 2010 attaches at the caret (`corpus/attachment-insert`): the text after it
6318 /// follows the file and keeps the caret, and an empty first half gives way to the file.
6319 #[test]
6320 fn a_file_attaches_at_the_caret_as_onenote_splits_around_it() {
6321 use onestore::page::{Attachment, Page, PageObject, ParagraphContent};
6322 let mut engine = TextEngine::default();
6323 let source = TextOutline::new(
6324 &mut engine,
6325 TextDocument::new(vec![
6326 Paragraph::new("Before".into(), Default::default()),
6327 Paragraph::new(String::new(), Default::default()),
6328 ])
6329 .unwrap(),
6330 240.0,
6331 [36.0, 36.0],
6332 )
6333 .unwrap()
6334 .snapshot();
6335 let mut editor = CanvasEditor::from_page(
6336 Page {
6337 title: String::new(),
6338 identity: None,
6339 created: None,
6340 margin_origin: [36.0, 14.4],
6341 color: None,
6342 rule_lines: None,
6343 definitions: BTreeMap::new(),
6344 objects: vec![PageObject::Outline(source)],
6345 },
6346 &mut engine,
6347 )
6348 .unwrap();
6349 editor.take_ops().unwrap();
6350 let file = |name: &str| Attachment {
6351 id: onestore::page::text::new_id().unwrap(),
6352 filename: name.into(),
6353 source_path: None,
6354 size: Some([24.0, 24.0]),
6355 layout: Default::default(),
6356 bytes: Some(std::sync::Arc::from(b"payload".as_slice())),
6357 preview: None,
6358 recording: None,
6359 };
6360 let at = |paragraph, offset| Selection {
6361 positions: [TextPosition { paragraph, offset }; 2],
6362 affinities: [Affinity::Downstream; 2],
6363 };
6364 let shown = |editor: &CanvasEditor| {
6365 let page = editor.page().unwrap();
6366 let PageObject::Outline(outline) = &page.objects[0] else {
6367 panic!()
6368 };
6369 outline
6370 .paragraphs
6371 .iter()
6372 .map(|node| match &node.content {
6373 ParagraphContent::Text(text) => text.text.text().to_owned(),
6374 ParagraphContent::Attachment(file) => format!("[{}]", file.filename),
6375 _ => panic!(),
6376 })
6377 .collect::<Vec<_>>()
6378 };
6379
6380 editor.select(at(0, 3)).unwrap();
6381 let middle = file("middle.txt");
6382 editor.insert_attachment(&mut engine, middle.clone()).unwrap();
6383 assert_eq!(shown(&editor), ["Bef", "[middle.txt]", "ore", ""]);
6384 // A file is no text position: the caret starts "ore", the second text.
6385 assert_eq!(editor.selection().positions, at(1, 0).positions);
6386 assert_eq!(editor.attachment(middle.id), Some(&middle));
6387 let ops = editor.take_ops().unwrap();
6388 assert!(ops.iter().any(|op| matches!(op, PageOp::Insert { paragraphs, .. }
6389 if paragraphs.iter().any(|p| matches!(&p.content, ParagraphContent::Attachment(a) if *a == middle)))));
6390
6391 editor.select(at(2, 0)).unwrap();
6392 editor.insert_attachment(&mut engine, file("empty.bin")).unwrap();
6393 assert_eq!(
6394 shown(&editor),
6395 ["Bef", "[middle.txt]", "ore", "[empty.bin]", ""]
6396 );
6397 assert_eq!(editor.selection().positions[0].paragraph, 2);
6398
6399 assert!(editor.undo(&mut engine).unwrap());
6400 assert_eq!(shown(&editor), ["Bef", "[middle.txt]", "ore", ""]);
6401 editor.remove_image(&mut engine, middle.id).unwrap();
6402 assert_eq!(shown(&editor), ["Bef", "ore", ""]);
6403 }
6404
58616405 #[test]
58626406 fn unknown_paragraphs_and_text_free_outlines_stay_on_the_page() {
58636407 use onestore::page::{Image, Page, PageObject, ParagraphContent, Unsupported};
......@@ -6405,17 +6949,6 @@ mod tests {
64056949 assert_eq!(editor.active_outline().shaped.size, expected.shaped.size);
64066950 assert_eq!(editor.active_outline().document.nodes().len(), 2);
64076951 editor.undo(&mut engine).unwrap();
6408 assert_eq!(
6409 editor
6410 .active_outline()
6411 .document
6412 .paragraphs()
6413 .last()
6414 .unwrap()
6415 .text(),
6416 "a"
6417 );
6418 editor.undo(&mut engine).unwrap();
64196952 assert_eq!(editor.outlines[0].document, source);
64206953 assert_eq!(editor.active_outline().document.nodes().len(), 4);
64216954 }
......@@ -7152,6 +7685,10 @@ mod tests {
71527685 let selection = editor.selection();
71537686 editor.insert(&mut engine, "x").unwrap();
71547687 assert_eq!(editor.outlines.len(), 1);
7688 // A caret move ends the typing run, so the backspace is its own step.
7689 for movement in [Movement::Left, Movement::Right] {
7690 editor.move_selection(&mut engine, movement, false).unwrap();
7691 }
71557692 editor.delete(&mut engine, true).unwrap();
71567693 assert!(editor.outlines.is_empty());
71577694 assert_eq!(editor.active_outline().document, spaces);
......@@ -7368,6 +7905,9 @@ mod tests {
73687905 )
73697906 .unwrap();
73707907 editor.insert(&mut engine, "One").unwrap();
7908 for movement in [Movement::Left, Movement::Right] {
7909 editor.move_selection(&mut engine, movement, false).unwrap();
7910 }
73717911 let source = editor.active_outline().snapshot();
73727912 let selection = editor.selection();
73737913 editor.insert(&mut engine, "!").unwrap();
crates/canvas/src/editor/evidence.rs+9-10
......@@ -91,13 +91,9 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
9191 Some(label) => (label, true),
9292 None => (tag, false),
9393 };
94 let tag = NoteTag::ALL
95 .into_iter()
96 .find(|tag| {
97 matches!(&tag.definition().kind, Kind::TagDefinition { label: Some(name), .. } if name == label)
98 })
99 .unwrap();
100 let definition = define(label.into(), tag.definition());
94 let defaults = NoteTag::defaults();
95 let place = defaults.iter().position(|tag| tag.label == label).unwrap();
96 let definition = define(label.into(), defaults[place].definition(place as u16));
10197 node.text_mut().unwrap().tags.push(Tag {
10298 definition: Some(definition),
10399 action_type: None,
......@@ -146,9 +142,12 @@ fn render(editor: &CanvasEditor) -> String {
146142 };
147143 let checked = if tag.status & 1 == 1 { "[x]" } else { "" };
148144 (
149 NoteTag::ALL
150 .iter()
151 .position(|known| known.definition().kind == *kind),
145 NoteTag::of(kind).and_then(|(tag, action_type)| {
146 NoteTag::defaults()
147 .iter()
148 .position(|known| *known == tag)
149 .filter(|place| *place == usize::from(action_type))
150 }),
152151 format!("{}{checked}", label.as_deref().unwrap()),
153152 )
154153 })
crates/canvas/src/editor/format.rs+231-233
......@@ -5,6 +5,7 @@ use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE};
55use onestore::document::{Format, Kind, Tag};
66use onestore::page::ParagraphContent;
77use onestore::page::text::new_id;
8use serde::{Deserialize, Serialize};
89
910/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour.
1011const AUTOMATIC: u32 = 0xff00_0000;
......@@ -32,7 +33,9 @@ pub enum Formatting {
3233 Indent,
3334 Outdent,
3435 Align(Alignment),
35 Tag(NoteTag),
36 /// A tag of the user's list and its place there, which the page stores as its action
37 /// type.
38 Tag(NoteTag, u16),
3639 /// Removes every note tag from the selected paragraphs.
3740 RemoveTags,
3841 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.
......@@ -161,40 +164,102 @@ impl ListStyle {
161164 }
162165}
163166
164/// OneNote 2010's default tags, the first nine on Ctrl+1 to Ctrl+9, as it stores their
165/// definitions (`corpus/structural-probe/tag-gallery.one`); declared in gallery order, their
166/// action types.
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub enum NoteTag {
169 ToDo,
170 Important,
171 Question,
172 RememberForLater,
173 Definition,
174 Highlight,
175 Contact,
176 Address,
177 PhoneNumber,
178 WebSiteToVisit,
179 Idea,
180 Password,
181 Critical,
182 ProjectA,
183 ProjectB,
184 MovieToSee,
185 BookToRead,
186 MusicToListenTo,
187 SourceForArticle,
188 RememberForBlog,
189 DiscussWithPersonA,
190 DiscussWithPersonB,
191 DiscussWithManager,
192 SendInEmail,
193 ScheduleMeeting,
194 CallBack,
195 ToDoPriority1,
196 ToDoPriority2,
197 ClientRequest,
167/// A tag of the user's list as the Customize Tags dialog edits it: what its definition
168/// stores besides its place in the list, which the page stores as its action type.
169#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
170pub struct NoteTag {
171 pub label: String,
172 /// MS-ONE's NoteTagShape; 0 has no symbol.
173 pub shape: u16,
174 /// COLORREFs; none leaves the text's own.
175 pub color: Option<u32>,
176 pub highlight: Option<u32>,
177}
178
179/// OneNote 2010's tag list before it is customized, the first nine on Ctrl+1 to Ctrl+9
180/// (`corpus/structural-probe/tag-gallery.one`): names, symbols and highlights.
181const DEFAULT_TAGS: [(&str, u16, Option<u32>); 29] = [
182 ("To Do", CHECKBOX, None),
183 ("Important", 13, None),
184 ("Question", 15, None),
185 ("Remember for later", 0, Some(0x0000_ffff)),
186 ("Definition", 0, Some(0x0000_ff00)),
187 ("Highlight", 136, None),
188 ("Contact", 118, None),
189 ("Address", 23, None),
190 ("Phone number", 18, None),
191 ("Web site to visit", 125, None),
192 ("Idea", 21, None),
193 ("Password", 131, None),
194 ("Critical", 17, None),
195 ("Project A", 100, None),
196 ("Project B", 101, None),
197 ("Movie to see", 122, None),
198 ("Book to read", 132, None),
199 ("Music to listen to", 121, None),
200 ("Source for article", 125, None),
201 ("Remember for blog", 24, None),
202 ("Discuss with <Person A>", 94, None),
203 ("Discuss with <Person B>", 94, None),
204 ("Discuss with manager", 95, None),
205 ("Send in e-mail", 106, None),
206 ("Schedule meeting", 12, None),
207 ("Call back", 12, None),
208 ("To Do priority 1", 28, None),
209 ("To Do priority 2", 71, None),
210 ("Client request", 8, None),
211];
212
213impl NoteTag {
214 pub fn defaults() -> Vec<Self> {
215 DEFAULT_TAGS
216 .iter()
217 .map(|&(label, shape, highlight)| Self {
218 label: label.into(),
219 shape,
220 // The highlighting tags also set black text.
221 color: highlight.map(|_| 0),
222 highlight,
223 })
224 .collect()
225 }
226
227 /// The definition OneNote stores for the tag at place `action_type` in the list.
228 pub fn definition(&self, action_type: u16) -> Definition {
229 Definition {
230 kind: Kind::TagDefinition {
231 label: Some(self.label.clone()),
232 action_type: Some(action_type),
233 shape: Some(self.shape),
234 color: self.color,
235 highlight: self.highlight,
236 },
237 format: Format::default(),
238 }
239 }
240
241 /// The tag a stored definition describes, and its action type.
242 pub fn of(kind: &Kind) -> Option<(Self, u16)> {
243 let Kind::TagDefinition {
244 label,
245 action_type,
246 shape,
247 color,
248 highlight,
249 } = kind
250 else {
251 return None;
252 };
253 Some((
254 Self {
255 label: label.clone().unwrap_or_default(),
256 shape: shape.unwrap_or(0),
257 color: *color,
258 highlight: *highlight,
259 },
260 action_type.unwrap_or(0),
261 ))
262 }
198263}
199264
200265/// What the selection shows on the toolbar.
......@@ -212,8 +277,8 @@ pub struct FormatState {
212277 pub numbering: bool,
213278 /// The library style every selected paragraph's list shares.
214279 pub list: Option<ListStyle>,
215 /// Tags every selected paragraph has.
216 pub tags: Vec<NoteTag>,
280 /// Tags every selected paragraph has, with their action types.
281 pub tags: Vec<(NoteTag, u16)>,
217282}
218283
219284impl Toggle {
......@@ -249,133 +314,6 @@ impl Toggle {
249314 }
250315}
251316
252impl NoteTag {
253 pub const ALL: [Self; 29] = [
254 Self::ToDo,
255 Self::Important,
256 Self::Question,
257 Self::RememberForLater,
258 Self::Definition,
259 Self::Highlight,
260 Self::Contact,
261 Self::Address,
262 Self::PhoneNumber,
263 Self::WebSiteToVisit,
264 Self::Idea,
265 Self::Password,
266 Self::Critical,
267 Self::ProjectA,
268 Self::ProjectB,
269 Self::MovieToSee,
270 Self::BookToRead,
271 Self::MusicToListenTo,
272 Self::SourceForArticle,
273 Self::RememberForBlog,
274 Self::DiscussWithPersonA,
275 Self::DiscussWithPersonB,
276 Self::DiscussWithManager,
277 Self::SendInEmail,
278 Self::ScheduleMeeting,
279 Self::CallBack,
280 Self::ToDoPriority1,
281 Self::ToDoPriority2,
282 Self::ClientRequest,
283 ];
284
285 /// The tag's name, as OneNote stores and lists it.
286 pub const fn label(self) -> &'static str {
287 match self {
288 Self::ToDo => "To Do",
289 Self::Important => "Important",
290 Self::Question => "Question",
291 Self::RememberForLater => "Remember for later",
292 Self::Definition => "Definition",
293 Self::Highlight => "Highlight",
294 Self::Contact => "Contact",
295 Self::Address => "Address",
296 Self::PhoneNumber => "Phone number",
297 Self::WebSiteToVisit => "Web site to visit",
298 Self::Idea => "Idea",
299 Self::Password => "Password",
300 Self::Critical => "Critical",
301 Self::ProjectA => "Project A",
302 Self::ProjectB => "Project B",
303 Self::MovieToSee => "Movie to see",
304 Self::BookToRead => "Book to read",
305 Self::MusicToListenTo => "Music to listen to",
306 Self::SourceForArticle => "Source for article",
307 Self::RememberForBlog => "Remember for blog",
308 Self::DiscussWithPersonA => "Discuss with <Person A>",
309 Self::DiscussWithPersonB => "Discuss with <Person B>",
310 Self::DiscussWithManager => "Discuss with manager",
311 Self::SendInEmail => "Send in e-mail",
312 Self::ScheduleMeeting => "Schedule meeting",
313 Self::CallBack => "Call back",
314 Self::ToDoPriority1 => "To Do priority 1",
315 Self::ToDoPriority2 => "To Do priority 2",
316 Self::ClientRequest => "Client request",
317 }
318 }
319
320 /// The symbol OneNote draws for the tag; 0 draws none.
321 pub const fn shape(self) -> u16 {
322 match self {
323 Self::ToDo => CHECKBOX,
324 Self::Important => 13,
325 Self::Question => 15,
326 Self::RememberForLater | Self::Definition => 0,
327 Self::Highlight => 136,
328 Self::Contact => 118,
329 Self::Address => 23,
330 Self::PhoneNumber => 18,
331 Self::WebSiteToVisit | Self::SourceForArticle => 125,
332 Self::Idea => 21,
333 Self::Password => 131,
334 Self::Critical => 17,
335 Self::ProjectA => 100,
336 Self::ProjectB => 101,
337 Self::MovieToSee => 122,
338 Self::BookToRead => 132,
339 Self::MusicToListenTo => 121,
340 Self::RememberForBlog => 24,
341 Self::DiscussWithPersonA | Self::DiscussWithPersonB => 94,
342 Self::DiscussWithManager => 95,
343 Self::SendInEmail => 106,
344 Self::ScheduleMeeting | Self::CallBack => 12,
345 Self::ToDoPriority1 => 28,
346 Self::ToDoPriority2 => 71,
347 Self::ClientRequest => 8,
348 }
349 }
350
351 pub(super) fn definition(self) -> Definition {
352 let highlight = match self {
353 Self::RememberForLater => Some(0x0000_ffff),
354 Self::Definition => Some(0x0000_ff00),
355 _ => None,
356 };
357 Definition {
358 kind: Kind::TagDefinition {
359 label: Some(self.label().into()),
360 action_type: Some(self as u16),
361 shape: Some(self.shape()),
362 // The highlighting tags also set black text.
363 color: highlight.map(|_| 0),
364 highlight,
365 },
366 format: Format::default(),
367 }
368 }
369}
370
371/// Whether tag symbol `shape` is a check box, which OneNote stores unchecked when applied.
372fn checkable(shape: Option<u16>) -> bool {
373 matches!(
374 shape.and_then(|shape| crate::outline::TagIcon::of(shape, false)),
375 Some(crate::outline::TagIcon::CheckBox { .. })
376 )
377}
378
379317/// Where a selection starts and ends, as text leaf identity and byte offset.
380318type Ends = [(ExGuid, usize); 2];
381319
......@@ -664,15 +602,19 @@ impl CanvasEditor {
664602 bullets: list(Formatting::Bullets),
665603 numbering: list(Formatting::Numbering),
666604 list: common(paragraphs.iter().map(|node| self.list_style(node))),
667 tags: NoteTag::ALL
668 .into_iter()
669 .filter(|tag| {
670 let kind = tag.definition().kind;
671 paragraphs
672 .iter()
673 .all(|node| tags(node).any(|tag| self.tag_kind(tag) == Some(&kind)))
674 })
675 .collect(),
605 tags: paragraphs.first().map_or_else(Vec::new, |first| {
606 let mut shared: Vec<_> = tags(first)
607 .filter_map(|tag| NoteTag::of(self.tag_kind(tag)?))
608 .filter(|(tag, action_type)| {
609 let kind = tag.definition(*action_type).kind;
610 paragraphs
611 .iter()
612 .all(|node| tags(node).any(|tag| self.tag_kind(tag) == Some(&kind)))
613 })
614 .collect();
615 shared.sort_by_key(|(_, action_type)| *action_type);
616 shared
617 }),
676618 })
677619 }
678620
......@@ -702,7 +644,7 @@ impl CanvasEditor {
702644 | Formatting::Bullets
703645 | Formatting::Numbering
704646 | Formatting::List(_)
705 | Formatting::Tag(_)
647 | Formatting::Tag(..)
706648 | Formatting::RemoveTags
707649 | Formatting::Check
708650 if title =>
......@@ -783,14 +725,8 @@ impl CanvasEditor {
783725 }
784726 });
785727 }
786 Formatting::Tag(tag) => {
787 let definition = tag.definition();
788 let Kind::TagDefinition {
789 action_type, shape, ..
790 } = definition.kind
791 else {
792 unreachable!()
793 };
728 Formatting::Tag(tag, action_type) => {
729 let definition = tag.definition(*action_type);
794730 let existing = self
795731 .definitions
796732 .iter()
......@@ -821,12 +757,12 @@ impl CanvasEditor {
821757 Some(kind) if remove => *kind != definition.kind,
822758 Some(Kind::TagDefinition {
823759 action_type: other, ..
824 }) => *other != action_type,
760 }) => *other != Some(*action_type),
825761 _ => true,
826762 });
827763 }
828764 if !remove {
829 let checkable = checkable(shape);
765 let checkable = crate::outline::checkable(tag.shape);
830766 // Stored newest first.
831767 text.tags.insert(
832768 0,
......@@ -967,7 +903,7 @@ impl CanvasEditor {
967903 Formatting::Toggle(toggle) => state.toggles.contains(toggle),
968904 Formatting::Bullets => state.bullets,
969905 Formatting::Numbering => state.numbering,
970 Formatting::Tag(tag) => state.tags.contains(tag),
906 Formatting::Tag(tag, action_type) => state.tags.contains(&(tag.clone(), *action_type)),
971907 _ => false,
972908 };
973909 let mut outlines = Vec::new();
......@@ -1038,7 +974,8 @@ impl CanvasEditor {
1038974 let checkable = |tag: &Tag| {
1039975 matches!(
1040976 self.tag_kind(tag),
1041 Some(Kind::TagDefinition { shape, .. }) if checkable(*shape)
977 Some(Kind::TagDefinition { shape: Some(shape), .. })
978 if crate::outline::checkable(*shape)
1042979 )
1043980 };
1044981 let checked = self::covered(replacement, ends)
......@@ -1823,7 +1760,10 @@ mod tests {
18231760 let mut editor = plain(&mut engine, &["task", "other"]);
18241761 editor.select([at(0, 0), at(1, 5)].into()).unwrap();
18251762 editor
1826 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1763 .format(
1764 &mut engine,
1765 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
1766 )
18271767 .unwrap();
18281768 editor.select([at(1, 2); 2].into()).unwrap();
18291769 let outline = editor.active_outline().id;
......@@ -1886,27 +1826,33 @@ mod tests {
18861826 let original = editor.active_outline().document.clone();
18871827 editor.select([at(0, 1), at(1, 2)].into()).unwrap();
18881828 editor
1889 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1829 .format(
1830 &mut engine,
1831 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
1832 )
18901833 .unwrap();
18911834 assert_eq!(text_tags(&editor, 0), [("To Do".into(), 0, true)]);
18921835 assert_eq!(text_tags(&editor, 1), [("To Do".into(), 0, true)]);
1893 assert_eq!(editor.format_state().unwrap().tags, [NoteTag::ToDo]);
1836 assert_eq!(
1837 editor.format_state().unwrap().tags,
1838 [(NoteTag::defaults()[0].clone(), 0)]
1839 );
18941840 let icon =
18951841 |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon;
18961842 assert_eq!(
18971843 icon(&editor),
1898 crate::outline::TagIcon::CheckBox {
1899 checked: false,
1900 mark: None
1844 crate::outline::TagIcon::Symbol {
1845 shape: 3,
1846 checked: false
19011847 }
19021848 );
19031849 editor.format(&mut engine, Formatting::Check).unwrap();
19041850 assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]);
19051851 assert_eq!(
19061852 icon(&editor),
1907 crate::outline::TagIcon::CheckBox {
1908 checked: true,
1909 mark: None
1853 crate::outline::TagIcon::Symbol {
1854 shape: 3,
1855 checked: true
19101856 }
19111857 );
19121858 editor.select([at(1, 0); 2].into()).unwrap();
......@@ -1918,27 +1864,39 @@ mod tests {
19181864
19191865 editor.select([at(1, 0), at(2, 5)].into()).unwrap();
19201866 editor
1921 .format(&mut engine, Formatting::Tag(NoteTag::Question))
1867 .format(
1868 &mut engine,
1869 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
1870 )
19221871 .unwrap();
19231872 assert_eq!(
19241873 text_tags(&editor, 1),
19251874 [("Question".into(), 1, true), ("To Do".into(), 1, true)]
19261875 );
19271876 assert_eq!(text_tags(&editor, 2), [("Question".into(), 1, true)]);
1928 assert_eq!(editor.format_state().unwrap().tags, [NoteTag::Question]);
1877 assert_eq!(
1878 editor.format_state().unwrap().tags,
1879 [(NoteTag::defaults()[2].clone(), 2)]
1880 );
19291881 let questions = editor
19301882 .definitions
19311883 .values()
1932 .filter(|definition| **definition == NoteTag::Question.definition())
1884 .filter(|definition| **definition == NoteTag::defaults()[2].definition(2))
19331885 .count();
19341886 assert_eq!(questions, 1);
19351887 editor
1936 .format(&mut engine, Formatting::Tag(NoteTag::Question))
1888 .format(
1889 &mut engine,
1890 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
1891 )
19371892 .unwrap();
19381893 assert_eq!(text_tags(&editor, 2), []);
19391894 editor.select([at(0, 0); 2].into()).unwrap();
19401895 editor
1941 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1896 .format(
1897 &mut engine,
1898 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
1899 )
19421900 .unwrap();
19431901 assert_eq!(text_tags(&editor, 0), []);
19441902 for _ in 0..7 {
......@@ -1961,44 +1919,35 @@ mod tests {
19611919 .values()
19621920 .filter(|definition| matches!(definition.kind, Kind::TagDefinition { .. }))
19631921 .collect::<Vec<_>>();
1964 assert_eq!(stored.len(), NoteTag::ALL.len());
1965 for tag in NoteTag::ALL {
1966 assert!(stored.contains(&&tag.definition()), "{tag:?}");
1922 let defaults = NoteTag::defaults();
1923 assert_eq!(stored.len(), defaults.len());
1924 for (place, tag) in defaults.iter().enumerate() {
1925 assert!(stored.contains(&&tag.definition(place as u16)), "{tag:?}");
19671926 }
19681927 let mut engine = TextEngine::default();
19691928 let mut editor = plain(&mut engine, &["tagged"]);
1970 for tag in NoteTag::ALL {
1971 editor.format(&mut engine, Formatting::Tag(tag)).unwrap();
1929 for (place, tag) in defaults.iter().enumerate() {
1930 editor
1931 .format(&mut engine, Formatting::Tag(tag.clone(), place as u16))
1932 .unwrap();
19721933 }
1973 assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL);
1934 assert_eq!(editor.format_state().unwrap().tags.len(), defaults.len());
19741935 let paragraph = &editor.active_outline().shaped.paragraphs[0];
1975 use crate::outline::TagIcon;
1976 let icons = paragraph
1936 let shapes = paragraph
19771937 .tags
19781938 .iter()
1979 .map(|tag| tag.icon)
1939 .map(|tag| match tag.icon {
1940 crate::outline::TagIcon::Symbol { shape, .. } => shape,
1941 crate::outline::TagIcon::Task => unreachable!(),
1942 })
19801943 .collect::<Vec<_>>();
1981 assert_eq!(icons.len(), NoteTag::ALL.len() - 2);
1982 assert_eq!(
1983 icons[..7],
1984 [
1985 TagIcon::CheckBox {
1986 checked: false,
1987 mark: None
1988 },
1989 TagIcon::Star,
1990 TagIcon::Question,
1991 TagIcon::Highlight,
1992 TagIcon::Contact,
1993 TagIcon::Address,
1994 TagIcon::Phone,
1995 ]
1996 );
1944 assert_eq!(shapes.len(), defaults.len() - 2);
1945 assert_eq!(shapes[..7], [3, 13, 15, 136, 118, 23, 18]);
19971946 // As OneNote stores them unchecked: To Do, Discuss with, Schedule meeting, Call back,
19981947 // the priorities and Client request.
1999 let checkable = NoteTag::ALL
2000 .into_iter()
2001 .filter(|tag| checkable(Some(tag.shape())))
1948 let checkable = defaults
1949 .iter()
1950 .filter(|tag| crate::outline::checkable(tag.shape))
20021951 .count();
20031952 assert_eq!(checkable, 9);
20041953 // Remember for later and Definition draw no symbol; the newer one's green marks the text.
......@@ -2011,6 +1960,46 @@ mod tests {
20111960 assert!(paragraph.text.backgrounds().next().is_some());
20121961 }
20131962
1963 /// A customized tag stores its name, symbol and colours with its place in the list as
1964 /// its action type; applied from another place it takes a definition of its own, as
1965 /// OneNote 2010's does (`corpus/custom-tags`).
1966 #[test]
1967 fn custom_tags_store_their_look_and_place() {
1968 let mut engine = TextEngine::default();
1969 let mut editor = plain(&mut engine, &["one", "two"]);
1970 let tag = NoteTag {
1971 label: "Snow check".into(),
1972 shape: 61,
1973 color: Some(0x0000_0080),
1974 highlight: Some(0x00ff_cc00),
1975 };
1976 editor
1977 .format(&mut engine, Formatting::Tag(tag.clone(), 0))
1978 .unwrap();
1979 assert_eq!(editor.format_state().unwrap().tags, [(tag.clone(), 0)]);
1980 assert_eq!(text_tags(&editor, 0), [("Snow check".into(), 1, true)]);
1981 editor.select([at(1, 0); 2].into()).unwrap();
1982 editor
1983 .format(&mut engine, Formatting::Tag(tag.clone(), 1))
1984 .unwrap();
1985 assert_eq!(editor.format_state().unwrap().tags, [(tag.clone(), 1)]);
1986 let mut stored: Vec<_> = editor
1987 .definitions
1988 .values()
1989 .filter_map(|definition| NoteTag::of(&definition.kind))
1990 .collect();
1991 stored.sort_by_key(|(_, action_type)| *action_type);
1992 assert_eq!(stored, [(tag.clone(), 0), (tag, 1)]);
1993 let paragraph = &editor.active_outline().shaped.paragraphs[0];
1994 assert!(
1995 paragraph
1996 .text
1997 .backgrounds()
1998 .all(|(_, color)| color == 0x00ff_cc00)
1999 );
2000 assert!(paragraph.text.backgrounds().next().is_some());
2001 }
2002
20142003 #[test]
20152004 fn commands_reach_every_cell_a_selection_crosses() {
20162005 let mut engine = TextEngine::default();
......@@ -2049,7 +2038,10 @@ mod tests {
20492038 editor.format(&mut engine, Formatting::Numbering).unwrap();
20502039 assert_eq!(numbers(&mut engine, &editor), [None, Some(1)]);
20512040 editor
2052 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
2041 .format(
2042 &mut engine,
2043 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2044 )
20532045 .unwrap();
20542046 assert_eq!(editor.active_outline().shaped.paragraphs[1].tags.len(), 1);
20552047 assert_eq!(editor.selection(), Selection::from([at(1, 0); 2]));
......@@ -2110,8 +2102,8 @@ mod tests {
21102102 Formatting::Highlight(Some(0x0000_ffff)),
21112103 Formatting::Align(Alignment::Center),
21122104 Formatting::Numbering,
2113 Formatting::Tag(NoteTag::ToDo),
2114 Formatting::Tag(NoteTag::Question),
2105 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2106 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
21152107 Formatting::Check,
21162108 ] {
21172109 editor.format(&mut engine, command).unwrap();
......@@ -2136,7 +2128,10 @@ mod tests {
21362128 bullets: false,
21372129 numbering: true,
21382130 list: Some(ListStyle::NUMBER),
2139 tags: vec![NoteTag::ToDo, NoteTag::Question],
2131 tags: vec![
2132 (NoteTag::defaults()[0].clone(), 0),
2133 (NoteTag::defaults()[2].clone(), 2)
2134 ],
21402135 }
21412136 );
21422137 // Format Painter, a gallery tag and an inserted table write as well.
......@@ -2155,7 +2150,10 @@ mod tests {
21552150 )
21562151 );
21572152 editor
2158 .format(&mut engine, Formatting::Tag(NoteTag::CallBack))
2153 .format(
2154 &mut engine,
2155 Formatting::Tag(NoteTag::defaults()[25].clone(), 25),
2156 )
21592157 .unwrap();
21602158 editor.select([at(0, 1); 2].into()).unwrap();
21612159 editor.insert_table(&mut engine, 2, 3).unwrap();
crates/canvas/src/editor/ops.rs+4
......@@ -330,6 +330,9 @@ impl CanvasEditor {
330330 page::Content::Ink(ink) if ink.id == placement.id => {
331331 return layout_ops(ink.id, &old, &moved(&ink.layout));
332332 }
333 page::Content::File { source, .. } if source.id == placement.id => {
334 return layout_ops(source.id, &old, &moved(&source.layout));
335 }
333336 page::Content::Outline { source, .. } if source.id == placement.id => {
334337 return layout_ops(source.id, &old, &moved(&source.layout));
335338 }
......@@ -434,6 +437,7 @@ impl CanvasEditor {
434437 page::Content::Outline { source, .. } => source.id,
435438 page::Content::Date { .. } => continue,
436439 page::Content::Image(image) => image.id,
440 page::Content::File { source, .. } => source.id,
437441 page::Content::Ink(ink) => ink.id,
438442 page::Content::ReadOnly(object)
439443 if matches!(object.source, PageObject::Title(_)) =>
crates/canvas/src/editor/page.rs+43-1
......@@ -5,7 +5,7 @@ use crate::{
55 outline::{Arrange, OutlineLayout},
66};
77use onestore::page::text::Paragraph;
8use onestore::page::{Image, Ink, Outline, Page, PageObject};
8use onestore::page::{Attachment, Image, Ink, Outline, Page, PageObject};
99use std::collections::BTreeMap;
1010
1111/// A title object's own state, plus the child origins `build` replaces with page coordinates.
......@@ -33,6 +33,11 @@ pub(crate) enum Content {
3333 below_title: Option<onestore::ExGuid>,
3434 },
3535 Image(Image),
36 /// A file on the page, laid out relative to its position.
37 File {
38 source: Attachment,
39 layout: Box<crate::outline::ObjectLayout>,
40 },
3641 Ink(Ink),
3742 Editable(onestore::ExGuid),
3843 ReadOnly(Box<ReadOnlyObject>),
......@@ -60,22 +65,46 @@ impl Content {
6065 )?))
6166 }
6267
68 /// The page-level picture shown, drawn or as a placeholder.
69 pub(super) fn picture(&self) -> Option<&Image> {
70 match self {
71 Self::Image(image) => Some(image),
72 Self::ReadOnly(object) => match &object.source {
73 PageObject::Image(image) => Some(image),
74 _ => None,
75 },
76 _ => None,
77 }
78 }
79
6380 pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> {
6481 match self {
6582 Self::Outline { source, .. } => Some((source.id, &source.layout)),
6683 Self::Image(source) => Some((source.id, &source.layout)),
84 Self::File { source, .. } => Some((source.id, &source.layout)),
6785 Self::Ink(source) => Some((source.id, &source.layout)),
6886 Self::ReadOnly(object) => Some((object.source.id(), object.source.layout())),
6987 Self::Date { .. } | Self::Editable(_) => None,
7088 }
7189 }
7290
91 /// A file on the page, where its column's `[x0, y0, x1, y1]` lie in page points.
92 pub(crate) fn file(&self) -> Option<(&Attachment, [f32; 4])> {
93 let Self::File { source, layout } = self else {
94 return None;
95 };
96 let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0));
97 let [x0, y0, x1, y1] = layout.bounds();
98 Some((source, [x + x0, y + y0, x + x1, y + y1]))
99 }
100
73101 pub(super) fn layout_mut(
74102 &mut self,
75103 ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> {
76104 match self {
77105 Self::Outline { source, .. } => Some((source.id, &mut source.layout)),
78106 Self::Image(source) => Some((source.id, &mut source.layout)),
107 Self::File { source, .. } => Some((source.id, &mut source.layout)),
79108 Self::Ink(source) => Some((source.id, &mut source.layout)),
80109 Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())),
81110 Self::Date { .. } | Self::Editable(_) => None,
......@@ -332,6 +361,19 @@ pub(crate) fn build(
332361 };
333362 objects.push(Content::Image(source));
334363 }
364 PageObject::Attachment(source) => {
365 if [source.layout.x, source.layout.y]
366 .iter()
367 .any(|v| !v.unwrap_or(0.0).is_finite())
368 {
369 return Err(EditorError::InvalidGeometry);
370 }
371 let layout = Box::new(crate::outline::page_file(engine, source)?);
372 let PageObject::Attachment(source) = object else {
373 unreachable!()
374 };
375 objects.push(Content::File { source, layout });
376 }
335377 PageObject::Ink(ink) => {
336378 if ink_bounds(ink).is_some_and(|b| b.iter().any(|v| !v.is_finite())) {
337379 return Err(EditorError::InvalidGeometry);
crates/canvas/src/editor/space.rs+2-1
......@@ -5,7 +5,7 @@ use super::*;
55
66/// Something Insert Space moves.
77enum Piece {
8 /// A body outline, picture or ink drawing, placed anew.
8 /// A body outline, picture, file or ink drawing, placed anew.
99 Object(ExGuid),
1010 /// The root nodes of a body outline from `at` on.
1111 Tail { outline: ExGuid, at: usize },
......@@ -81,6 +81,7 @@ impl CanvasEditor {
8181 ];
8282 (image.id, rect)
8383 }
84 page::Content::File { source, .. } => (source.id, content.file().unwrap().1),
8485 page::Content::Ink(ink) => match page::ink_bounds(ink) {
8586 Some(rect) => (ink.id, rect),
8687 None => continue,
crates/canvas/src/gpu/mod.rs+206-126
......@@ -4,7 +4,7 @@ mod profile;
44
55use crate::{
66 layout::{TextBrush, TextLayout},
7 outline::{BoxMark, TagIcon},
7 outline::TagIcon,
88};
99use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError};
1010use parley::PositionedLayoutItem;
......@@ -140,116 +140,199 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout {
140140 .expect("Page text is a TextLayout")
141141}
142142
143const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg");
144143const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg");
145const STAR: &str = include_str!("../../assets/tags/star.svg");
146const QUESTION: &str = include_str!("../../assets/tags/question.svg");
147const HIGHLIGHT: &str = include_str!("../../assets/tags/highlight.svg");
148const CONTACT: &str = include_str!("../../assets/tags/contact.svg");
149const ADDRESS: &str = include_str!("../../assets/tags/address.svg");
150const PHONE: &str = include_str!("../../assets/tags/phone.svg");
151const MUSIC: &str = include_str!("../../assets/tags/music.svg");
152const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg");
153const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");
154const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg");
155const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg");
156144const FLAG: &str = include_str!("../../assets/tags/flag.svg");
157const IDEA: &str = include_str!("../../assets/tags/idea.svg");
158const PASSWORD: &str = include_str!("../../assets/tags/password.svg");
159const MOVIE: &str = include_str!("../../assets/tags/movie.svg");
160const BOOK: &str = include_str!("../../assets/tags/book.svg");
161const WEB: &str = include_str!("../../assets/tags/web.svg");
162const BLOG: &str = include_str!("../../assets/tags/blog.svg");
163const EMAIL: &str = include_str!("../../assets/tags/email.svg");
164145const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg");
165146const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg");
166const MARK_PERSON: &str = include_str!("../../assets/tags/mark-person.svg");
167const MARK_MANAGER: &str = include_str!("../../assets/tags/mark-manager.svg");
168const MARK_ARROW: &str = include_str!("../../assets/tags/mark-arrow.svg");
169const MARK_ONE: &str = include_str!("../../assets/tags/mark-one.svg");
170const MARK_TWO: &str = include_str!("../../assets/tags/mark-two.svg");
171const MARK_CLIENT: &str = include_str!("../../assets/tags/mark-client.svg");
172147const TAG: &str = include_str!("../../assets/tags/tag.svg");
173148
149/// How a tag symbol's artwork takes a check: a large box under it, a small box with the
150/// check between the box and its badge, or art that shows none.
151enum Art {
152 Box(&'static str),
153 Badged(&'static str),
154 Plain(&'static str),
155}
156
157/// The artwork of symbol `shape` of MS-ONE's NoteTagShape, as OneNote 2010 draws it on the
158/// page: it draws 54, Green 8-Point Star, blue there. A shape MS-ONE does not list shows a
159/// plain tag.
160fn art(shape: u16) -> Art {
161 use Art::{Badged, Box, Plain};
162 macro_rules! tag {
163 ($name:literal) => {
164 include_str!(concat!("../../assets/tags/", $name, ".svg"))
165 };
166 }
167 match shape {
168 1 => Box(tag!("checkbox-green")),
169 2 => Box(tag!("checkbox-yellow")),
170 3 => Box(tag!("checkbox")),
171 4 => Badged(tag!("mark-star-green")),
172 5 => Badged(tag!("mark-star-yellow")),
173 6 => Badged(tag!("mark-star-blue")),
174 7 => Badged(tag!("mark-exclamation-green")),
175 8 => Badged(tag!("mark-exclamation-yellow")),
176 9 => Badged(tag!("mark-exclamation-blue")),
177 10 => Badged(tag!("mark-arrow-green")),
178 11 => Badged(tag!("mark-arrow-yellow")),
179 12 => Badged(tag!("mark-arrow-blue")),
180 13 => Plain(tag!("star")),
181 14 => Plain(tag!("follow-up")),
182 15 => Plain(tag!("question")),
183 16 => Plain(tag!("arrow-right-blue")),
184 17 => Plain(tag!("exclamation")),
185 18 => Plain(tag!("phone")),
186 19 => Plain(tag!("calendar")),
187 20 => Plain(tag!("clock")),
188 21 => Plain(tag!("idea")),
189 22 => Plain(tag!("pushpin")),
190 23 => Plain(tag!("address")),
191 24 => Plain(tag!("blog")),
192 25 => Plain(tag!("smiley")),
193 26 => Plain(tag!("ribbon")),
194 27 => Plain(tag!("key")),
195 28 => Badged(tag!("mark-one-blue")),
196 29 => Plain(tag!("circle-1-blue")),
197 30 => Badged(tag!("mark-two-blue")),
198 31 => Plain(tag!("circle-2-blue")),
199 32 => Badged(tag!("mark-three-blue")),
200 33 => Plain(tag!("circle-3-blue")),
201 34 => Plain(tag!("star8-blue")),
202 35 => Plain(tag!("tick-blue")),
203 36 => Plain(tag!("circle-blue")),
204 37 => Plain(tag!("arrow-down-blue")),
205 38 => Plain(tag!("arrow-left-blue")),
206 39 => Plain(tag!("solid-target-blue")),
207 40 => Plain(tag!("star-blue")),
208 41 => Plain(tag!("sun-blue")),
209 42 => Plain(tag!("target-blue")),
210 43 => Plain(tag!("triangle-blue")),
211 44 => Plain(tag!("umbrella-blue")),
212 45 => Plain(tag!("arrow-up-blue")),
213 46 => Plain(tag!("x-dots-blue")),
214 47 => Plain(tag!("x-blue")),
215 48 => Badged(tag!("mark-one-green")),
216 49 => Plain(tag!("circle-1-green")),
217 50 => Badged(tag!("mark-two-green")),
218 51 => Plain(tag!("circle-2-green")),
219 52 => Badged(tag!("mark-three-green")),
220 53 => Plain(tag!("circle-3-green")),
221 54 => Plain(tag!("star8-blue")),
222 55 => Plain(tag!("tick-green")),
223 56 => Plain(tag!("circle-green")),
224 57 => Plain(tag!("arrow-down-green")),
225 58 => Plain(tag!("arrow-left-green")),
226 59 => Plain(tag!("arrow-right-green")),
227 60 => Plain(tag!("solid-target-green")),
228 61 => Plain(tag!("star-green")),
229 62 => Plain(tag!("sun-green")),
230 63 => Plain(tag!("target-green")),
231 64 => Plain(tag!("triangle-green")),
232 65 => Plain(tag!("umbrella-green")),
233 66 => Plain(tag!("arrow-up-green")),
234 67 => Plain(tag!("x-dots-green")),
235 68 => Plain(tag!("x-green")),
236 69 => Badged(tag!("mark-one-yellow")),
237 70 => Plain(tag!("circle-1-yellow")),
238 71 => Badged(tag!("mark-two-yellow")),
239 72 => Plain(tag!("circle-2-yellow")),
240 73 => Badged(tag!("mark-three-yellow")),
241 74 => Plain(tag!("circle-3-yellow")),
242 75 => Plain(tag!("star8-yellow")),
243 76 => Plain(tag!("tick-yellow")),
244 77 => Plain(tag!("circle-yellow")),
245 78 => Plain(tag!("arrow-down-yellow")),
246 79 => Plain(tag!("arrow-left-yellow")),
247 80 => Plain(tag!("arrow-right-yellow")),
248 81 => Plain(tag!("solid-target-yellow")),
249 82 => Plain(tag!("sun-yellow")),
250 83 => Plain(tag!("target-yellow")),
251 84 => Plain(tag!("triangle-yellow")),
252 85 => Plain(tag!("umbrella-yellow")),
253 86 => Plain(tag!("arrow-up-yellow")),
254 87 => Plain(tag!("x-dots-yellow")),
255 88 => Plain(tag!("x-yellow")),
256 89 => Plain(tag!("flag-today")),
257 90 => Plain(tag!("flag-tomorrow")),
258 91 => Plain(tag!("flag-this-week")),
259 92 => Plain(tag!("flag-next-week")),
260 93 => Plain(tag!("flag-no-date")),
261 94 => Badged(tag!("mark-person-blue")),
262 95 => Badged(tag!("mark-person-yellow")),
263 96 => Badged(tag!("mark-person-green")),
264 97 => Badged(tag!("mark-flag-blue")),
265 98 => Badged(tag!("mark-flag-yellow")),
266 99 => Badged(tag!("mark-flag-green")),
267 100 => Plain(tag!("red-square")),
268 101 => Plain(tag!("yellow-square")),
269 102 => Plain(tag!("blue-square")),
270 103 => Plain(tag!("green-square")),
271 104 => Plain(tag!("orange-square")),
272 105 => Plain(tag!("pink-square")),
273 106 => Plain(tag!("email")),
274 107 => Plain(tag!("envelope")),
275 108 => Plain(tag!("envelope-open")),
276 109 => Plain(tag!("mobile")),
277 110 => Plain(tag!("phone-clock")),
278 111 => Plain(tag!("question-balloon")),
279 112 => Plain(tag!("paperclip")),
280 113 => Plain(tag!("frown")),
281 114 => Plain(tag!("im-contact")),
282 115 => Plain(tag!("person")),
283 116 => Plain(tag!("people")),
284 117 => Plain(tag!("bell")),
285 118 => Plain(tag!("contact")),
286 119 => Plain(tag!("rose")),
287 120 => Plain(tag!("date")),
288 121 => Plain(tag!("music")),
289 122 => Plain(tag!("movie")),
290 123 => Plain(tag!("quote")),
291 124 => Plain(tag!("globe")),
292 125 => Plain(tag!("web")),
293 126 => Plain(tag!("laptop")),
294 127 => Plain(tag!("plane")),
295 128 => Plain(tag!("car")),
296 129 => Plain(tag!("binoculars")),
297 130 => Plain(tag!("presentation")),
298 131 => Plain(tag!("password")),
299 132 => Plain(tag!("book")),
300 133 => Plain(tag!("notebook")),
301 134 => Plain(tag!("paper")),
302 135 => Plain(tag!("research")),
303 136 => Plain(tag!("highlight")),
304 137 => Plain(tag!("dollar")),
305 138 => Plain(tag!("coins")),
306 139 => Plain(tag!("schedule")),
307 140 => Plain(tag!("lightning")),
308 141 => Plain(tag!("cloud")),
309 142 => Plain(tag!("heart")),
310 143 => Plain(tag!("sunflower")),
311 _ => Plain(TAG),
312 }
313}
314
315/// Symbols MS-ONE lists.
316const SYMBOLS: u16 = 143;
317
174318/// The tag's artwork, drawn in order.
175319pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
176 match icon {
177 TagIcon::CheckBox {
178 checked: false,
179 mark: None,
180 } => &[CHECKBOX],
181 TagIcon::CheckBox {
182 checked: true,
183 mark: None,
184 } => &[CHECKBOX, CHECKMARK],
185 TagIcon::CheckBox {
186 checked: false,
187 mark: Some(BoxMark::Person),
188 } => &[BOX_SMALL, MARK_PERSON],
189 TagIcon::CheckBox {
190 checked: true,
191 mark: Some(BoxMark::Person),
192 } => &[BOX_SMALL, CHECK_SMALL, MARK_PERSON],
193 TagIcon::CheckBox {
194 checked: false,
195 mark: Some(BoxMark::Manager),
196 } => &[BOX_SMALL, MARK_MANAGER],
197 TagIcon::CheckBox {
198 checked: true,
199 mark: Some(BoxMark::Manager),
200 } => &[BOX_SMALL, CHECK_SMALL, MARK_MANAGER],
201 TagIcon::CheckBox {
202 checked: false,
203 mark: Some(BoxMark::Arrow),
204 } => &[BOX_SMALL, MARK_ARROW],
205 TagIcon::CheckBox {
206 checked: true,
207 mark: Some(BoxMark::Arrow),
208 } => &[BOX_SMALL, CHECK_SMALL, MARK_ARROW],
209 TagIcon::CheckBox {
210 checked: false,
211 mark: Some(BoxMark::One),
212 } => &[BOX_SMALL, MARK_ONE],
213 TagIcon::CheckBox {
214 checked: true,
215 mark: Some(BoxMark::One),
216 } => &[BOX_SMALL, CHECK_SMALL, MARK_ONE],
217 TagIcon::CheckBox {
218 checked: false,
219 mark: Some(BoxMark::Two),
220 } => &[BOX_SMALL, MARK_TWO],
221 TagIcon::CheckBox {
222 checked: true,
223 mark: Some(BoxMark::Two),
224 } => &[BOX_SMALL, CHECK_SMALL, MARK_TWO],
225 TagIcon::CheckBox {
226 checked: false,
227 mark: Some(BoxMark::Client),
228 } => &[BOX_SMALL, MARK_CLIENT],
229 TagIcon::CheckBox {
230 checked: true,
231 mark: Some(BoxMark::Client),
232 } => &[BOX_SMALL, CHECK_SMALL, MARK_CLIENT],
233 TagIcon::Star => &[STAR],
234 TagIcon::Question => &[QUESTION],
235 TagIcon::Highlight => &[HIGHLIGHT],
236 TagIcon::Contact => &[CONTACT],
237 TagIcon::Address => &[ADDRESS],
238 TagIcon::Phone => &[PHONE],
239 TagIcon::Music => &[MUSIC],
240 TagIcon::Exclamation => &[EXCLAMATION],
241 TagIcon::RedSquare => &[RED_SQUARE],
242 TagIcon::YellowSquare => &[YELLOW_SQUARE],
243 TagIcon::BlueSquare => &[BLUE_SQUARE],
244 TagIcon::Flag => &[FLAG],
245 TagIcon::Idea => &[IDEA],
246 TagIcon::Password => &[PASSWORD],
247 TagIcon::Movie => &[MOVIE],
248 TagIcon::Book => &[BOOK],
249 TagIcon::Web => &[WEB],
250 TagIcon::Blog => &[BLOG],
251 TagIcon::Email => &[EMAIL],
252 TagIcon::Other => &[TAG],
320 static SOURCES: std::sync::OnceLock<Vec<[Vec<&'static str>; 2]>> = std::sync::OnceLock::new();
321 let TagIcon::Symbol { shape, checked } = icon else {
322 return &[FLAG];
323 };
324 let sources = SOURCES.get_or_init(|| {
325 (0..=SYMBOLS)
326 .map(|shape| match art(shape) {
327 Art::Box(art) => [vec![art], vec![art, CHECKMARK]],
328 Art::Badged(badge) => [vec![BOX_SMALL, badge], vec![BOX_SMALL, CHECK_SMALL, badge]],
329 Art::Plain(art) => [vec![art], vec![art]],
330 })
331 .collect()
332 });
333 match sources.get(usize::from(shape)) {
334 Some(sources) => &sources[usize::from(checked)],
335 None => &[TAG],
253336 }
254337}
255338
......@@ -364,29 +447,26 @@ mod tests {
364447 mapped_at_creation: false,
365448 });
366449 let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb);
367 let icons = [
368 TagIcon::CheckBox {
369 checked: false,
370 mark: None,
371 },
372 TagIcon::CheckBox {
373 checked: true,
374 mark: None,
375 },
376 TagIcon::Star,
377 TagIcon::Question,
378 TagIcon::Highlight,
379 TagIcon::Contact,
380 TagIcon::Address,
381 TagIcon::Phone,
382 TagIcon::Music,
383 TagIcon::Exclamation,
384 TagIcon::RedSquare,
385 TagIcon::YellowSquare,
386 TagIcon::BlueSquare,
387 TagIcon::Flag,
388 TagIcon::Other,
389 ];
450 let icons: Vec<_> = [
451 (3, false),
452 (3, true),
453 (13, false),
454 (15, false),
455 (136, false),
456 (118, false),
457 (23, false),
458 (18, false),
459 (121, false),
460 (17, false),
461 (100, false),
462 (101, false),
463 (102, false),
464 (999, false),
465 ]
466 .into_iter()
467 .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap())
468 .chain([TagIcon::Task])
469 .collect();
390470 let text = TextEngine::default()
391471 .layout(
392472 &Paragraph::from_runs([
crates/canvas/src/gpu/page.rs+139-39
......@@ -79,6 +79,7 @@ pub enum SceneHit<T> {
7979 Date(DateField),
8080 ReadOnly(usize),
8181 Image(onestore::ExGuid),
82 File(onestore::ExGuid),
8283}
8384
8485fn outline_origin(
......@@ -139,6 +140,14 @@ fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec<
139140 }
140141}
141142
143const FILE_ICON: &str = include_str!("../../assets/file.svg");
144
145/// The blank page for a file without an icon of its own, as the 32 pixel PNG OneNote 2010
146/// stores with every attachment; it draws a broken picture for one stored without.
147pub fn file_icon() -> Vec<u8> {
148 background::png(FILE_ICON, 32)
149}
150
142151fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primitive<'_>>) {
143152 primitives.push(Primitive::Rect {
144153 rect,
......@@ -230,10 +239,9 @@ impl PageScene {
230239 }
231240 }
232241 for (id, art) in &mut scene.backgrounds {
233 if let Some(shown) = self.backgrounds.remove(id)
234 && shown.same(art)
235 {
236 *art = shown;
242 match self.backgrounds.remove(id) {
243 Some(shown) if shown.same(art) => *art = shown,
244 _ => art.inline = true,
237245 }
238246 }
239247 scene.mark_unavailable(&mut editor.objects, engine)?;
......@@ -241,6 +249,28 @@ impl PageScene {
241249 Ok(())
242250 }
243251
252 /// Follows `editor` after an edit or undo brought page-level pictures the scene has not
253 /// seen, as `refresh` does.
254 pub fn follow(
255 &mut self,
256 editor: &mut CanvasEditor,
257 engine: &mut TextEngine,
258 ) -> Result<(), SceneError> {
259 let known = |object: &Content| match object {
260 Content::Image(source) => {
261 self.pictures.contains_key(&source.id) || self.backgrounds.contains_key(&source.id)
262 }
263 Content::File { source, .. } => {
264 source.preview.is_none() || self.pictures.contains_key(&source.id)
265 }
266 _ => true,
267 };
268 if editor.objects.iter().all(known) {
269 return Ok(());
270 }
271 self.refresh(editor, engine)
272 }
273
244274 /// Turns page pictures the renderer cannot decode into placeholders; their stored data
245275 /// is kept.
246276 fn mark_unavailable(
......@@ -275,7 +305,6 @@ impl PageScene {
275305 onestore::page::ParagraphContent::Image(image) => {
276306 payloads.push((image.id, image.bytes.as_ref()))
277307 }
278 // A file without the icon OneNote rendered for it keeps an empty slot.
279308 onestore::page::ParagraphContent::Attachment(file) => {
280309 if let Some(icon) = file.preview.as_ref() {
281310 payloads.push((file.id, Some(icon)))
......@@ -303,6 +332,11 @@ impl PageScene {
303332 payloads.push((source.id, source.bytes.as_ref()))
304333 }
305334 }
335 Content::File { source, .. } => {
336 if let Some(icon) = source.preview.as_ref() {
337 payloads.push((source.id, Some(icon)))
338 }
339 }
306340 Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads),
307341 Content::Editable(id) => {
308342 if let Some(outline) =
......@@ -396,6 +430,11 @@ impl PageScene {
396430 rects.push((source.id, [x, y, x + width, y + height]));
397431 }
398432 }
433 Content::File { source, layout } => {
434 let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0));
435 let [x0, y0, x1, y1] = layout.rect;
436 rects.push((source.id, [x + x0, y + y0, x + x1, y + y1]));
437 }
399438 Content::Outline {
400439 source,
401440 layout,
......@@ -433,40 +472,61 @@ impl PageScene {
433472 primitives: &mut Vec<Primitive<'a>>,
434473 ) {
435474 for object in &outline.objects {
436 let [x0, y0, x1, y1] = object.rect;
437 let rect = match moving {
438 Some((id, rect)) if id == object.id => rect,
439 _ => [
440 x0 + origin[0],
441 y0 + origin[1],
442 x1 + origin[0],
443 y1 + origin[1],
444 ],
445 };
446 let picture = self.pictures.get(&object.id);
447 if let Some(image) = picture.and_then(Picture::image) {
448 primitives.push(Primitive::Image { image, rect });
475 self.append_object(object, origin, moving, paper, primitives);
476 }
477 }
478
479 /// One picture, file, drawing or placeholder laid out from `origin`; `moving` as for
480 /// `append_outline_objects`.
481 fn append_object<'a>(
482 &'a self,
483 object: &'a crate::outline::ObjectLayout,
484 origin: [f32; 2],
485 moving: Option<(onestore::ExGuid, [f32; 4])>,
486 paper: Paper,
487 primitives: &mut Vec<Primitive<'a>>,
488 ) {
489 let [x0, y0, x1, y1] = object.rect;
490 let rect = match moving {
491 Some((id, rect)) if id == object.id => rect,
492 _ => [
493 x0 + origin[0],
494 y0 + origin[1],
495 x1 + origin[0],
496 y1 + origin[1],
497 ],
498 };
499 let picture = self.pictures.get(&object.id);
500 if let Some(image) = picture.and_then(Picture::image) {
501 primitives.push(Primitive::Image { image, rect });
502 }
503 match &object.kind {
504 crate::outline::ObjectKind::Ink(ink) => append_ink(ink, [rect[0], rect[1]], primitives),
505 crate::outline::ObjectKind::Unsupported(_) => {
506 append_placeholder(rect, paper, primitives)
449507 }
450 match &object.kind {
451 crate::outline::ObjectKind::Ink(ink) => {
452 append_ink(ink, [rect[0], rect[1]], primitives)
453 }
454 crate::outline::ObjectKind::Unsupported(_) => {
455 append_placeholder(rect, paper, primitives)
456 }
457 crate::outline::ObjectKind::Picture if picture.is_none_or(Picture::failed) => {
458 append_placeholder(rect, paper, primitives)
459 }
460 crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {}
508 crate::outline::ObjectKind::Picture if picture.is_none_or(Picture::failed) => {
509 append_placeholder(rect, paper, primitives)
461510 }
462 if let Some(label) = object.label() {
463 primitives.push(Primitive::Text {
464 text: &label.text,
465 origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]],
466 clip: None,
467 ink: paper.ink,
468 });
511 // A file stored without the icon OneNote rendered for it shows a blank page.
512 crate::outline::ObjectKind::File(_) if picture.is_none() => {
513 primitives.push(Primitive::Icon {
514 sources: &[FILE_ICON],
515 origin: [rect[0], rect[1]],
516 size: rect[2] - rect[0],
517 tint: [1.0; 4],
518 palette: draw::Palette::default(),
519 })
469520 }
521 crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {}
522 }
523 if let Some(label) = object.label() {
524 primitives.push(Primitive::Text {
525 text: &label.text,
526 origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]],
527 clip: None,
528 ink: paper.ink,
529 });
470530 }
471531 }
472532
......@@ -558,6 +618,7 @@ impl PageScene {
558618 ],
559619 false,
560620 )),
621 Content::File { .. } => Some((object.file()?.1, true)),
561622 Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)),
562623 Content::ReadOnly(object) => Some((object.rect(), true)),
563624 Content::Editable(_) => None,
......@@ -652,6 +713,12 @@ impl PageScene {
652713 return Some(SceneHit::Image(source.id));
653714 }
654715 }
716 Content::File { .. } => {
717 let (file, [x0, y0, x1, y1]) = object.file()?;
718 if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) {
719 return Some(SceneHit::File(file.id));
720 }
721 }
655722 Content::Outline { .. } | Content::Image(_) | Content::Ink(_) => {}
656723 }
657724 }
......@@ -737,13 +804,43 @@ impl PageScene {
737804 append_ink(ink, [offset[0] + x, offset[1] + y], primitives);
738805 continue;
739806 }
807 Content::File { source, layout } => {
808 // A dragged file draws where its column would land.
809 let [x, y] = match moving {
810 Some((id, [x0, y0, ..])) if id == source.id => [x0, y0],
811 _ => [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)),
812 };
813 self.append_object(
814 layout,
815 [x + offset[0], y + offset[1]],
816 None,
817 paper,
818 primitives,
819 );
820 continue;
821 }
740822 };
741823 let object_origin = [origin[0] + offset[0], origin[1] + offset[1]];
742824 match content {
743825 Content::Image(source) => {
744 // The paper shows until the worker's raster lands.
745826 let (image, [width, height]) = match self.backgrounds.get(&source.id) {
746 Some(art) => (art.image(paper), art.size),
827 Some(art) => {
828 // OneNote's art is opaque, over white, and hides the rule lines.
829 primitives.push(Primitive::Rect {
830 rect: [
831 object_origin[0],
832 object_origin[1],
833 object_origin[0] + art.size[0],
834 object_origin[1] + art.size[1],
835 ],
836 color: if background::dark(paper) {
837 paper.color
838 } else {
839 Paper::WHITE.color
840 },
841 });
842 (art.image(paper), art.size)
843 }
747844 None => {
748845 let picture = self
749846 .pictures
......@@ -816,7 +913,10 @@ impl PageScene {
816913 );
817914 }
818915 }
819 Content::Editable(_) | Content::ReadOnly(_) | Content::Ink(_) => unreachable!(),
916 Content::Editable(_)
917 | Content::ReadOnly(_)
918 | Content::Ink(_)
919 | Content::File { .. } => unreachable!(),
820920 }
821921 }
822922 Ok(())
crates/canvas/src/gpu/page/background.rs+54-17
......@@ -402,6 +402,10 @@ const MARKER: &str = "Snowbound template";
402402/// device pixels per point.
403403const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0;
404404
405/// Art an edit brings draws in its first frame in at most this many pixels, a few
406/// milliseconds' work, then sharpens.
407const INLINE_PIXELS: f32 = 1024.0 * 1024.0;
408
405409/// Template art standing in for a page's background picture.
406410pub(super) struct Background {
407411 svg: &'static str,
......@@ -409,14 +413,19 @@ pub(super) struct Background {
409413 pub size: [f32; 2],
410414 /// For light and for dark paper.
411415 variants: [Variant; 2],
416 /// Whether the first raster is made on the frame's thread rather than awaited.
417 pub inline: bool,
412418}
413419
420/// A raster's device pixels per point, and the dark paper it was recoloured for.
421type Made = (f32, Option<[f32; 4]>);
422
414423#[derive(Default)]
415424struct Variant {
416 /// The raster painted and its device pixels per point.
417 shown: Option<(RasterImage, f32)>,
418 /// The one raster being made and its device pixels per point; replacing it abandons it.
419 pending: Option<(Slot<RasterImage>, f32)>,
425 /// The raster painted and what it was made for.
426 shown: Option<(RasterImage, Made)>,
427 /// The one raster being made and what for; replacing it abandons it.
428 pending: Option<(Slot<RasterImage>, Made)>,
420429}
421430
422431impl Background {
......@@ -451,6 +460,7 @@ impl Background {
451460 svg,
452461 size,
453462 variants: Default::default(),
463 inline: false,
454464 })
455465 }
456466
......@@ -469,38 +479,44 @@ impl Background {
469479 let density = density(scale)
470480 .min((MAX_RASTER_BYTES / (4.0 * width * height)).sqrt())
471481 .min(4096.0 / width.max(height));
482 let made = (density, dark.then_some(paper.color));
483 let svg = self.svg;
484 let paper = dark.then_some(paper);
485 let raster = move |density: f32| {
486 let size = [width, height].map(|side| (side * density).round().max(1.0) as u32);
487 move || rasterize(svg, paper, size)
488 };
472489 let variant = &mut self.variants[usize::from(dark)];
473490 let landed = variant
474491 .pending
475492 .as_ref()
476493 .and_then(|(slot, made)| Some((slot.lock().unwrap().take()?, *made)));
477 if let Some((image, made)) = landed {
478 if made == density || variant.shown.is_none() {
479 variant.shown = Some((image, made));
494 if let Some((image, landed)) = landed {
495 if landed == made || variant.shown.is_none() {
496 variant.shown = Some((image, landed));
480497 }
481498 variant.pending = None;
482499 }
500 if variant.shown.is_none() && std::mem::take(&mut self.inline) {
501 let quick = density.min((INLINE_PIXELS / (width * height)).sqrt());
502 variant.shown = Some((raster(quick)(), (quick, made.1)));
503 }
483504 let shown = variant.shown.as_ref().map(|(_, made)| *made);
484 if shown != Some(density)
485 && variant.pending.as_ref().map(|(_, made)| *made) != Some(density)
486 {
487 let svg = self.svg;
488 let paper = dark.then_some(paper);
489 let size = [width, height].map(|side| (side * density).round().max(1.0) as u32);
505 if shown != Some(made) && variant.pending.as_ref().map(|(_, made)| *made) != Some(made) {
490506 let start = Instant::now()
491507 + if shown.is_some() {
492508 SETTLE
493509 } else {
494510 Duration::ZERO
495511 };
496 let slot = queue(start, waker, move || rasterize(svg, paper, size));
497 variant.pending = Some((slot, density));
512 let slot = queue(start, waker, raster(density));
513 variant.pending = Some((slot, made));
498514 }
499 shown == Some(density)
515 shown == Some(made)
500516 }
501517}
502518
503fn dark(paper: Paper) -> bool {
519pub(super) fn dark(paper: Paper) -> bool {
504520 let [lightness, ..] = draw::oklab(paper.color);
505521 let [ink, ..] = draw::oklab(paper.ink);
506522 ink > lightness
......@@ -514,6 +530,19 @@ fn rasterize(svg: &str, dark: Option<Paper>, size: [u32; 2]) -> RasterImage {
514530 RasterImage::new(size, pixels).expect("Art is within the image budget")
515531}
516532
533/// `svg` as a PNG `side` pixels square.
534pub(super) fn png(svg: &str, side: u32) -> Vec<u8> {
535 let mut encoded = Vec::new();
536 let mut encoder = png::Encoder::new(&mut encoded, side, side);
537 encoder.set_color(png::ColorType::Rgba);
538 encoder.set_depth(png::BitDepth::Eight);
539 encoder
540 .write_header()
541 .and_then(|mut writer| writer.write_image_data(&render(svg, [side; 2])))
542 .expect("A PNG encodes into memory");
543 encoded
544}
545
517546/// Straight-alpha RGBA rows of `svg` stretched to `width` by `height` pixels.
518547fn render(svg: &str, [width, height]: [u32; 2]) -> Vec<u8> {
519548 let tree = parse(svg);
......@@ -747,6 +776,7 @@ mod tests {
747776 svg: TEMPLATES.iter().find(|t| t.name == "ivy").unwrap().svg,
748777 size: [64.0, 48.0],
749778 variants: Default::default(),
779 inline: false,
750780 }
751781 }
752782
......@@ -805,6 +835,13 @@ mod tests {
805835 }
806836 }
807837
838 #[test]
839 fn every_template_s_art_is_recreated() {
840 for art in crate::template::TEMPLATES.iter().flat_map(|t| t.art) {
841 assert!(TEMPLATES.iter().any(|t| t.name == art.art), "{}", art.art);
842 }
843 }
844
808845 #[test]
809846 fn our_template_pictures_are_recognised_as_the_template() {
810847 for (name, magic, size) in [
crates/canvas/src/interaction/accessibility.rs+8-31
......@@ -594,38 +594,15 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
594594 .tags
595595 .iter()
596596 .map(|tag| {
597 use crate::outline::TagIcon;
598 let fallback = match tag.icon {
599 TagIcon::CheckBox { .. } => "To do",
600 TagIcon::Star => "Important",
601 TagIcon::Question => "Question",
602 TagIcon::Highlight => "Highlight",
603 TagIcon::Contact => "Contact",
604 TagIcon::Address => "Address",
605 TagIcon::Phone => "Phone number",
606 TagIcon::Music => "Music",
607 TagIcon::Exclamation => "Critical",
608 TagIcon::RedSquare => "Project A",
609 TagIcon::YellowSquare => "Project B",
610 TagIcon::BlueSquare => "Project C",
611 TagIcon::Flag => "Outlook task",
612 TagIcon::Idea => "Idea",
613 TagIcon::Password => "Password",
614 TagIcon::Movie => "Movie to see",
615 TagIcon::Book => "Book to read",
616 TagIcon::Web => "Web site to visit",
617 TagIcon::Blog => "Remember for blog",
618 TagIcon::Email => "Send in email",
619 TagIcon::Other => "Tag",
620 };
621 let label = if tag.label.is_empty() {
622 fallback
623 } else {
624 &tag.label
597 use crate::outline::{TagIcon, symbol_name};
598 let label = match tag.icon {
599 _ if !tag.label.is_empty() => &tag.label,
600 TagIcon::Symbol { shape, .. } => symbol_name(shape).unwrap_or("Tag"),
601 TagIcon::Task => "Outlook task",
625602 };
626603 let state = match tag.icon {
627 TagIcon::CheckBox { checked: true, .. } => ", completed",
628 TagIcon::CheckBox { checked: false, .. } => ", incomplete",
604 TagIcon::Symbol { checked: true, .. } => ", completed",
605 icon if icon.checkable() => ", incomplete",
629606 _ => "",
630607 };
631608 format!(
......@@ -1026,7 +1003,7 @@ pub(super) mod tests {
10261003 "Rehearsal, incomplete",
10271004 "Rehearsal, completed",
10281005 "Question",
1029 "Music, disabled"
1006 "Music Note, disabled"
10301007 ]
10311008 );
10321009 let text = state.focus().unwrap().document_range().text();
crates/canvas/src/interaction/mod.rs+146-20
......@@ -60,6 +60,8 @@ pub enum Request {
6060 Paste,
6161 /// Open a link's address, as a click on a link does.
6262 OpenLink(String),
63 /// Open a copy of a file with the system's application for it, as a double click does.
64 OpenAttachment(onestore::page::Attachment),
6365}
6466
6567/// What a secondary press lands on, which its context menu offers commands for.
......@@ -72,6 +74,8 @@ pub struct Context {
7274 pub selected: bool,
7375 /// The paragraph at the caret, which Copy Link to Paragraph names.
7476 pub paragraph: Option<onestore::ExGuid>,
77 /// The file the press selected, which Open and Save As take.
78 pub attachment: Option<onestore::page::Attachment>,
7579}
7680
7781/// What an event did: `changed` means the page or selection changed (the host saves,
......@@ -175,13 +179,15 @@ enum PointerFeedback<'a> {
175179pub enum ObjectFocus {
176180 ReadOnly(usize),
177181 Image(onestore::ExGuid),
182 /// A file in an outline's flow or on the page.
183 File(onestore::ExGuid),
178184}
179185
180186impl ObjectFocus {
181187 pub fn read_only(self) -> Option<usize> {
182188 match self {
183189 Self::ReadOnly(index) => Some(index),
184 Self::Image(_) => None,
190 Self::Image(_) | Self::File(_) => None,
185191 }
186192 }
187193}
......@@ -519,6 +525,7 @@ impl PageView {
519525 let (origin, size) = self.editor.image_placement(id).unwrap();
520526 image_rect(origin, size)
521527 }
528 ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(),
522529 };
523530 [
524531 x0 + offset[0],
......@@ -861,7 +868,7 @@ impl PageView {
861868 {
862869 Cursor::Pointer
863870 }
864 (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default,
871 (None, Some(Hit::ReadOnly(_) | Hit::Check { .. } | Hit::File(_))) => Cursor::Default,
865872 _ => Cursor::Text,
866873 }
867874 }
......@@ -998,6 +1005,24 @@ impl PageView {
9981005 .click_check(&mut self.engine, outline, paragraph)?;
9991006 return self.changed();
10001007 }
1008 Some(Hit::File(id)) => {
1009 self.set_object_focus(Some(ObjectFocus::File(id)));
1010 self.drag = None;
1011 if unit != SelectionUnit::Grapheme
1012 && let Some(file) = self.editor.attachment(id)
1013 {
1014 return Ok(Response::request(Request::OpenAttachment(file.clone())));
1015 }
1016 // A file on the page drags as a picture does.
1017 if !self.editor.image_in_outline(id) {
1018 self.drag = Some(Drag::Image {
1019 id,
1020 handle: [0, 0],
1021 press: point,
1022 pending_press: Some(self.pointer),
1023 });
1024 }
1025 }
10011026 Some(Hit::Image { id, handle }) => {
10021027 self.set_object_focus(Some(ObjectFocus::Image(id)));
10031028 self.drag = Some(Drag::Image {
......@@ -1062,15 +1087,7 @@ impl PageView {
10621087 });
10631088 }
10641089 None => {
1065 // A fresh click places text 7 px above the pointer, on the grid.
1066 let position = [point[0], point[1] - 7.0 * self.pixel()];
1067 let position = if self.modifiers.option {
1068 position
1069 } else {
1070 snap_to_grid(position, self.editor.margin_origin())
1071 };
1072 self.editor
1073 .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?;
1090 self.place_caret(point)?;
10741091 self.set_object_focus(None);
10751092 self.drag = Some(Drag::Text {
10761093 anchor: self.editor.selection(),
......@@ -1081,6 +1098,19 @@ impl PageView {
10811098 self.changed()
10821099 }
10831100
1101 /// A fresh click places text 7 px above the pointer, on the grid.
1102 fn place_caret(&mut self, point: [f32; 2]) -> Result<()> {
1103 let position = [point[0], point[1] - 7.0 * self.pixel()];
1104 let position = if self.modifiers.option {
1105 position
1106 } else {
1107 snap_to_grid(position, self.editor.margin_origin())
1108 };
1109 self.editor
1110 .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?;
1111 Ok(())
1112 }
1113
10841114 pub fn pointer_released(&mut self) -> Result<Response> {
10851115 if let Some(Drag::Space { axis, line }) = self.drag {
10861116 self.drag = None;
......@@ -1261,11 +1291,22 @@ impl PageView {
12611291 }
12621292
12631293 /// A secondary press at the pointer: text there takes the caret unless it lies in the
1264 /// selection, as OneNote's context menu acts where it opens. `None` off text.
1294 /// selection, and a file is selected, as OneNote's context menu acts where it opens.
1295 /// `None` off text and files.
12651296 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {
12661297 let point = self.viewport.document_point(self.pointer);
1267 let Some(Hit::Text { id, point }) = self.hit_test(point) else {
1268 return Ok(None);
1298 let (id, point) = match self.hit_test(point) {
1299 Some(Hit::Text { id, point }) => (id, point),
1300 Some(Hit::File(id)) => {
1301 let attachment = self.editor.attachment(id).cloned();
1302 self.set_object_focus(Some(ObjectFocus::File(id)));
1303 let context = Context {
1304 attachment,
1305 ..Context::default()
1306 };
1307 return Ok(Some((self.changed()?, context)));
1308 }
1309 _ => return Ok(None),
12691310 };
12701311 if self.object_focus.is_some() || !self.accepts_text() {
12711312 return Ok(None);
......@@ -1296,6 +1337,7 @@ impl PageView {
12961337 .document()
12971338 .leaf(anchor.min(focus).paragraph)
12981339 .map(|(_, _, node)| node.id),
1340 attachment: None,
12991341 };
13001342 Ok(Some((self.changed()?, context)))
13011343 }
......@@ -1363,6 +1405,44 @@ impl PageView {
13631405 self.edited()
13641406 }
13651407
1408 /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon
1409 /// takes the page's blank one.
1410 pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result<Response> {
1411 if !self.accepts_text() || self.editor.marked_range().is_some() {
1412 return Ok(Response::default());
1413 }
1414 file.preview
1415 .get_or_insert_with(|| crate::gpu::page::file_icon().into());
1416 self.editor.insert_attachment(&mut self.engine, file)?;
1417 self.follow_pictures()?;
1418 self.edited()
1419 }
1420
1421 /// A file dropped at view point `position`, in device pixels: text or blank page there
1422 /// takes the caret as a click would, then the file is attached at the caret, on the page
1423 /// where the caret is on blank page.
1424 pub fn drop_attachment(
1425 &mut self,
1426 position: [f32; 2],
1427 file: onestore::page::Attachment,
1428 ) -> Result<Response> {
1429 let point = self.viewport.document_point(position);
1430 self.set_object_focus(None);
1431 match self.hit_test(point) {
1432 Some(Hit::Text { id, point }) => {
1433 self.editor.focus_outline(id)?;
1434 self.editor.select_below(&mut self.engine, id, point)?;
1435 let at = self
1436 .editor
1437 .selection_at(point[0], point[1], SelectionUnit::Grapheme)?;
1438 self.editor.select(at)?;
1439 }
1440 None => self.place_caret(point)?,
1441 _ => {}
1442 }
1443 self.insert_attachment(file)
1444 }
1445
13661446 /// Professional, or Linear with `linear`, on the equation at the caret.
13671447 pub fn switch_equation(&mut self, linear: bool) -> Result<Response> {
13681448 if linear {
......@@ -1380,7 +1460,8 @@ impl PageView {
13801460 } else {
13811461 self.editor.undo(&mut self.engine)?;
13821462 }
1383 if let Some(ObjectFocus::Image(id)) = self.object_focus
1463 self.follow_pictures()?;
1464 if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus
13841465 && self.editor.image_placement(id).is_none()
13851466 {
13861467 self.set_object_focus(None);
......@@ -1404,18 +1485,29 @@ impl PageView {
14041485 self.changed()
14051486 }
14061487
1407 /// Gives the page a colour (COLORREF) and rule lines, as one undo step.
1488 /// Gives the page a colour (COLORREF), rule lines and, when given, `art` in place of its
1489 /// background pictures, as one undo step: see [`CanvasEditor::set_paper`].
14081490 pub fn set_paper(
14091491 &mut self,
14101492 color: Option<u32>,
14111493 rule_lines: Option<onestore::page::RuleLines>,
1494 art: Option<Vec<onestore::page::Image>>,
14121495 ) -> Result<Response> {
1413 if !self.editor.set_paper(color, rule_lines) {
1496 if !self.editor.set_paper(color, rule_lines, art) {
14141497 return Ok(Response::default());
14151498 }
1499 self.follow_pictures()?;
14161500 self.changed()
14171501 }
14181502
1503 /// Draws page-level pictures an edit or undo brought to the page.
1504 fn follow_pictures(&mut self) -> Result<()> {
1505 if let Some((scene, _)) = &mut self.scene {
1506 scene.follow(&mut self.editor, &mut self.engine)?;
1507 }
1508 Ok(())
1509 }
1510
14191511 /// Gives keyboard focus to a read-only object, as an assistive technology asks.
14201512 pub fn focus_read_only(&mut self, index: usize) -> Result<Response> {
14211513 self.set_object_focus(Some(ObjectFocus::ReadOnly(index)));
......@@ -1439,14 +1531,14 @@ impl PageView {
14391531 if key == &Key::Named(NamedKey::Modifier) {
14401532 return Ok(Response::default());
14411533 }
1442 if let Some(ObjectFocus::Image(id)) = self.object_focus
1534 if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus
14431535 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))
14441536 {
14451537 self.editor.remove_image(&mut self.engine, id)?;
14461538 self.set_object_focus(None);
14471539 return self.changed();
14481540 }
1449 if let Some(ObjectFocus::Image(id)) = self.object_focus
1541 if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus
14501542 && !(shift || command || option || control)
14511543 && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key
14521544 && self.editor.step_from_image(
......@@ -1671,6 +1763,8 @@ pub enum Hit {
16711763 id: onestore::ExGuid,
16721764 handle: [i8; 2],
16731765 },
1766 /// A file in an outline's flow or on the page, its icon or name.
1767 File(onestore::ExGuid),
16741768}
16751769
16761770/// What a document point lands on; `pixel` is document points per device pixel.
......@@ -1762,7 +1856,7 @@ fn page_hit(
17621856 paragraph.tags.iter().any(|tag| {
17631857 let tag_x = left + shaped.tag_column_offset() + tag.origin[0];
17641858 let tag_y = top + paragraph.origin[1] + tag.origin[1];
1765 matches!(tag.icon, crate::outline::TagIcon::CheckBox { .. })
1859 tag.icon.checkable()
17661860 && (tag_x..=tag_x + tag.size).contains(&x)
17671861 && (tag_y..=tag_y + tag.size).contains(&y)
17681862 })
......@@ -1791,6 +1885,14 @@ fn page_hit(
17911885 handle: [0, 0],
17921886 });
17931887 }
1888 if let Some(file) = outline.shaped().objects.iter().find(|object| {
1889 let [x0, y0, x1, y1] = object.bounds();
1890 matches!(object.kind, crate::outline::ObjectKind::File(_))
1891 && (x0..=x1).contains(&inner[0])
1892 && (y0..=y1).contains(&inner[1])
1893 }) {
1894 return Some(Hit::File(file.id));
1895 }
17941896 if x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3] {
17951897 return Some(Hit::Text {
17961898 id: outline.id,
......@@ -1826,6 +1928,7 @@ fn page_hit(
18261928 crate::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)),
18271929 crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
18281930 crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }),
1931 crate::gpu::page::SceneHit::File(id) => Some(Hit::File(id)),
18291932 }
18301933 };
18311934 [Layer::Grips, Layer::Body, Layer::Below]
......@@ -2010,6 +2113,29 @@ fn page_primitives<'a>(
20102113 };
20112114 append_image_chrome(image_rect(origin, size), paint.pixel, &mut primitives);
20122115 }
2116 // OneNote shades a selected file's column and frames it with dashes.
2117 if let Some(ObjectFocus::File(id)) = object_focus {
2118 let rect = match preview {
2119 Some(PointerFeedback::Image(moving, origin, size)) if moving == id => {
2120 image_rect(origin, size)
2121 }
2122 _ => editor
2123 .attachment_rect(id)
2124 .ok_or("The selected file is missing.")?,
2125 };
2126 primitives.extend([
2127 Primitive::Rect {
2128 rect,
2129 color: [0.0, 0.0, 0.0, 0.08],
2130 },
2131 Primitive::RoundedRect {
2132 rect,
2133 radius: [0.0; 2],
2134 stroke: Some(Stroke::Dashed(paint.pixel)),
2135 color: [0.25, 0.45, 0.7, 1.0],
2136 },
2137 ]);
2138 }
20132139 if let Some(ObjectFocus::ReadOnly(index)) = object_focus {
20142140 let (scene, offset) = scene.unwrap();
20152141 let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect();
crates/canvas/src/interaction/tests.rs+264-7
......@@ -1805,7 +1805,7 @@ fn outline_chrome_grows_left_to_contain_its_tag_column() {
18051805 assert!(frame[1] < y && y + size < frame[3], "{frame:?} {y}");
18061806 // OneNote 2010 shows an arrow over a check box, which a click toggles.
18071807 let hit = page_hit_test(editor, None, [x + 1.0, y + 1.0], pixel);
1808 if matches!(tag.icon, crate::outline::TagIcon::CheckBox { .. }) {
1808 if tag.icon.checkable() {
18091809 assert_eq!(
18101810 hit,
18111811 Some(Hit::Check {
......@@ -1821,18 +1821,30 @@ fn outline_chrome_grows_left_to_contain_its_tag_column() {
18211821 };
18221822 check(&editor, 0.0);
18231823 editor
1824 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1824 .format(
1825 &mut engine,
1826 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
1827 )
18251828 .unwrap();
18261829 check(&editor, 1.0);
18271830 editor
1828 .format(&mut engine, Formatting::Tag(NoteTag::Question))
1831 .format(
1832 &mut engine,
1833 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
1834 )
18291835 .unwrap();
18301836 check(&editor, 2.0);
18311837 editor
1832 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1838 .format(
1839 &mut engine,
1840 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
1841 )
18331842 .unwrap();
18341843 editor
1835 .format(&mut engine, Formatting::Tag(NoteTag::Question))
1844 .format(
1845 &mut engine,
1846 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
1847 )
18361848 .unwrap();
18371849 check(&editor, 0.0);
18381850}
......@@ -1851,7 +1863,10 @@ fn a_click_on_a_check_box_toggles_it_under_an_arrow() {
18511863 let mut editor =
18521864 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
18531865 editor
1854 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1866 .format(
1867 &mut engine,
1868 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
1869 )
18551870 .unwrap();
18561871 let outline = editor.active_outline();
18571872 let (_, paragraph) = outline.layouts().next().unwrap();
......@@ -1923,7 +1938,10 @@ fn a_page_opens_where_onenote_places_the_view() {
19231938 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
19241939 if tag {
19251940 editor
1926 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1941 .format(
1942 &mut engine,
1943 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
1944 )
19271945 .unwrap();
19281946 }
19291947 let reached = reach(editor.active_outline());
......@@ -2625,3 +2643,242 @@ fn rule_lines_start_at_the_margin_origin() {
26252643 [0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 96.0]
26262644 );
26272645}
2646
2647/// Template art the Page Color menu gives draws in the frame after, over the rule lines as
2648/// OneNote's opaque pictures hide them, and follows undo and redo without a reload.
2649#[test]
2650fn art_draws_at_once_over_the_rule_lines_and_follows_undo() {
2651 let mut engine = TextEngine::default();
2652 let outline = TextOutline::new(
2653 &mut engine,
2654 TextDocument::new(vec![Paragraph::new("Notes".into(), Format::default())]).unwrap(),
2655 300.0,
2656 [300.0, 100.0],
2657 )
2658 .unwrap()
2659 .snapshot();
2660 let page = Page {
2661 identity: None,
2662 title: String::new(),
2663 created: None,
2664 margin_origin: [36.0, 14.4],
2665 color: None,
2666 rule_lines: Some(crate::template::RULE_LINES[2].1),
2667 definitions: Default::default(),
2668 objects: vec![onestore::page::PageObject::Outline(outline)],
2669 };
2670 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2671 let mut view = PageView::new(
2672 editor,
2673 engine,
2674 Some((scene, [0.0; 2])),
2675 [800, 600],
2676 1.0,
2677 Duration::from_millis(500),
2678 );
2679 let ivy = crate::template::find("Ivy").unwrap();
2680 let art = ivy.pictures().unwrap();
2681 let [x, y] = ivy.art[0].position;
2682 let [width, height] = ivy.art[0].size.unwrap();
2683 let drawn = |view: &mut PageView, paper: Paper| {
2684 view.update_pictures(paper, std::task::Waker::noop());
2685 let primitives = view.primitives(TextColors { paper, ..COLORS }).unwrap();
2686 let backing = primitives.iter().position(|primitive| {
2687 matches!(primitive, Primitive::Rect { rect, color }
2688 if *rect == [x, y, x + width, y + height] && *color == paper.color)
2689 });
2690 let image = primitives
2691 .iter()
2692 .position(|primitive| matches!(primitive, Primitive::Image { .. }));
2693 let lines = primitives.iter().rposition(
2694 |primitive| matches!(primitive, Primitive::Rect { rect, .. } if rect[0] < x),
2695 );
2696 (backing, image, lines)
2697 };
2698 let _ = view
2699 .set_paper(None, view.editor.rule_lines(), Some(art))
2700 .unwrap();
2701 let (backing, image, lines) = drawn(&mut view, Paper::WHITE);
2702 assert!(
2703 lines < backing && backing < image,
2704 "{lines:?} {backing:?} {image:?}"
2705 );
2706 let dark = Paper {
2707 color: [0.0137, 0.0144, 0.0159, 1.0],
2708 ink: [0.791, 0.791, 0.791, 1.0],
2709 };
2710 // Dark paper hides the lines in its own colour while its recolouring is made.
2711 assert!(matches!(drawn(&mut view, dark), (Some(_), None, _)));
2712 let _ = view.undo(false).unwrap();
2713 assert!(matches!(drawn(&mut view, Paper::WHITE), (None, None, _)));
2714 let _ = view.undo(true).unwrap();
2715 assert!(matches!(
2716 drawn(&mut view, Paper::WHITE),
2717 (Some(_), Some(_), _)
2718 ));
2719}
2720
2721#[test]
2722fn an_attached_file_selects_opens_on_a_double_click_and_deletes() {
2723 let (mut view, _) = picture_view();
2724 let now = Instant::now();
2725 let file = onestore::page::Attachment {
2726 id: onestore::page::text::new_id().unwrap(),
2727 filename: "notes.txt".into(),
2728 source_path: None,
2729 size: Some([24.0, 24.0]),
2730 layout: Default::default(),
2731 bytes: Some(Arc::from(b"notes".as_slice())),
2732 preview: None,
2733 recording: None,
2734 };
2735 view.editor
2736 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
2737 .unwrap();
2738 assert!(view.insert_attachment(file.clone()).unwrap().changed);
2739 let stored = view.editor.attachment(file.id).unwrap().clone();
2740 // OneNote draws a broken picture for a file stored without its icon.
2741 assert!(stored.preview.as_ref().unwrap().starts_with(b"\x89PNG"));
2742 view.primitives(COLORS).unwrap();
2743
2744 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
2745 let center = [(x0 + x1) / 2.0, (y0 + y1) / 2.0];
2746 click(&mut view, center, now);
2747 assert_eq!(view.object_focus(), Some(ObjectFocus::File(file.id)));
2748 assert!(!view.accepts_text());
2749 view.primitives(COLORS).unwrap();
2750 let _ = view.pointer_moved(center).unwrap();
2751 assert_eq!(
2752 view.pointer_pressed(now + Duration::from_millis(100))
2753 .unwrap()
2754 .request,
2755 Some(Request::OpenAttachment(stored.clone()))
2756 );
2757 let _ = view.pointer_released().unwrap();
2758 let (_, context) = view.context().unwrap().unwrap();
2759 assert_eq!(context.attachment, Some(stored));
2760
2761 assert!(
2762 view.key(&Key::Named(NamedKey::Delete), None)
2763 .unwrap()
2764 .changed
2765 );
2766 assert!(view.editor.attachment(file.id).is_none());
2767 assert!(view.accepts_text());
2768}
2769
2770/// OneNote 2010 puts a file attached or dropped on blank page on the page there, where it
2771/// selects, drags on the grid, opens and deletes as one (`corpus/attachment-floating`).
2772#[test]
2773fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() {
2774 use onestore::op::PageOp;
2775 let mut engine = TextEngine::default();
2776 let document =
2777 TextDocument::new(vec![Paragraph::new("Text".into(), Default::default())]).unwrap();
2778 let outline = TextOutline::new(&mut engine, document, 240.0, [36.0, 36.0]).unwrap();
2779 let page = Page {
2780 title: String::new(),
2781 identity: None,
2782 created: None,
2783 margin_origin: [36.0, 14.4],
2784 color: None,
2785 rule_lines: None,
2786 definitions: Default::default(),
2787 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
2788 };
2789 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2790 let mut view = PageView::new(
2791 editor,
2792 engine,
2793 Some((scene, [0.0; 2])),
2794 [800, 600],
2795 1.0,
2796 Duration::from_millis(500),
2797 );
2798 view.viewport.scale = 1.0;
2799 view.viewport.origin = [0.0; 2];
2800 let _ = view.editor.take_ops().unwrap();
2801 let file = onestore::page::Attachment {
2802 id: onestore::page::text::new_id().unwrap(),
2803 filename: "float.txt".into(),
2804 source_path: None,
2805 size: Some([24.0, 24.0]),
2806 layout: Default::default(),
2807 bytes: Some(Arc::from(b"float".as_slice())),
2808 preview: None,
2809 recording: None,
2810 };
2811 let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap();
2812 assert_eq!(view.editor.outlines().len(), 1);
2813 let placed = |view: &PageView| {
2814 view.editor
2815 .page()
2816 .unwrap()
2817 .objects
2818 .into_iter()
2819 .find_map(|object| match object {
2820 onestore::page::PageObject::Attachment(file) => Some(file),
2821 _ => None,
2822 })
2823 };
2824 // At the caret a click there places, on the 18 pt grid from the margin origin.
2825 let stored = placed(&view).unwrap();
2826 assert_eq!(
2827 [stored.layout.x, stored.layout.y],
2828 [Some(396.0), Some(392.4)]
2829 );
2830 assert_eq!(stored.layout.max_width, Some(54.0));
2831 assert!(matches!(
2832 &view.editor.take_ops().unwrap()[..],
2833 [PageOp::Add { object: onestore::page::PageObject::Attachment(added), before: None }]
2834 if added.id == file.id
2835 ));
2836 assert!(
2837 view.primitives(COLORS)
2838 .unwrap()
2839 .iter()
2840 .any(|primitive| matches!(primitive, Primitive::Text { .. }))
2841 );
2842
2843 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
2844 let center = [(x0 + x1) / 2.0, (y0 + y1) / 2.0];
2845 let now = Instant::now();
2846 let _ = view.pointer_moved(center).unwrap();
2847 let _ = view.pointer_pressed(now).unwrap();
2848 assert_eq!(view.object_focus(), Some(ObjectFocus::File(file.id)));
2849 let _ = view
2850 .pointer_moved([center[0] + 100.0, center[1] + 50.0])
2851 .unwrap();
2852 view.primitives(COLORS).unwrap();
2853 let _ = view.pointer_released().unwrap();
2854 let moved = placed(&view).unwrap();
2855 assert_eq!([moved.layout.x, moved.layout.y], [Some(504.0), Some(446.4)]);
2856 assert!(matches!(
2857 &view.editor.take_ops().unwrap()[..],
2858 [PageOp::Outline { object, .. }] if *object == file.id
2859 ));
2860 let (_, context) = view.context().unwrap().unwrap();
2861 assert_eq!(context.attachment.map(|found| found.id), Some(file.id));
2862
2863 let _ = view.undo(false).unwrap();
2864 assert_eq!(placed(&view).unwrap().layout.x, Some(396.0));
2865 let _ = view.undo(true).unwrap();
2866 assert_eq!(placed(&view).unwrap().layout.x, Some(504.0));
2867 let _ = view.editor.take_ops().unwrap();
2868
2869 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
2870 click(
2871 &mut view,
2872 [(x0 + x1) / 2.0, (y0 + y1) / 2.0],
2873 now + Duration::from_secs(1),
2874 );
2875 let _ = view.key(&Key::Named(NamedKey::Delete), None).unwrap();
2876 assert!(placed(&view).is_none());
2877 assert!(matches!(
2878 &view.editor.take_ops().unwrap()[..],
2879 [PageOp::Delete { object }] if *object == file.id
2880 ));
2881 let _ = view.undo(false).unwrap();
2882 assert_eq!(placed(&view).unwrap().layout.x, Some(504.0));
2883 view.primitives(COLORS).unwrap();
2884}
crates/canvas/src/outline.rs+279-126
......@@ -87,6 +87,19 @@ impl ObjectLayout {
8787 }
8888 }
8989
90 /// The whole object, a file's column with its name included.
91 pub fn bounds(&self) -> [f32; 4] {
92 match &self.kind {
93 ObjectKind::File(label) => [
94 label.origin[0],
95 self.rect[1] - 6.0,
96 label.origin[0] + ATTACHMENT_WIDTH,
97 self.bottom,
98 ],
99 _ => [self.rect[0], self.rect[1], self.rect[2], self.bottom],
100 }
101 }
102
90103 /// The rect's top and bottom, the object's bottom and its label's top once the object
91104 /// starts at `y`, given its own height, and the height it takes in the flow.
92105 fn at(&self, y: f32, height: f32) -> ([f32; 4], f32) {
......@@ -209,93 +222,192 @@ pub struct ParagraphLayout {
209222 pub band: Option<u32>,
210223}
211224
225/// How the page draws a note tag.
212226#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
213227pub enum TagIcon {
214 CheckBox {
215 checked: bool,
216 /// The badge on the small box of OneNote's check box variants.
217 mark: Option<BoxMark>,
218 },
219 Star,
220 Question,
221 Highlight,
222 Contact,
223 Address,
224 Phone,
225 Music,
226 Exclamation,
227 RedSquare,
228 YellowSquare,
229 BlueSquare,
230 /// An Outlook task flag.
231 Flag,
232 Idea,
233 Password,
234 Movie,
235 Book,
236 Web,
237 Blog,
238 Email,
239 /// A tag symbol without artwork of its own.
240 Other,
241}
242
243/// What OneNote draws on a check box variant's small box.
244#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
245pub enum BoxMark {
246 Person,
247 Manager,
248 Arrow,
249 One,
250 Two,
251 Client,
228 /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box.
229 Symbol { shape: u16, checked: bool },
230 /// An Outlook task, which OneNote 2010 flags.
231 Task,
252232}
253233
254234impl TagIcon {
255235 /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for
256236 /// shape 0, which marks the text instead.
257237 pub fn of(shape: u16, checked: bool) -> Option<Self> {
258 Some(match shape {
259 0 => return None,
260 // OneNote's check boxes, plain and with a person, arrow, number or bulb.
261 3 => Self::CheckBox {
262 checked,
263 mark: None,
264 },
265 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox {
266 checked,
267 mark: Some(match shape {
268 8 => BoxMark::Client,
269 12 => BoxMark::Arrow,
270 28 => BoxMark::One,
271 71 => BoxMark::Two,
272 94 => BoxMark::Person,
273 _ => BoxMark::Manager,
274 }),
275 },
276 13 => Self::Star,
277 15 => Self::Question,
278 17 => Self::Exclamation,
279 18 => Self::Phone,
280 23 => Self::Address,
281 118 => Self::Contact,
282 136 => Self::Highlight,
283 100 => Self::RedSquare,
284 101 => Self::YellowSquare,
285 102 => Self::BlueSquare,
286 121 => Self::Music,
287 21 => Self::Idea,
288 131 => Self::Password,
289 122 => Self::Movie,
290 132 => Self::Book,
291 125 => Self::Web,
292 24 => Self::Blog,
293 106 => Self::Email,
294 _ => Self::Other,
238 (shape != 0).then_some(Self::Symbol {
239 shape,
240 checked: checked && checkable(shape),
295241 })
296242 }
243
244 pub fn checkable(self) -> bool {
245 matches!(self, Self::Symbol { shape, .. } if checkable(shape))
246 }
297247}
298248
249/// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them.
250pub fn checkable(shape: u16) -> bool {
251 matches!(
252 shape,
253 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99
254 )
255}
256
257/// OneNote 2010's name for tag symbol `shape`, as its symbol gallery's tooltips give it; the
258/// follow-up flags it keeps for Outlook tasks take MS-ONE's descriptions.
259pub fn symbol_name(shape: u16) -> Option<&'static str> {
260 SYMBOL_NAMES
261 .get(usize::from(shape).checked_sub(1)?)
262 .copied()
263}
264
265const SYMBOL_NAMES: [&str; 143] = [
266 "Green Check Box",
267 "Yellow Check Box",
268 "Blue Check Box",
269 "Green Star Check Box",
270 "Yellow Star Check Box",
271 "Blue Star Check Box",
272 "Green Exclamation Check Box",
273 "Yellow Exclamation Check Box",
274 "Blue Exclamation Check Box",
275 "Green Arrow Check Box",
276 "Yellow Arrow Check Box",
277 "Blue Arrow Check Box",
278 "Yellow Star",
279 "Follow-up Flag",
280 "Question",
281 "Blue Right Arrow",
282 "High Priority",
283 "Telephone",
284 "Calendar",
285 "Clock",
286 "Light Bulb",
287 "Pushpin",
288 "Home",
289 "Comment",
290 "Smiley",
291 "Award Ribbon",
292 "Key",
293 "Blue Check Box 1",
294 "Blue Circle 1",
295 "Blue Check Box 2",
296 "Blue Circle 2",
297 "Blue Check Box 3",
298 "Blue Circle 3",
299 "Blue 8-Point Star",
300 "Blue Check Mark",
301 "Blue Circle",
302 "Blue Down Arrow",
303 "Blue Left Arrow",
304 "Blue Solid Target",
305 "Blue Star",
306 "Blue Sun",
307 "Blue Target",
308 "Blue Triangle",
309 "Blue Umbrella",
310 "Blue Up Arrow",
311 "Blue X with Dots",
312 "Blue X",
313 "Green Check Box 1",
314 "Green Circle 1",
315 "Green Check Box 2",
316 "Green Circle 2",
317 "Green Check Box 3",
318 "Green Circle 3",
319 "Green 8-Point Star",
320 "Green Check Mark",
321 "Green Circle",
322 "Green Down Arrow",
323 "Green Left Arrow",
324 "Green Right Arrow",
325 "Green Solid Target",
326 "Green Star",
327 "Green Sun",
328 "Green Target",
329 "Green Triangle",
330 "Green Umbrella",
331 "Green Up Arrow",
332 "Green X with Dots",
333 "Green X",
334 "Yellow Check Box 1",
335 "Yellow Circle 1",
336 "Yellow Check Box 2",
337 "Yellow Circle 2",
338 "Yellow Check Box 3",
339 "Yellow Circle 3",
340 "Yellow 8-Point Star",
341 "Yellow Check Mark",
342 "Yellow Circle",
343 "Yellow Down Arrow",
344 "Yellow Left Arrow",
345 "Yellow Right Arrow",
346 "Yellow Solid Target",
347 "Yellow Sun",
348 "Yellow Target",
349 "Yellow Triangle",
350 "Yellow Umbrella",
351 "Yellow Up Arrow",
352 "Yellow X with Dots",
353 "Yellow X",
354 "Follow Up Today Flag",
355 "Follow Up Tomorrow Flag",
356 "Follow Up This Week Flag",
357 "Follow Up Next Week Flag",
358 "No Follow Up Date Flag",
359 "To Do Blue Person",
360 "To Do Yellow Person",
361 "To Do Green Person",
362 "To Do Blue Flag",
363 "To Do Yellow Flag",
364 "To Do Green Flag",
365 "Red Square (Project A)",
366 "Yellow Square (Project B)",
367 "Blue Square (Project C)",
368 "Green Square",
369 "Orange Square",
370 "Pink Square",
371 "E-mail",
372 "Envelope (Closed)",
373 "Envelope (Opened)",
374 "Mobile phone",
375 "Telephone with Clock",
376 "Question Balloon",
377 "Paperclip",
378 "Frowning",
379 "IM contact",
380 "Person",
381 "Two People",
382 "Reminder Bell",
383 "Contact (like Outlook's)",
384 "Flowers Bouquet",
385 "Date",
386 "Music Note",
387 "Movie Clip",
388 "Quote Mark",
389 "Globe",
390 "Link Globe",
391 "Laptop",
392 "Plane",
393 "Car",
394 "Binoculars",
395 "Presentation",
396 "Padlock",
397 "Book (Open)",
398 "Notebook Icon",
399 "Paper (blank with lines)",
400 "Research Icon",
401 "Marker",
402 "Dollar sign $",
403 "Coins with window behind it",
404 "Schedule Task",
405 "Lighting Bolt",
406 "Cloud",
407 "Heart",
408 "Flower",
409];
410
299411#[derive(Clone, Debug, PartialEq)]
300412pub struct ParagraphTag {
301413 pub icon: TagIcon,
......@@ -367,6 +479,72 @@ fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParag
367479 }
368480}
369481
482/// A file's column at `x`, before it is placed: its icon centred over its name, which OneNote
483/// shows without the extension; and the icon's height.
484fn file_column(
485 file: &onestore::page::Attachment,
486 paragraph: ExGuid,
487 format: &Format,
488 x: f32,
489 shape: &mut impl FnMut(
490 &PageParagraph,
491 Option<&Count>,
492 f32,
493 &[f32],
494 ) -> Result<ParagraphLayout, LayoutError>,
495) -> Result<(ObjectLayout, f32), LayoutError> {
496 let [w, h] = file.size.unwrap_or([24.0, 24.0]);
497 let left = x + (ATTACHMENT_WIDTH - w) / 2.0;
498 let name = std::path::Path::new(&file.filename)
499 .file_stem()
500 .and_then(|stem| stem.to_str())
501 .unwrap_or(&file.filename);
502 let format = Format {
503 alignment: Some(1),
504 ..format.clone()
505 };
506 let mut label = shape(
507 &caption(paragraph, file.id, name, format),
508 None,
509 ATTACHMENT_WIDTH,
510 &[0.0, 0.0],
511 )?;
512 label.reset_origin(x);
513 let object = ObjectLayout {
514 id: file.id,
515 rect: [left, 0.0, left + w, 0.0],
516 kind: ObjectKind::File(label),
517 bottom: 0.0,
518 };
519 Ok((object, h))
520}
521
522/// A file on the page as OneNote draws one, relative to its position: the column a
523/// paragraph's file takes, with its text in the page's default format.
524pub(crate) fn page_file(
525 engine: &mut TextEngine,
526 file: &onestore::page::Attachment,
527) -> Result<ObjectLayout, LayoutError> {
528 let (mut object, height) = file_column(
529 file,
530 file.id,
531 &Format::default(),
532 0.0,
533 &mut |paragraph, previous, width, indents| {
534 ParagraphLayout::shape(
535 engine,
536 paragraph,
537 previous,
538 width,
539 indents,
540 &BTreeMap::new(),
541 )
542 },
543 )?;
544 object.place(0.0, height);
545 Ok(object)
546}
547
370548/// A picture's displayed size: the user-set layout size, else its intrinsic size.
371549pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> {
372550 let size = [
......@@ -887,7 +1065,7 @@ impl ParagraphLayout {
8871065 for tag in &tag_definitions {
8881066 let (tag, shape, label) = (tag.0, tag.1, tag.2);
8891067 let icon = match shape {
890 None => TagIcon::Flag,
1068 None => TagIcon::Task,
8911069 Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) {
8921070 Some(icon) => icon,
8931071 None => continue,
......@@ -1149,30 +1327,8 @@ impl OutlineLayout {
11491327 (object, h, x + w)
11501328 }
11511329 ParagraphContent::Attachment(file) => {
1152 let [w, h] = file.size.unwrap_or([24.0, 24.0]);
1153 let left = x + (ATTACHMENT_WIDTH - w) / 2.0;
1154 // OneNote shows the name without its extension.
1155 let name = std::path::Path::new(&file.filename)
1156 .file_stem()
1157 .and_then(|stem| stem.to_str())
1158 .unwrap_or(&file.filename);
1159 let format = Format {
1160 alignment: Some(1),
1161 ..node.format.clone()
1162 };
1163 let mut label = shape(
1164 &caption(node.id, file.id, name, format),
1165 None,
1166 ATTACHMENT_WIDTH,
1167 &[0.0, 0.0],
1168 )?;
1169 label.reset_origin(x);
1170 let object = ObjectLayout {
1171 id: file.id,
1172 rect: [left, 0.0, left + w, 0.0],
1173 kind: ObjectKind::File(label),
1174 bottom: 0.0,
1175 };
1330 let (object, h) =
1331 file_column(file, node.id, &node.format, x, shape)?;
11761332 (object, h, x + ATTACHMENT_WIDTH)
11771333 }
11781334 ParagraphContent::Ink(ink) => {
......@@ -2092,26 +2248,7 @@ mod tests {
20922248 task_id: None,
20932249 extra_set: 0,
20942250 });
2095 for (shape, expected) in [
2096 (
2097 3,
2098 TagIcon::CheckBox {
2099 checked: true,
2100 mark: None,
2101 },
2102 ),
2103 (13, TagIcon::Star),
2104 (15, TagIcon::Question),
2105 (17, TagIcon::Exclamation),
2106 (18, TagIcon::Phone),
2107 (23, TagIcon::Address),
2108 (118, TagIcon::Contact),
2109 (136, TagIcon::Highlight),
2110 (100, TagIcon::RedSquare),
2111 (101, TagIcon::YellowSquare),
2112 (102, TagIcon::BlueSquare),
2113 (121, TagIcon::Music),
2114 ] {
2251 for shape in [3, 13, 15, 17, 18, 23, 118, 136, 100, 101, 102, 121] {
21152252 definitions.insert(
21162253 id,
21172254 Definition {
......@@ -2134,7 +2271,13 @@ mod tests {
21342271 &definitions,
21352272 )
21362273 .unwrap();
2137 assert_eq!(tagged.tags[0].icon, expected);
2274 assert_eq!(
2275 tagged.tags[0].icon,
2276 TagIcon::Symbol {
2277 shape,
2278 checked: shape == 3
2279 }
2280 );
21382281 let baseline = tagged.text.lines().next().unwrap().1.baseline;
21392282 assert_eq!(
21402283 tagged.tags[0].origin,
......@@ -2203,12 +2346,18 @@ mod tests {
22032346 )
22042347 .unwrap()
22052348 };
2206 // A symbol without artwork shows as a plain tag, an Outlook task as its flag, and a
2207 // tag whose definition is missing as nothing.
2208 assert_eq!(shaped(&node, &definitions).tags[0].icon, TagIcon::Other);
2349 // A symbol MS-ONE does not list keeps its number, an Outlook task shows its flag, and
2350 // a tag whose definition is missing shows nothing.
2351 assert_eq!(
2352 shaped(&node, &definitions).tags[0].icon,
2353 TagIcon::Symbol {
2354 shape: 999,
2355 checked: false
2356 }
2357 );
22092358 let mut task = node.clone();
22102359 task.text_mut().unwrap().tags[0].status |= 4;
2211 assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Flag);
2360 assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Task);
22122361 let mut untagged = definitions.clone();
22132362 untagged.remove(&id);
22142363 assert!(shaped(&node, &untagged).tags.is_empty());
......@@ -2431,6 +2580,7 @@ mod tests {
24312580 filename: "notes 🦀.txt".into(),
24322581 source_path: None,
24332582 size: Some([24.0, 24.0]),
2583 layout: Default::default(),
24342584 bytes: None,
24352585 preview: None,
24362586 recording: None,
......@@ -2542,7 +2692,10 @@ mod tests {
25422692 .iter()
25432693 .map(|tag| (tag.icon, tag.origin[0]))
25442694 .collect::<Vec<_>>(),
2545 [(TagIcon::RedSquare, -20.25), (TagIcon::Question, -8.25)]
2695 [
2696 (TagIcon::of(100, false).unwrap(), -20.25),
2697 (TagIcon::of(15, false).unwrap(), -8.25)
2698 ]
25462699 );
25472700 let colors: Vec<_> = shaped
25482701 .text
crates/canvas/src/template.rs+30
......@@ -21,6 +21,36 @@ pub struct Template {
2121 pub art: &'static [Art],
2222}
2323
24impl Template {
25 /// The background pictures a page stores for this template: OneNote's places, our
26 /// recreations' bytes.
27 #[cfg(feature = "gpu")]
28 pub fn pictures(&self) -> Result<Vec<onestore::page::Image>, onestore::page::text::EditError> {
29 self.art
30 .iter()
31 .map(|art| {
32 let bytes = crate::gpu::page::template_picture(art.art)
33 .expect("Every template's art is recreated");
34 Ok(onestore::page::Image {
35 id: onestore::page::text::new_id()?,
36 layout: onestore::document::Layout {
37 x: Some(art.position[0]),
38 y: Some(art.position[1]),
39 max_width: art.size.map(|size| size[0]),
40 max_height: art.size.map(|size| size[1]),
41 width_set_by_user: art.size.map(|_| true),
42 ..Default::default()
43 },
44 size: art.size,
45 bytes: Some(bytes.into()),
46 alt: None,
47 background: true,
48 })
49 })
50 .collect()
51 }
52}
53
2454/// OneNote 2010's Decorative templates and Informal Meeting Notes (resources/onenote-templates.md),
2555/// in the order the gallery lists them.
2656pub const TEMPLATES: &[Template] = &[
crates/canvas/tests/attachment_insert.rs created+256
......@@ -0,0 +1,256 @@
1//! Files attached through the editor, as Snowbound's Attach File and drops store them: the
2//! ops the editor records seal into a section that reads back with the exact bytes.
3//! `CANVAS_ATTACHMENT_EXPORT` names a directory receiving that section as a notebook, for
4//! a cold reopen in OneNote 2010 (`corpus/attachment-insert`), and `CANVAS_FLOATING_EXPORT`
5//! the section of files attached on blank page (`corpus/attachment-floating`).
6
7use canvas::{editor::CanvasEditor, layout::TextEngine};
8use draw::edit::Movement;
9use onestore::{
10 Arena, Section, Store,
11 op::{Edit, Op},
12 page::{Attachment, PageObject, ParagraphContent},
13};
14use std::sync::Arc;
15
16/// The icon OneNote 2010 stored with a text file (`corpus/native/cold-05-06-attachment`).
17fn native_icon() -> Arc<[u8]> {
18 let native =
19 include_bytes!("../../../corpus/native/cold-05-06-attachment/notebook/synthetic.one");
20 let arena = Arena::default();
21 let mut section = Section::open(&arena, native.to_vec()).unwrap();
22 let (space, ..) = section.pages().unwrap()[0].clone();
23 let page = section.page(space).unwrap();
24 page.objects
25 .iter()
26 .filter_map(|object| match object {
27 PageObject::Outline(outline) => Some(&outline.paragraphs),
28 _ => None,
29 })
30 .flatten()
31 .find_map(|paragraph| match &paragraph.content {
32 ParagraphContent::Attachment(file) => file.preview.clone(),
33 _ => None,
34 })
35 .unwrap()
36}
37
38/// Two mebibytes no compressor shortens, larger than any buffer the writer fills at once.
39fn large() -> Vec<u8> {
40 let mut state = 0x2545_f491_4f6c_dd1d_u64;
41 (0..2 << 20)
42 .map(|_| {
43 state ^= state << 13;
44 state ^= state >> 7;
45 state ^= state << 17;
46 state as u8
47 })
48 .collect()
49}
50
51fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment {
52 Attachment {
53 id: onestore::page::text::new_id().unwrap(),
54 filename: name.into(),
55 source_path: Some(format!("/Users/snowbound/Documents/{name}")),
56 size: Some([24.0, 24.0]),
57 layout: Default::default(),
58 bytes: Some(bytes.into()),
59 preview,
60 recording: None,
61 }
62}
63
64#[test]
65fn attached_files_store_their_bytes_between_the_text_around_them() {
66 let source = onestore::create_section("files.one", "Before the file", "Author").unwrap();
67 let arena = Arena::default();
68 let mut section = Section::open(&arena, source.clone()).unwrap();
69 let (space, ..) = section.pages().unwrap()[0].clone();
70 let mut engine = TextEngine::default();
71 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
72 let body = editor
73 .outlines()
74 .iter()
75 .find(|outline| !outline.title)
76 .unwrap()
77 .id;
78 editor.focus_outline(body).unwrap();
79 editor
80 .move_selection(&mut engine, Movement::DocumentEnd, false)
81 .unwrap();
82 let small = file(
83 "notes 🦀.txt",
84 b"Snowbound attached this file.\n".to_vec(),
85 Some(native_icon()),
86 );
87 // The icon a host without the system's draws, which `PageView::insert_attachment` gives.
88 let large = file(
89 "large.bin",
90 large(),
91 Some(canvas::gpu::page::file_icon().into()),
92 );
93 let mut at = 133_000_000_000_000_000;
94 let mut store = |editor: &mut CanvasEditor| {
95 let ops = editor.take_ops().unwrap();
96 assert!(!ops.is_empty());
97 at += 10_000_000;
98 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
99 section.apply("Author", &Edit { at, ops }).unwrap();
100 };
101 editor
102 .insert_attachment(&mut engine, small.clone())
103 .unwrap();
104 store(&mut editor);
105 editor.insert(&mut engine, "After the file").unwrap();
106 store(&mut editor);
107 editor
108 .insert_attachment(&mut engine, large.clone())
109 .unwrap();
110 store(&mut editor);
111 let mut image = source;
112 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
113
114 let arena = Arena::default();
115 let reopened = Section::open(&arena, image.clone()).unwrap();
116 let page = reopened.page(space).unwrap();
117 let body = page
118 .objects
119 .iter()
120 .find_map(|object| match object {
121 PageObject::Outline(outline) if !outline.title => Some(outline),
122 _ => None,
123 })
124 .unwrap();
125 let shown: Vec<String> = body
126 .paragraphs
127 .iter()
128 .map(|paragraph| match &paragraph.content {
129 ParagraphContent::Text(text) => text.text.text().to_owned(),
130 ParagraphContent::Attachment(file) => {
131 let expected = [&small, &large]
132 .into_iter()
133 .find(|expected| expected.id == file.id)
134 .unwrap();
135 assert_eq!(file, expected);
136 assert_eq!(file.bytes, expected.bytes);
137 assert_eq!(file.preview, expected.preview);
138 format!("[{}]", file.filename)
139 }
140 _ => panic!(),
141 })
142 .collect();
143 assert_eq!(
144 shown,
145 [
146 "Before the file",
147 "[notes 🦀.txt]",
148 "After the file",
149 "[large.bin]",
150 ""
151 ]
152 );
153
154 if let Some(directory) = std::env::var_os("CANVAS_ATTACHMENT_EXPORT") {
155 let directory = std::path::PathBuf::from(directory);
156 std::fs::create_dir_all(&directory).unwrap();
157 std::fs::write(directory.join("files.one"), &image).unwrap();
158 let file_id = Store::parse(&image).unwrap().header.file_id;
159 std::fs::write(
160 directory.join("Open Notebook.onetoc2"),
161 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)])
162 .unwrap(),
163 )
164 .unwrap();
165 }
166}
167
168/// Files attached where a click on blank page left the caret lie on the page there, as OneNote
169/// 2010 places them, and move as one does.
170#[test]
171fn files_attached_on_blank_page_store_their_bytes_where_they_were_placed_and_moved() {
172 let source = onestore::create_section("files.one", "Floating files", "Author").unwrap();
173 let arena = Arena::default();
174 let mut section = Section::open(&arena, source.clone()).unwrap();
175 let (space, ..) = section.pages().unwrap()[0].clone();
176 let mut engine = TextEngine::default();
177 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
178 let small = file(
179 "float 🦀.txt",
180 b"Snowbound put this file on the page.\n".to_vec(),
181 Some(native_icon()),
182 );
183 let large = file(
184 "large.bin",
185 large(),
186 Some(canvas::gpu::page::file_icon().into()),
187 );
188 let mut at = 133_000_000_000_000_000;
189 let mut store = |editor: &mut CanvasEditor| {
190 let ops = editor.take_ops().unwrap();
191 assert_eq!(ops.len(), 1, "{ops:?}");
192 at += 10_000_000;
193 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
194 section.apply("Author", &Edit { at, ops }).unwrap();
195 };
196 editor
197 .place_caret(&mut engine, [342.0, 284.4], 240.0)
198 .unwrap();
199 editor
200 .insert_attachment(&mut engine, small.clone())
201 .unwrap();
202 store(&mut editor);
203 editor
204 .place_caret(&mut engine, [126.0, 410.4], 240.0)
205 .unwrap();
206 editor
207 .insert_attachment(&mut engine, large.clone())
208 .unwrap();
209 store(&mut editor);
210 let (_, size) = editor.image_placement(large.id).unwrap();
211 editor
212 .place_image(&mut engine, large.id, [288.0, 140.4], size)
213 .unwrap();
214 store(&mut editor);
215 let mut image = source;
216 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
217
218 let arena = Arena::default();
219 let reopened = Section::open(&arena, image.clone()).unwrap();
220 let page = reopened.page(space).unwrap();
221 let files: Vec<&Attachment> = page
222 .objects
223 .iter()
224 .filter_map(|object| match object {
225 PageObject::Attachment(file) => Some(file),
226 _ => None,
227 })
228 .collect();
229 let [first, second] = files.as_slice() else {
230 panic!("{page:?}")
231 };
232 for (stored, expected, at) in [
233 (first, &small, [342.0, 284.4]),
234 (second, &large, [288.0, 140.4]),
235 ] {
236 assert_eq!(stored.id, expected.id);
237 assert_eq!([stored.layout.x, stored.layout.y], at.map(Some));
238 assert_eq!(stored.layout.max_width, Some(54.0));
239 assert_eq!(stored.bytes, expected.bytes);
240 assert_eq!(stored.preview, expected.preview);
241 }
242 assert_eq!(editor.page().unwrap(), page);
243
244 if let Some(directory) = std::env::var_os("CANVAS_FLOATING_EXPORT") {
245 let directory = std::path::PathBuf::from(directory);
246 std::fs::create_dir_all(&directory).unwrap();
247 std::fs::write(directory.join("files.one"), &image).unwrap();
248 let file_id = Store::parse(&image).unwrap().header.file_id;
249 std::fs::write(
250 directory.join("Open Notebook.onetoc2"),
251 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)])
252 .unwrap(),
253 )
254 .unwrap();
255 }
256}
crates/canvas/tests/custom_tags.rs created+207
......@@ -0,0 +1,207 @@
1//! Tags of a customized list, applied through the editor, store the definitions OneNote
2//! 2010's Customize Tags stores (`corpus/custom-tags`).
3
4use canvas::{
5 document::TextPosition,
6 editor::{CanvasEditor, Formatting, NoteTag},
7 layout::TextEngine,
8};
9use onestore::{RevisionIndex, Store, document::Document, op, page::Page};
10
11fn page(bytes: &[u8]) -> (onestore::ExGuid, Page) {
12 let store = Store::parse(bytes).unwrap();
13 let index = RevisionIndex::parse(&store).unwrap();
14 let document = Document::parse(&index).unwrap();
15 let (space, _) = document.pages().unwrap()[0];
16 (space, Page::from_space(&document, space).unwrap())
17}
18
19/// The list's tags: a symbol with both colours, a check box, a symbol alone and colours
20/// alone, at the places the definitions store as action types.
21fn list() -> Vec<NoteTag> {
22 let tag = |label: &str, shape, color, highlight| NoteTag {
23 label: label.into(),
24 shape,
25 color,
26 highlight,
27 };
28 vec![
29 tag("Snow check", 61, Some(0x0000_0080), Some(0x00ff_cc00)),
30 tag("Snow task", 48, None, None),
31 tag("Snow circle", 31, None, None),
32 tag("Snow ink", 0, Some(0x00ff_0000), None),
33 ]
34}
35
36/// Each paragraph takes the tags at these places in `list`; the third's box is checked.
37const TAGGED: [(&str, &[u16]); 5] = [
38 ("Custom symbol and colours", &[0]),
39 ("Custom check box", &[1]),
40 ("Custom check box, checked", &[1]),
41 ("Two custom tags", &[2, 3]),
42 // Typed before the new page's own "Untagged".
43 ("", &[]),
44];
45
46/// `ONESTORE_CUSTOM_TAGS_EXPORT` names a new directory receiving the candidate for a cold
47/// reopen.
48#[test]
49fn custom_tags_store_what_customize_tags_stores() {
50 let source = onestore::create_section("custom-tags.one", "Untagged", "Author").unwrap();
51 let (space, before) = page(&source);
52 let mut engine = TextEngine::default();
53 let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap();
54 let body = editor
55 .outlines()
56 .iter()
57 .find(|outline| !outline.title)
58 .unwrap()
59 .id;
60 editor.focus_outline(body).unwrap();
61 let at = |paragraph| {
62 [TextPosition {
63 paragraph,
64 offset: 0,
65 }; 2]
66 .into()
67 };
68 let list = list();
69 for (paragraph, (text, places)) in TAGGED.iter().enumerate() {
70 if paragraph > 0 {
71 editor.enter(&mut engine, false).unwrap();
72 }
73 editor.insert(&mut engine, text).unwrap();
74 editor.select(at(paragraph)).unwrap();
75 for &place in *places {
76 let tag = list[usize::from(place)].clone();
77 editor
78 .format(&mut engine, Formatting::Tag(tag, place))
79 .unwrap();
80 }
81 if paragraph == 2 {
82 editor.format(&mut engine, Formatting::Check).unwrap();
83 }
84 let offset = text.len() as u32;
85 editor
86 .select([TextPosition { paragraph, offset }; 2].into())
87 .unwrap();
88 }
89 let arena = onestore::Arena::default();
90 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();
91 let ops = editor
92 .take_ops()
93 .unwrap()
94 .into_iter()
95 .map(|op| op::Op::Page { space, op })
96 .collect();
97 section
98 .apply(
99 "Author",
100 &op::Edit {
101 at: 134_000_000_000_000_000,
102 ops,
103 },
104 )
105 .unwrap();
106 section.seal().unwrap();
107 let written = section.image();
108 let (_, stored) = page(&written);
109 let mut definitions: Vec<_> = stored
110 .definitions
111 .values()
112 .filter_map(|definition| NoteTag::of(&definition.kind))
113 .collect();
114 definitions.sort_by_key(|(_, action_type)| *action_type);
115 let expected: Vec<_> = list.into_iter().zip(0..).collect();
116 assert_eq!(definitions, expected);
117 let mut reread = CanvasEditor::from_page(stored, &mut engine).unwrap();
118 reread.focus_outline(body).unwrap();
119 for (paragraph, (_, places)) in TAGGED.iter().enumerate() {
120 reread.select(at(paragraph)).unwrap();
121 let tags: Vec<u16> = reread
122 .format_state()
123 .unwrap()
124 .tags
125 .into_iter()
126 .map(|(_, action_type)| action_type)
127 .collect();
128 assert_eq!(tags, *places, "paragraph {paragraph}");
129 }
130 if let Some(directory) = std::env::var_os("ONESTORE_CUSTOM_TAGS_EXPORT") {
131 let directory = std::path::PathBuf::from(directory);
132 std::fs::create_dir(&directory).unwrap();
133 std::fs::write(directory.join("custom-tags.one"), &written).unwrap();
134 let file_id = Store::parse(&written).unwrap().header.file_id;
135 std::fs::write(
136 directory.join("Open Notebook.onetoc2"),
137 onestore::create_table_of_contents(
138 "Open Notebook.onetoc2",
139 &[("custom-tags.one", file_id)],
140 )
141 .unwrap(),
142 )
143 .unwrap();
144 }
145}
146
147/// A tag OneNote 2010's New Tag made, applied from two places in its list, reads back with
148/// its name, symbol and colours (`corpus/custom-tags/native`).
149#[test]
150fn onenotes_custom_tags_read_back() {
151 let bytes = include_bytes!("../../../corpus/custom-tags/native/shapes.one");
152 let store = Store::parse(bytes).unwrap();
153 let index = RevisionIndex::parse(&store).unwrap();
154 let document = Document::parse(&index).unwrap();
155 let page = document
156 .pages()
157 .unwrap()
158 .into_iter()
159 .map(|(space, _)| Page::from_space(&document, space).unwrap())
160 .find(|page| page.title.starts_with("Custom tags"))
161 .unwrap();
162 let mut engine = TextEngine::default();
163 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
164 let body = editor
165 .outlines()
166 .iter()
167 .find(|outline| !outline.title)
168 .unwrap()
169 .id;
170 editor.focus_outline(body).unwrap();
171 let snow = NoteTag {
172 label: "Snow check".into(),
173 shape: 61,
174 color: Some(0x0000_0080),
175 highlight: Some(0x00ff_cc00),
176 };
177 let to_do = NoteTag::defaults()[0].clone();
178 // Snow check went in at the top of the list, then moved below To Do.
179 for (paragraph, expected) in [
180 (0, (snow.clone(), 0)),
181 (1, (to_do.clone(), 1)),
182 (2, (snow, 1)),
183 (3, (to_do, 0)),
184 ] {
185 editor
186 .select(
187 [TextPosition {
188 paragraph,
189 offset: 0,
190 }; 2]
191 .into(),
192 )
193 .unwrap();
194 assert_eq!(editor.format_state().unwrap().tags, [expected]);
195 }
196 let shaped = &editor.active_outline().shaped().paragraphs[0];
197 assert_eq!(
198 shaped.tags[0].icon,
199 canvas::outline::TagIcon::of(61, false).unwrap()
200 );
201 assert!(
202 shaped
203 .text
204 .backgrounds()
205 .all(|(_, color)| color == 0x00ff_cc00)
206 );
207}
crates/canvas/tests/ops_differential.rs+184-15
......@@ -13,12 +13,21 @@ use canvas::{
1313use draw::edit::Movement;
1414use onestore::{
1515 Arena, ExGuid, RevisionIndex, Section, Store,
16 document::{Document, Kind as Node},
16 document::{Document, Kind as Node, Layout},
1717 op::{Edit, Op, PageOp},
18 page::Page,
18 page::{Image, Page},
1919};
2020use std::{collections::BTreeMap, path::Path};
2121
22/// A one-pixel PNG standing in for template art's bytes.
23const ART: [u8; 67] = [
24 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
25 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
26 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
27 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
28 0x42, 0x60, 0x82,
29];
30
2231#[path = "../../onestore/tests/support/sweep.rs"]
2332mod sweep;
2433
......@@ -98,11 +107,22 @@ enum Kind {
98107 InsertSpace,
99108 RemoveSpace,
100109 InsertSpaceRight,
110 Paper,
111 Art,
112 Attach,
113 /// A click on blank page, then Attach File: a file on the page.
114 AttachPage,
115 MoveFile,
116 RemoveFile,
117 /// Typing, backspaces and a word backspace at one caret: one undo step.
118 TypeRun,
119 /// More typing where the caret is, joining the run before it.
120 TypeOn,
101121 Undo,
102122 Redo,
103123}
104124
105const KINDS: [Kind; 55] = [
125const KINDS: [Kind; 57] = [
106126 Kind::Type,
107127 Kind::TypeEnd,
108128 Kind::Compose,
......@@ -156,10 +176,61 @@ const KINDS: [Kind; 55] = [
156176 Kind::InsertSpace,
157177 Kind::RemoveSpace,
158178 Kind::InsertSpaceRight,
179 Kind::Paper,
180 Kind::Art,
159181 Kind::Undo,
160182 Kind::Redo,
161183];
162184
185/// Typing runs, in plans of their own so the random edits' sequences stay as they were.
186const TYPING_PLANS: [&[Kind]; 3] = [
187 &[Kind::TypeRun, Kind::Undo, Kind::Redo],
188 &[
189 Kind::TypeRun,
190 Kind::TypeOn,
191 Kind::Undo,
192 Kind::Redo,
193 Kind::Undo,
194 ],
195 &[Kind::PageType, Kind::TypeOn, Kind::Undo, Kind::Redo],
196];
197
198/// Files attached at a random caret, in plans of their own so the random edits' sequences
199/// stay as they were.
200const ATTACH_PLANS: [&[Kind]; 2] = [
201 &[Kind::Attach, Kind::Undo, Kind::Redo],
202 &[Kind::Attach, Kind::Type, Kind::Attach, Kind::Undo],
203];
204
205/// Files attached on blank page, moved, spaced and removed, in plans of their own so the
206/// random edits' sequences stay as they were.
207const FLOATING_PLANS: [&[Kind]; 4] = [
208 &[Kind::AttachPage, Kind::Undo, Kind::Redo],
209 &[
210 Kind::AttachPage,
211 Kind::MoveFile,
212 Kind::Undo,
213 Kind::Redo,
214 Kind::RemoveFile,
215 Kind::Undo,
216 ],
217 &[Kind::AttachPage, Kind::InsertSpace, Kind::Undo, Kind::Redo],
218 &[
219 Kind::AttachPage,
220 Kind::AttachPage,
221 Kind::MoveFile,
222 Kind::Undo,
223 Kind::Undo,
224 ],
225];
226
227/// Page colour, rule lines and art undone and redone across each other.
228const PAPER_PLANS: [&[Kind]; 3] = [
229 &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo],
230 &[Kind::Art, Kind::Paper, Kind::Art, Kind::Undo, Kind::Undo],
231 &[Kind::Paper, Kind::Art, Kind::Undo, Kind::Paper, Kind::Redo],
232];
233
163234struct Random(u64);
164235
165236impl Random {
......@@ -212,10 +283,13 @@ fn perform(
212283 kind,
213284 Kind::Undo
214285 | Kind::Redo
286 | Kind::TypeOn
215287 | Kind::PlaceImage
216288 | Kind::RemoveImage
217289 | Kind::CreateOutline
218290 | Kind::Date
291 | Kind::Paper
292 | Kind::Art
219293 ) {
220294 if outlines.is_empty() {
221295 return false;
......@@ -328,18 +402,9 @@ fn perform(
328402 Kind::Bullets => format(editor, engine, random, Formatting::Bullets),
329403 Kind::Numbering => format(editor, engine, random, Formatting::Numbering),
330404 Kind::Tag => {
331 let tag = [
332 NoteTag::ToDo,
333 NoteTag::Important,
334 NoteTag::Question,
335 NoteTag::RememberForLater,
336 NoteTag::Definition,
337 NoteTag::Highlight,
338 NoteTag::Contact,
339 NoteTag::Address,
340 NoteTag::PhoneNumber,
341 ][random.below(9)];
342 format(editor, engine, random, Formatting::Tag(tag))
405 let place = random.below(9);
406 let tag = NoteTag::defaults().swap_remove(place);
407 format(editor, engine, random, Formatting::Tag(tag, place as u16))
343408 }
344409 Kind::Check => format(editor, engine, random, Formatting::Check),
345410 Kind::ClickCheck => {
......@@ -462,6 +527,106 @@ fn perform(
462527 let line = editor.active_outline().origin()[0] - random.below(40) as f32;
463528 editor.insert_space(engine, 0, line, 45.0).unwrap_or(false)
464529 }
530 // Lemon paper with college rules, or none.
531 Kind::Paper => {
532 let (color, lines) = match editor.page_color() {
533 None => (Some(0x00dd_fdfd), Some(canvas::template::RULE_LINES[1].1)),
534 Some(_) => (None, None),
535 };
536 editor.set_paper(color, lines, None)
537 }
538 // Template art in two pieces, as the Page Color menu gives it, or none after art.
539 Kind::Art => {
540 let piece = |x: f32| Image {
541 id: onestore::page::text::new_id().unwrap(),
542 layout: Layout {
543 x: Some(x),
544 y: Some(-3.6),
545 max_width: Some(174.5),
546 max_height: Some(640.0),
547 width_set_by_user: Some(true),
548 ..Default::default()
549 },
550 size: Some([174.5, 640.0]),
551 bytes: Some(std::sync::Arc::from(ART.as_slice())),
552 alt: None,
553 background: true,
554 };
555 let shown = editor.page().unwrap().objects.iter().any(|object| {
556 matches!(object, onestore::page::PageObject::Image(image) if image.background)
557 });
558 let art = match shown {
559 true => Vec::new(),
560 false => vec![piece(-27.0), piece(400.0)],
561 };
562 editor.set_paper(editor.page_color(), editor.rule_lines(), Some(art))
563 }
564 Kind::Attach => {
565 let file = onestore::page::Attachment {
566 id: onestore::page::text::new_id().unwrap(),
567 filename: "notes.txt".into(),
568 source_path: None,
569 size: Some([24.0, 24.0]),
570 layout: Default::default(),
571 bytes: Some(std::sync::Arc::from(b"notes".as_slice())),
572 preview: Some(std::sync::Arc::from(ART.as_slice())),
573 recording: None,
574 };
575 place(editor, middle) && editor.insert_attachment(engine, file).is_ok()
576 }
577 Kind::AttachPage => {
578 let file = onestore::page::Attachment {
579 id: onestore::page::text::new_id().unwrap(),
580 filename: "float.txt".into(),
581 source_path: None,
582 size: Some([24.0, 24.0]),
583 layout: Default::default(),
584 bytes: Some(std::sync::Arc::from(b"float".as_slice())),
585 preview: Some(std::sync::Arc::from(ART.as_slice())),
586 recording: None,
587 };
588 let x = 36.0 + 18.0 * random.below(20) as f32;
589 editor.place_caret(engine, [x, 1440.0], 240.0).is_ok()
590 && editor.insert_attachment(engine, file).is_ok()
591 }
592 Kind::MoveFile | Kind::RemoveFile => {
593 let Some(file) = editor
594 .page()
595 .unwrap()
596 .objects
597 .into_iter()
598 .find_map(|object| match object {
599 onestore::page::PageObject::Attachment(file) => Some(file),
600 _ => None,
601 })
602 else {
603 return false;
604 };
605 if kind == Kind::RemoveFile {
606 return editor.remove_image(engine, file.id).is_ok();
607 }
608 let (origin, size) = editor.image_placement(file.id).unwrap();
609 let delta = [18.0, -36.0].map(|step| step * (1 + random.below(3)) as f32);
610 editor
611 .place_image(
612 engine,
613 file.id,
614 [origin[0] + delta[0], origin[1] + delta[1]],
615 size,
616 )
617 .is_ok()
618 }
619 Kind::TypeRun => {
620 place(editor, middle)
621 && editor.insert(engine, "ab").is_ok()
622 && editor.delete(engine, true).is_ok()
623 && editor.insert(engine, " cd").is_ok()
624 && editor.delete_to(engine, Movement::WordLeft).is_ok()
625 && editor.commit_text(engine, "ef".into()).is_ok()
626 }
627 Kind::TypeOn => {
628 editor.insert(engine, "gh").is_ok() && editor.delete(engine, true).is_ok()
629 }
465630 Kind::Undo => editor.undo(engine).unwrap_or(false),
466631 Kind::Redo => editor.redo(engine).unwrap_or(false),
467632 }
......@@ -749,6 +914,10 @@ fn sweep(
749914 .map(|_| KINDS[random.below(KINDS.len())])
750915 .collect()
751916 }))
917 .chain(PAPER_PLANS.map(<[Kind]>::to_vec))
918 .chain(ATTACH_PLANS.map(<[Kind]>::to_vec))
919 .chain(FLOATING_PLANS.map(<[Kind]>::to_vec))
920 .chain(TYPING_PLANS.map(<[Kind]>::to_vec))
752921 .collect::<Vec<Vec<Kind>>>();
753922 for plan in plans {
754923 let arena = Arena::default();
crates/canvas/tests/search.rs+2-1
......@@ -235,7 +235,8 @@ fn tags_summary_lists_each_tagged_paragraph() {
235235 // The probe's T24 and T25 also carry Call back, one entry under each tag.
236236 assert_eq!(tagged.len(), names.len());
237237 names.dedup();
238 let mut labels = canvas::editor::NoteTag::ALL.map(|tag| tag.label());
238 let defaults = canvas::editor::NoteTag::defaults();
239 let mut labels: Vec<&str> = defaults.iter().map(|tag| tag.label.as_str()).collect();
239240 labels.sort_unstable();
240241 assert_eq!(names, labels);
241242 assert!(tagged.iter().all(|tagged| tagged.space == space));
crates/draw/src/draw.glsl+31-1
......@@ -10,6 +10,7 @@ attribute vec4 shape;
1010attribute float stroke;
1111attribute vec2 clip_local;
1212attribute vec3 clip;
13attribute float blur;
1314#endif
1415varying vec2 v_uv;
1516varying vec4 v_color;
......@@ -18,6 +19,7 @@ varying vec4 v_shape;
1819varying float v_stroke;
1920varying vec2 v_clip_local;
2021varying vec3 v_clip;
22varying float v_blur;
2123
2224#ifdef VERTEX
2325void main() {
......@@ -28,6 +30,7 @@ void main() {
2830 v_stroke = stroke;
2931 v_clip_local = clip_local;
3032 v_clip = clip;
33 v_blur = blur;
3134 gl_Position = vec4(position, 0.0, 1.0);
3235}
3336#else
......@@ -54,10 +57,37 @@ float ellipse_arc(float angle, vec2 radius) {
5457 return half_angle * sum * scale;
5558}
5659
60vec2 erf(vec2 x) {
61 vec2 s = sign(x);
62 vec2 a = abs(x);
63 vec2 y = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a;
64 y *= y;
65 return s - s / (y * y);
66}
67
68float shadow(vec2 p, vec2 half_size, float corner, float sigma) {
69 float start = clamp(-3.0 * sigma, p.y - half_size.y, p.y + half_size.y);
70 float end = clamp(3.0 * sigma, p.y - half_size.y, p.y + half_size.y);
71 float stride = (end - start) / 4.0;
72 float y = start + stride * 0.5;
73 float value = 0.0;
74 for (int i = 0; i < 4; i++) {
75 float delta = min(half_size.y - corner - abs(p.y - y), 0.0);
76 float curved = half_size.x - corner + sqrt(max(0.0, corner * corner - delta * delta));
77 vec2 integral = 0.5 + 0.5 * erf((p.x + vec2(-curved, curved)) * (0.70710678 / sigma));
78 float weight = exp(-y * y / (2.0 * sigma * sigma)) / (2.50662827 * sigma);
79 value += (integral.y - integral.x) * weight * stride;
80 y += stride;
81 }
82 return value;
83}
84
5785void main() {
5886 vec4 color = texture2D(atlas, v_uv) * v_color;
5987 vec4 shape = v_shape;
60 if (shape.x > 0.0 && shape.y > 0.0) {
88 if (v_blur > 1e-6) {
89 color *= shadow(v_local, shape.xy, shape.z, v_blur);
90 } else if (shape.x > 0.0 && shape.y > 0.0) {
6191 vec2 q = abs(v_local) - shape.xy + shape.zw;
6292 float distance;
6393 if (same(shape.z, shape.w)) {
crates/draw/src/draw.wgsl+35-3
......@@ -7,6 +7,7 @@ struct Vertex {
77 @location(4) @interpolate(flat) stroke: f32,
88 @location(5) clip_local: vec2<f32>,
99 @location(6) @interpolate(flat) clip: vec3<f32>,
10 @location(7) @interpolate(flat) blur: f32,
1011}
1112
1213@group(0) @binding(0) var atlas: texture_2d<f32>;
......@@ -16,8 +17,9 @@ struct Vertex {
1617fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>,
1718 @location(2) color: vec4<f32>, @location(3) local: vec2<f32>,
1819 @location(4) shape: vec4<f32>, @location(5) stroke: f32,
19 @location(6) clip_local: vec2<f32>, @location(7) clip: vec3<f32>) -> Vertex {
20 return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke, clip_local, clip);
20 @location(6) clip_local: vec2<f32>, @location(7) clip: vec3<f32>,
21 @location(8) blur: f32) -> Vertex {
22 return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke, clip_local, clip, blur);
2123}
2224
2325fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 {
......@@ -37,10 +39,40 @@ fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 {
3739 return half * sum * scale;
3840}
3941
42fn erf(x: vec2<f32>) -> vec2<f32> {
43 let s = sign(x);
44 let a = abs(x);
45 var y = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a;
46 y *= y;
47 return s - s / (y * y);
48}
49
50// Coverage at `p` of a rectangle `half` about the origin with round corners `corner`,
51// blurred by a Gaussian of deviation `sigma`: exact across, sampled four times along
52// (Evan Wallace, "Fast Rounded Rectangle Shadows").
53fn shadow(p: vec2<f32>, half: vec2<f32>, corner: f32, sigma: f32) -> f32 {
54 let start = clamp(-3.0 * sigma, p.y - half.y, p.y + half.y);
55 let end = clamp(3.0 * sigma, p.y - half.y, p.y + half.y);
56 let stride = (end - start) / 4.0;
57 var y = start + stride * 0.5;
58 var value = 0.0;
59 for (var i = 0; i < 4; i += 1) {
60 let delta = min(half.y - corner - abs(p.y - y), 0.0);
61 let curved = half.x - corner + sqrt(max(0.0, corner * corner - delta * delta));
62 let integral = 0.5 + 0.5 * erf((p.x + vec2<f32>(-curved, curved)) * (0.70710678 / sigma));
63 let weight = exp(-y * y / (2.0 * sigma * sigma)) / (2.50662827 * sigma);
64 value += (integral.y - integral.x) * weight * stride;
65 y += stride;
66 }
67 return value;
68}
69
4070@fragment
4171fn fragment(input: Vertex) -> @location(0) vec4<f32> {
4272 var color = textureSample(atlas, atlas_sampler, input.uv) * input.color;
43 if input.shape.x > 0.0 && input.shape.y > 0.0 {
73 if input.blur > 0.0 {
74 color *= shadow(input.local, input.shape.xy, input.shape.z, input.blur);
75 } else if input.shape.x > 0.0 && input.shape.y > 0.0 {
4476 let q = abs(input.local) - input.shape.xy + input.shape.zw;
4577 var distance: f32;
4678 if input.shape.z == input.shape.w {
crates/draw/src/render.rs+184-41
......@@ -32,7 +32,11 @@ use swash::{
3232 zeno::{Angle, Cap, Format, Join, Mask, Transform, Vector},
3333};
3434
35/// The atlas's side at first; it doubles, up to `MAX_ATLAS_SIZE`, when one frame needs
36/// more than half of it.
3537const ATLAS_SIZE: u32 = 2048;
38const MAX_ATLAS_SIZE: u32 = 8192;
39/// Atlas entries per `ATLAS_SIZE` square of atlas.
3640const MAX_GLYPHS: usize = 8192;
3741const MAX_VERTICES: usize = 65_536;
3842const VERTEX_BUFFER_BYTES: u64 = (MAX_VERTICES * size_of::<Vertex>()) as u64;
......@@ -199,6 +203,8 @@ struct Vertex {
199203 /// that rectangle's half size and corner radius; a zero half size paints everywhere.
200204 clip_local: [f32; 2],
201205 clip: [f32; 3],
206 /// The standard deviation, in device pixels, of the blur that makes `shape` a shadow.
207 blur: f32,
202208}
203209
204210#[derive(Hash, PartialEq, Eq)]
......@@ -401,6 +407,14 @@ pub enum Primitive<'a> {
401407 radius: [f32; 2],
402408 colors: [[f32; 4]; 2],
403409 },
410 /// The soft shadow a rounded rectangle casts, blurred `blur` units wide as CSS's
411 /// `box-shadow` takes it.
412 Shadow {
413 rect: [f32; 4],
414 radius: f32,
415 blur: f32,
416 color: [f32; 4],
417 },
404418 Image {
405419 image: &'a RasterImage,
406420 rect: [f32; 4],
......@@ -490,11 +504,36 @@ impl Renderer {
490504 pen: [1, 0],
491505 row_height: 1,
492506 };
493 // The atlas's first texel is the white that solid fills sample.
494507 renderer.write_atlas([0, 0], [1, 1], &[255; 4]);
495508 renderer
496509 }
497510
511 /// Replaces the atlas with an empty one twice as wide and tall.
512 fn grow_atlas(&mut self) {
513 self.new_atlas(self.atlas_side() * 2);
514 self.clear_glyph_cache();
515 self.write_atlas([0, 0], [1, 1], &[255; 4]);
516 }
517
518 fn glyph_limit(&self) -> usize {
519 MAX_GLYPHS * (self.atlas_side() / ATLAS_SIZE).pow(2) as usize
520 }
521
522 fn uv(&self, glyph: AtlasGlyph) -> [f32; 4] {
523 let side = self.atlas_side() as f32;
524 [
525 glyph.x as f32 / side,
526 glyph.y as f32 / side,
527 (glyph.x + glyph.width) as f32 / side,
528 (glyph.y + glyph.height) as f32 / side,
529 ]
530 }
531
532 /// The atlas's first texel, the white that solid fills sample.
533 fn white(&self) -> [f32; 4] {
534 [0.5 / self.atlas_side() as f32; 4]
535 }
536
498537 pub fn clear_glyph_cache(&mut self) {
499538 self.glyphs.clear();
500539 self.pen = [1, 0];
......@@ -524,9 +563,9 @@ impl Renderer {
524563 batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(),
525564 glyph_capacity: self.glyphs.capacity(),
526565 image_capacity: self.images.capacity(),
527 atlas_bytes: u64::from(ATLAS_SIZE) * u64::from(ATLAS_SIZE) * 4,
566 atlas_bytes: u64::from(self.atlas_side()).pow(2) * 4,
528567 vertex_buffer_bytes: VERTEX_BUFFER_BYTES,
529 within_budget: self.glyphs.len() <= MAX_GLYPHS
568 within_budget: self.glyphs.len() <= self.glyph_limit()
530569 && self.images.len() <= MAX_IMAGES
531570 && image_bytes <= MAX_IMAGE_BYTES
532571 && self.vertices.len() <= MAX_VERTICES,
......@@ -578,17 +617,35 @@ impl Renderer {
578617 }
579618 }
580619 }
581 for attempt in 0..2 {
620 let limit = self.max_texture_dimension().min(MAX_ATLAS_SIZE);
621 let mut cleared = false;
622 loop {
582623 self.vertices.clear();
583624 self.batches.clear();
584625 self.groups.clear();
585626 self.barrier = 0;
586 match self.prepare(size, layers, &active_images) {
587 Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(),
627 let full = match self.prepare(size, layers, &active_images) {
628 Err(RenderError::AtlasFull) => true,
588629 result => {
589630 result?;
590 break;
631 false
591632 }
633 };
634 // Once cleared, the atlas holds only this frame's entries; past half full, the
635 // next frames would clear it again and again.
636 let crowded = full || (cleared && 2 * self.pen[1] > self.atlas_side());
637 if !crowded {
638 break;
639 }
640 if !cleared {
641 self.clear_glyph_cache();
642 cleared = true;
643 } else if self.atlas_side() * 2 <= limit {
644 self.grow_atlas();
645 } else if full {
646 return Err(RenderError::AtlasFull);
647 } else {
648 break;
592649 }
593650 }
594651 self.submit(target, size, clear);
......@@ -677,6 +734,7 @@ impl Renderer {
677734 stroke: 0.0,
678735 clip_local: [0.0; 2],
679736 clip: [0.0; 3],
737 blur: 0.0,
680738 }
681739 };
682740 for strip in 0..strips {
......@@ -766,7 +824,7 @@ impl Renderer {
766824 rect[2] * space.scale + space.origin[0],
767825 rect[3] * space.scale + space.origin[1],
768826 ],
769 [0.5 / ATLAS_SIZE as f32; 4],
827 self.white(),
770828 *color,
771829 )?;
772830 }
......@@ -781,6 +839,12 @@ impl Renderer {
781839 radius,
782840 colors,
783841 } => self.rounded_rect(space, *rect, *radius, None, *colors)?,
842 Primitive::Shadow {
843 rect,
844 radius,
845 blur,
846 color,
847 } => self.shadow(space, *rect, *radius, *blur, *color)?,
784848 Primitive::Segment {
785849 from,
786850 to,
......@@ -871,7 +935,7 @@ impl Renderer {
871935 return Ok(());
872936 }
873937 let size = run.size * scale;
874 if !size.is_finite() || size > ATLAS_SIZE as f32 {
938 if !size.is_finite() || size > self.atlas_side() as f32 {
875939 return Err(RenderError::AtlasFull);
876940 }
877941 let color = run
......@@ -905,7 +969,7 @@ impl Renderer {
905969 let cached = if let Some(cached) = self.glyphs.get(&key) {
906970 *cached
907971 } else {
908 if self.glyphs.len() >= MAX_GLYPHS {
972 if self.glyphs.len() >= self.glyph_limit() {
909973 return Err(RenderError::AtlasFull);
910974 }
911975 let font = FontRef::from_index(run.font.as_ref(), run.index as usize)
......@@ -967,16 +1031,10 @@ impl Renderer {
9671031 height *= ratio;
9681032 top = top.clamp(line_top, (line_top + line_height - height).max(line_top));
9691033 }
970 let atlas_size = ATLAS_SIZE as f32;
9711034 self.quad(
9721035 space,
9731036 [left, top, left + width, top + height],
974 [
975 glyph.x as f32 / atlas_size,
976 glyph.y as f32 / atlas_size,
977 (glyph.x + glyph.width) as f32 / atlas_size,
978 (glyph.y + glyph.height) as f32 / atlas_size,
979 ],
1037 self.uv(glyph),
9801038 if glyph.color { [1.0; 4] } else { color },
9811039 )?;
9821040 }
......@@ -992,7 +1050,7 @@ impl Renderer {
9921050 x + decoration.width * scale,
9931051 y + (decoration.thickness * scale).max(1.0),
9941052 ],
995 [0.5 / ATLAS_SIZE as f32; 4],
1053 self.white(),
9961054 decoration
9971055 .color
9981056 .map_or(ink, |color| legible(color, space.backdrop, run.backdrop)),
......@@ -1003,15 +1061,16 @@ impl Renderer {
10031061
10041062 fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> {
10051063 let p = image.placement;
1006 if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE {
1064 let side = self.atlas_side();
1065 if p.width + 2 > side || p.height + 2 > side {
10071066 return Err(RenderError::AtlasFull);
10081067 }
1009 if self.pen[0] + p.width + 1 > ATLAS_SIZE {
1068 if self.pen[0] + p.width + 1 > side {
10101069 self.pen[0] = 0;
10111070 self.pen[1] += self.row_height + 1;
10121071 self.row_height = 0;
10131072 }
1014 if self.pen[1] + p.height + 1 > ATLAS_SIZE {
1073 if self.pen[1] + p.height + 1 > side {
10151074 return Err(RenderError::AtlasFull);
10161075 }
10171076 let cached = AtlasGlyph {
......@@ -1057,7 +1116,7 @@ impl Renderer {
10571116 {
10581117 return Ok(());
10591118 }
1060 if size + 3.0 > ATLAS_SIZE as f32 {
1119 if size + 3.0 > self.atlas_side() as f32 {
10611120 return Err(RenderError::AtlasFull);
10621121 }
10631122 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];
......@@ -1072,7 +1131,7 @@ impl Renderer {
10721131 let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) {
10731132 *glyph
10741133 } else {
1075 if self.glyphs.len() >= MAX_GLYPHS {
1134 if self.glyphs.len() >= self.glyph_limit() {
10761135 return Err(RenderError::AtlasFull);
10771136 }
10781137 let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?;
......@@ -1081,7 +1140,6 @@ impl Renderer {
10811140 };
10821141 let left = x.floor();
10831142 let top = y.floor();
1084 let atlas = ATLAS_SIZE as f32;
10851143 self.quad(
10861144 space,
10871145 [
......@@ -1090,12 +1148,7 @@ impl Renderer {
10901148 left + glyph.width as f32,
10911149 top + glyph.height as f32,
10921150 ],
1093 [
1094 glyph.x as f32 / atlas,
1095 glyph.y as f32 / atlas,
1096 (glyph.x + glyph.width) as f32 / atlas,
1097 (glyph.y + glyph.height) as f32 / atlas,
1098 ],
1151 self.uv(glyph),
10991152 [1.0, 1.0, 1.0, tint[3]],
11001153 )
11011154 }
......@@ -1133,7 +1186,7 @@ impl Renderer {
11331186 let start = self.vertices.len();
11341187 // A pixel of margin holds the antialiased fringe outside the edge.
11351188 let fringe = [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0];
1136 self.quad(space, fringe, [0.5 / ATLAS_SIZE as f32; 4], colors[0])?;
1189 self.quad(space, fringe, self.white(), colors[0])?;
11371190 let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
11381191 if !(4.0 * (half[0] + half[1])).is_finite() || !colors[1].iter().all(|v| v.is_finite()) {
11391192 return Err(RenderError::InvalidPrimitive);
......@@ -1160,6 +1213,41 @@ impl Renderer {
11601213 Ok(())
11611214 }
11621215
1216 fn shadow(
1217 &mut self,
1218 space: Space,
1219 rect: [f32; 4],
1220 radius: f32,
1221 blur: f32,
1222 color: [f32; 4],
1223 ) -> Result<(), RenderError> {
1224 if !radius.is_finite() || radius < 0.0 || !blur.is_finite() || blur <= 0.0 {
1225 return Err(RenderError::InvalidPrimitive);
1226 }
1227 let rect = [0, 1, 2, 3].map(|i| rect[i] * space.scale + space.origin[i % 2]);
1228 let sigma = blur * space.scale / 2.0;
1229 let reach = 3.0 * sigma;
1230 let start = self.vertices.len();
1231 self.quad(
1232 space,
1233 [
1234 rect[0] - reach,
1235 rect[1] - reach,
1236 rect[2] + reach,
1237 rect[3] + reach,
1238 ],
1239 self.white(),
1240 color,
1241 )?;
1242 let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
1243 let radius = (radius * space.scale).min(half[0]).min(half[1]).max(0.0);
1244 for vertex in &mut self.vertices[start..] {
1245 vertex.shape = [half[0], half[1], radius, radius];
1246 vertex.blur = sigma;
1247 }
1248 Ok(())
1249 }
1250
11631251 fn path(
11641252 &mut self,
11651253 space: Space,
......@@ -1195,7 +1283,7 @@ impl Renderer {
11951283 let glyph = match self.glyphs.get(&key) {
11961284 Some(glyph) => *glyph,
11971285 None => {
1198 if self.glyphs.len() >= MAX_GLYPHS {
1286 if self.glyphs.len() >= self.glyph_limit() {
11991287 return Err(RenderError::AtlasFull);
12001288 }
12011289 let mut mask = Mask::new(data);
......@@ -1232,7 +1320,6 @@ impl Renderer {
12321320 };
12331321 let left = x.floor() + glyph.left as f32;
12341322 let top = y.floor() + glyph.top as f32;
1235 let atlas = ATLAS_SIZE as f32;
12361323 let start = self.vertices.len();
12371324 self.quad(
12381325 space,
......@@ -1242,12 +1329,7 @@ impl Renderer {
12421329 left + glyph.width as f32,
12431330 top + glyph.height as f32,
12441331 ],
1245 [
1246 glyph.x as f32 / atlas,
1247 glyph.y as f32 / atlas,
1248 (glyph.x + glyph.width) as f32 / atlas,
1249 (glyph.y + glyph.height) as f32 / atlas,
1250 ],
1332 self.uv(glyph),
12511333 colors[0],
12521334 )?;
12531335 for vertex in &mut self.vertices[start..] {
......@@ -1318,13 +1400,14 @@ impl Renderer {
13181400 x * 2.0 / space.size[0] as f32 - 1.0,
13191401 1.0 - y * 2.0 / space.size[1] as f32,
13201402 ],
1321 uv: [0.5 / ATLAS_SIZE as f32; 2],
1403 uv: [0.5 / self.atlas_side() as f32; 2],
13221404 color,
13231405 local,
13241406 shape: [half[0], half[1], corner, corner],
13251407 stroke: 0.0,
13261408 clip_local: [0.0; 2],
13271409 clip: [0.0; 3],
1410 blur: 0.0,
13281411 });
13291412 }
13301413 Ok(())
......@@ -1378,6 +1461,7 @@ impl Renderer {
13781461 stroke: 0.0,
13791462 clip_local: [0.0; 2],
13801463 clip: [0.0; 3],
1464 blur: 0.0,
13811465 });
13821466 }
13831467 Ok(())
......@@ -2031,7 +2115,7 @@ mod tests {
20312115 );
20322116 }
20332117 assert!(!renderer.glyphs.is_empty());
2034 assert!(renderer.glyphs.len() <= MAX_GLYPHS);
2118 assert!(renderer.glyphs.len() <= renderer.glyph_limit());
20352119 assert_eq!(
20362120 renderer
20372121 .glyphs
......@@ -2255,6 +2339,12 @@ mod tests {
22552339 style: PathStyle::Shadow(6.0),
22562340 colors: [[0.0, 0.0, 0.0, 1.0]; 2],
22572341 },
2342 Primitive::Shadow {
2343 rect: [170.0, 90.0, 190.0, 110.0],
2344 radius: 4.0,
2345 blur: 6.0,
2346 color: [0.0, 0.0, 0.0, 1.0],
2347 },
22582348 Primitive::Gradient {
22592349 rect: [100.0, 10.0, 120.0, 110.0],
22602350 radius: [0.0; 2],
......@@ -2305,6 +2395,59 @@ mod tests {
23052395 "and fades at its edge: {fading:?}"
23062396 );
23072397 assert_eq!(beyond, [255; 4]);
2398 let [inside, fading, beyond] = [pixel(360, 200), pixel(340, 200), pixel(310, 200)];
2399 assert!(
2400 inside[0] < 40,
2401 "a rectangle's shadow is dark inside: {inside:?}"
2402 );
2403 assert!(
2404 fading[0] > 40 && fading[0] < 230,
2405 "and fades at its edge: {fading:?}"
2406 );
2407 assert_eq!(beyond, [255; 4]);
2408 assert_eq!(renderer.glyphs.len(), 3, "and takes no room in the atlas");
2409 }
2410
2411 #[test]
2412 #[ignore = "requires a native GPU adapter"]
2413 fn icons_past_one_atlas_never_fail_a_frame() {
2414 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
2415 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
2416 let (device, queue) =
2417 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
2418 let target = Target::new(&device);
2419 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
2420 let mut renderer = Renderer::new(device, queue, format);
2421 // Each tint is an entry of its own, 64 device pixels square.
2422 let icons = |tints: Range<usize>| -> Vec<Primitive<'static>> {
2423 tints
2424 .map(|tint| Primitive::Icon {
2425 sources: &[CHECKBOX],
2426 origin: [0.0; 2],
2427 size: 32.0,
2428 tint: [tint as f32 / 65_536.0, 0.0, 0.0, 1.0],
2429 palette: Palette::default(),
2430 })
2431 .collect()
2432 };
2433 let one_atlas = (ATLAS_SIZE as usize / 65).pow(2);
2434 for frame in 0..8 {
2435 let start = frame * one_atlas / 3;
2436 let primitives = icons(start..start + one_atlas / 3);
2437 target.draw(&mut renderer, &page(&primitives)).unwrap();
2438 }
2439 assert_eq!(
2440 renderer.atlas_side(),
2441 ATLAS_SIZE,
2442 "frames each needing a third of the atlas evict, never grow it"
2443 );
2444 let primitives = icons(0..3 * one_atlas);
2445 target.draw(&mut renderer, &page(&primitives)).unwrap();
2446 assert!(renderer.atlas_side() > ATLAS_SIZE);
2447 assert!(renderer.glyphs.len() >= 3 * one_atlas);
2448 let capture = target.capture(&renderer);
2449 let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4];
2450 assert_ne!(pixel(24 + 12, 24 + 32), [255; 4], "the icons paint");
23082451 }
23092452
23102453 #[test]
crates/draw/src/render/gl.rs+10-1
......@@ -305,7 +305,7 @@ pub(super) struct Gpu {
305305}
306306
307307/// Each attribute's name, component count and offset, bound to its index in the shader.
308const ATTRIBUTES: [(&CStr, GLint, usize); 8] = [
308const ATTRIBUTES: [(&CStr, GLint, usize); 9] = [
309309 (c"position", 2, offset_of!(Vertex, position)),
310310 (c"uv", 2, offset_of!(Vertex, uv)),
311311 (c"color", 4, offset_of!(Vertex, color)),
......@@ -314,6 +314,7 @@ const ATTRIBUTES: [(&CStr, GLint, usize); 8] = [
314314 (c"stroke", 1, offset_of!(Vertex, stroke)),
315315 (c"clip_local", 2, offset_of!(Vertex, clip_local)),
316316 (c"clip", 3, offset_of!(Vertex, clip)),
317 (c"blur", 1, offset_of!(Vertex, blur)),
317318];
318319
319320impl Renderer {
......@@ -384,6 +385,14 @@ impl Renderer {
384385 self.gpu.max_texture
385386 }
386387
388 pub(super) fn atlas_side(&self) -> u32 {
389 self.gpu.atlas.size[0]
390 }
391
392 pub(super) fn new_atlas(&mut self, side: u32) {
393 self.gpu.atlas = Image::new([side; 2], NEAREST, None);
394 }
395
387396 pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
388397 unsafe {
389398 glBindTexture(TEXTURE_2D, self.gpu.atlas.name);
crates/draw/src/render/webgpu.rs+53-35
......@@ -63,7 +63,7 @@ impl Renderer {
6363 buffers: &[Some(wgpu::VertexBufferLayout {
6464 array_stride: size_of::<Vertex>() as u64,
6565 step_mode: wgpu::VertexStepMode::Vertex,
66 attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3],
66 attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3, 8 => Float32],
6767 })],
6868 },
6969 fragment: Some(wgpu::FragmentState {
......@@ -98,40 +98,7 @@ impl Renderer {
9898 color: erase,
9999 alpha: erase,
100100 });
101 let atlas = device.create_texture(&wgpu::TextureDescriptor {
102 label: Some("Glyph atlas"),
103 size: wgpu::Extent3d {
104 width: ATLAS_SIZE,
105 height: ATLAS_SIZE,
106 depth_or_array_layers: 1,
107 },
108 mip_level_count: 1,
109 sample_count: 1,
110 dimension: wgpu::TextureDimension::D2,
111 format: wgpu::TextureFormat::Rgba8UnormSrgb,
112 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
113 view_formats: &[],
114 });
115 let view = atlas.create_view(&Default::default());
116 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
117 mag_filter: wgpu::FilterMode::Nearest,
118 min_filter: wgpu::FilterMode::Nearest,
119 ..Default::default()
120 });
121 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
122 label: Some("Glyph atlas"),
123 layout: &pipeline.get_bind_group_layout(0),
124 entries: &[
125 wgpu::BindGroupEntry {
126 binding: 0,
127 resource: wgpu::BindingResource::TextureView(&view),
128 },
129 wgpu::BindGroupEntry {
130 binding: 1,
131 resource: wgpu::BindingResource::Sampler(&sampler),
132 },
133 ],
134 });
101 let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE);
135102 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
136103 label: Some("Draw vertices"),
137104 size: VERTEX_BUFFER_BYTES,
......@@ -167,6 +134,14 @@ impl Renderer {
167134 self.device.limits().max_texture_dimension_2d
168135 }
169136
137 pub(super) fn atlas_side(&self) -> u32 {
138 self.gpu.atlas.width()
139 }
140
141 pub(super) fn new_atlas(&mut self, side: u32) {
142 (self.gpu.atlas, self.gpu.bind_group) = atlas(&self.device, &self.gpu.pipeline, side);
143 }
144
170145 pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
171146 self.queue.write_texture(
172147 wgpu::TexelCopyTextureInfo {
......@@ -336,3 +311,46 @@ impl Renderer {
336311 self.queue.submit([encoder.finish()]);
337312 }
338313}
314
315/// An empty atlas `side` texels square, and how the pipeline samples it.
316fn atlas(
317 device: &wgpu::Device,
318 pipeline: &wgpu::RenderPipeline,
319 side: u32,
320) -> (wgpu::Texture, wgpu::BindGroup) {
321 let atlas = device.create_texture(&wgpu::TextureDescriptor {
322 label: Some("Glyph atlas"),
323 size: wgpu::Extent3d {
324 width: side,
325 height: side,
326 depth_or_array_layers: 1,
327 },
328 mip_level_count: 1,
329 sample_count: 1,
330 dimension: wgpu::TextureDimension::D2,
331 format: wgpu::TextureFormat::Rgba8UnormSrgb,
332 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
333 view_formats: &[],
334 });
335 let view = atlas.create_view(&Default::default());
336 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
337 mag_filter: wgpu::FilterMode::Nearest,
338 min_filter: wgpu::FilterMode::Nearest,
339 ..Default::default()
340 });
341 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
342 label: Some("Glyph atlas"),
343 layout: &pipeline.get_bind_group_layout(0),
344 entries: &[
345 wgpu::BindGroupEntry {
346 binding: 0,
347 resource: wgpu::BindingResource::TextureView(&view),
348 },
349 wgpu::BindGroupEntry {
350 binding: 1,
351 resource: wgpu::BindingResource::Sampler(&sampler),
352 },
353 ],
354 });
355 (atlas, bind_group)
356}
crates/mobile/Cargo.toml+1
......@@ -16,6 +16,7 @@ onestore = { path = "../onestore" }
1616wgpu.workspace = true
1717parley.workspace = true
1818pollster = "0.4"
19resvg = { version = "0.48.1", default-features = false }
1920serde = { version = "1", features = ["derive"] }
2021serde_json = "1"
2122
crates/mobile/include/snowbound.h+14-1
......@@ -14,6 +14,9 @@ void sb_string_free(char *text);
1414
1515typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context);
1616void sb_set_coordinator(sb_coordinator coordinator);
17void sb_set_sync_wake(void (*wake)(void));
18char *sb_tags(void);
19bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
1720
1821const Library *sb_library_open(const char *path, const char *cache, char **error);
1922const 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);
2225char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,
2326 const char *date, const char *time);
2427char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query);
28char *sb_library_tagged(const Library *library, const Section *open, const char *path);
29char *sb_library_sync_status(const Library *library);
30void sb_library_set_offline(const Library *library, bool offline);
31void sb_library_sync_now(const Library *library);
2532void sb_library_free(const Library *library);
2633
2734Share *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);
5865bool sb_view_release(View *view);
5966bool sb_view_undo(View *view, bool redo);
6067bool sb_view_can_undo(const View *view, bool redo);
61uint32_t sb_view_format(const View *view);
68uint64_t sb_view_format(const View *view);
6269bool sb_view_apply(View *view, uint8_t command);
6370char *sb_view_title(const View *view);
6471bool sb_view_focus_title(View *view);
6572bool sb_view_find(View *view, const char *query);
73bool sb_view_select_paragraph(View *view, const char *id);
74char *sb_view_copy(View *view, bool cut);
75void sb_view_insert_space(View *view);
76char *sb_view_paper(const View *view);
77bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);
78bool sb_view_set_art(View *view, const char *name);
6679char *sb_view_page_text(const View *view);
6780int8_t sb_view_date_request(View *view, int64_t *seconds);
6881bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time);
crates/mobile/src/lib.rs+210-24
......@@ -78,9 +78,9 @@ enum Target {
7878 Grip = 3,
7979}
8080
81/// Formatting the toolbar shows and applies, by bit in `sb_view_format` and by number in
82/// `sb_view_apply`: toggles, lists, indentation, then OneNote's nine default tags from 16.
83const FORMATS: [Formatting; 8] = [
81/// Formatting the host shows and applies, by bit in `sb_view_format` and by number in
82/// `sb_view_apply`; OneNote's default tags follow from 16, in their list's order.
83const FORMATS: [Formatting; 11] = [
8484 Formatting::Toggle(Toggle::Bold),
8585 Formatting::Toggle(Toggle::Italic),
8686 Formatting::Toggle(Toggle::Underline),
......@@ -89,17 +89,10 @@ const FORMATS: [Formatting; 8] = [
8989 Formatting::Numbering,
9090 Formatting::Indent,
9191 Formatting::Outdent,
92];
93const TAGS: [NoteTag; 9] = [
94 NoteTag::ToDo,
95 NoteTag::Important,
96 NoteTag::Question,
97 NoteTag::RememberForLater,
98 NoteTag::Definition,
99 NoteTag::Highlight,
100 NoteTag::Contact,
101 NoteTag::Address,
102 NoteTag::PhoneNumber,
92 Formatting::RemoveTags,
93 Formatting::Clear,
94 // OneNote's first highlighter colour, yellow.
95 Formatting::Highlight(Some(0x0000_ffff)),
10396];
10497
10598/// The page and its text model, apart from the surface it is drawn on.
......@@ -217,6 +210,9 @@ impl Canvas {
217210 }
218211
219212 fn target(&self, point: [f32; 2]) -> Target {
213 if self.page.inserting_space() {
214 return Target::Grip;
215 }
220216 match self.page.hit(self.device(point)) {
221217 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,
222218 Some(Hit::Image { id, .. })
......@@ -363,8 +359,8 @@ impl Canvas {
363359 }))
364360 }
365361
366 /// The formatting the selection has, as `FORMATS` and `TAGS` bits.
367 fn format_bits(&self) -> Result<u32> {
362 /// The formatting the selection has, as `FORMATS` and default tag bits.
363 fn format_bits(&self) -> Result<u64> {
368364 let state = self.page.editor.format_state()?;
369365 let mut bits = 0;
370366 for (bit, format) in FORMATS.iter().enumerate() {
......@@ -374,17 +370,25 @@ impl Canvas {
374370 Formatting::Numbering => state.numbering,
375371 _ => false,
376372 };
377 bits |= u32::from(on) << bit;
373 bits |= u64::from(on) << bit;
378374 }
379 for (bit, tag) in TAGS.iter().enumerate() {
380 bits |= u32::from(state.tags.contains(tag)) << (16 + bit);
375 for (place, tag) in NoteTag::defaults().into_iter().enumerate() {
376 let tagged = state.tags.contains(&(tag, place as u16));
377 bits |= u64::from(tagged) << (16 + place);
381378 }
382379 Ok(bits)
383380 }
384381
385382 fn format(&mut self, command: u8) -> Result<bool> {
386383 let command = match command {
387 16.. => Formatting::Tag(*TAGS.get(usize::from(command - 16)).ok_or("No such tag")?),
384 16.. => {
385 let place = command - 16;
386 let tag = NoteTag::defaults()
387 .into_iter()
388 .nth(usize::from(place))
389 .ok_or("No such tag")?;
390 Formatting::Tag(tag, place.into())
391 }
388392 _ => FORMATS
389393 .get(usize::from(command))
390394 .ok_or("No such formatting")?
......@@ -426,6 +430,17 @@ impl Canvas {
426430 Ok(true)
427431 }
428432
433 /// Selects paragraph `id`, as a Tags Summary entry shows it.
434 fn select_paragraph(&mut self, id: ExGuid) -> Result<bool> {
435 let Some((outline, selection)) = canvas::search::paragraph_match(&self.page.editor, id)
436 else {
437 return Ok(false);
438 };
439 self.page.editor.focus_outline(outline)?;
440 self.page.editor.select(selection)?;
441 Ok(true)
442 }
443
429444 /// The page's text, outline by outline, as the desktop puts a refused edit's on the
430445 /// clipboard.
431446 fn page_text(&self) -> String {
......@@ -446,6 +461,27 @@ impl Canvas {
446461 .join("\n\n")
447462 }
448463
464 /// Gives the page `color`, a COLORREF, and rule lines `ruled` of `RULE_LINES`, or none.
465 fn set_paper(&mut self, color: Option<u32>, ruled: Option<usize>) -> Result<bool> {
466 let rules = ruled
467 .and_then(|index| canvas::template::RULE_LINES.get(index))
468 .map(|(_, lines)| *lines);
469 Ok(moved(self.page.set_paper(color, rules, None)?))
470 }
471
472 /// Puts template `name`'s art behind the page in place of any it had, or none.
473 fn set_art(&mut self, name: Option<&str>) -> Result<bool> {
474 let art = match name {
475 Some(name) => canvas::template::find(name)
476 .ok_or("No such template")?
477 .pictures()?,
478 None => Vec::new(),
479 };
480 let editor = &self.page.editor;
481 let (color, rules) = (editor.page_color(), editor.rule_lines());
482 Ok(moved(self.page.set_paper(color, rules, Some(art))?))
483 }
484
449485 /// Puts a picture of `size` points after the caret's paragraph.
450486 fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result<bool> {
451487 let image = Image {
......@@ -855,15 +891,15 @@ pub extern "C" fn sb_view_can_undo(view: &View, redo: bool) -> bool {
855891}
856892
857893/// The selection's formatting as bits: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4
858/// bulleted, 5 numbered, and from 16 OneNote's default tags in order, To Do first.
894/// bulleted, 5 numbered, and from 16 OneNote's tags in `sb_tags` order, To Do first.
859895#[unsafe(no_mangle)]
860pub extern "C" fn sb_view_format(view: &View) -> u32 {
896pub extern "C" fn sb_view_format(view: &View) -> u64 {
861897 report(view.canvas.format_bits()).unwrap_or(0)
862898}
863899
864900/// Applies formatting to the selection: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4
865/// bullets, 5 numbering, 6 indent, 7 outdent, each toggling where it can; from 16 toggles
866/// OneNote's default tag `command - 16`, To Do first.
901/// bullets, 5 numbering, 6 indent, 7 outdent, 8 remove tags, 9 clear formatting, 10 yellow
902/// highlight, each toggling where it can; from 16 toggles tag `command - 16` of `sb_tags`.
867903#[unsafe(no_mangle)]
868904pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool {
869905 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) ->
893929 report(view.canvas.find(&string(query))).unwrap_or(false)
894930}
895931
932/// Selects paragraph `id` of the page; false where the page has none.
933///
934/// # Safety
935/// `id` is NUL-terminated UTF-8.
936#[unsafe(no_mangle)]
937pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_char) -> bool {
938 let result = string(id)
939 .parse()
940 .map_err(Into::into)
941 .and_then(|id| view.canvas.select_paragraph(id));
942 report(result).unwrap_or(false)
943}
944
945/// Copy, or Cut with `cut`: the selected text for the clipboard, a page selection's
946/// outlines with a blank line between; null with nothing selected.
947#[unsafe(no_mangle)]
948pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {
949 let mut copied = None;
950 let result = view.canvas.page.copy(cut).map(|response| {
951 if let Some(Request::Copy(text)) = &response.request {
952 copied = Some(text.clone());
953 }
954 moved(response)
955 });
956 view.stored(result);
957 copied.map_or(std::ptr::null_mut(), owned)
958}
959
960/// Insert Space: the next drag on the page moves what lies below or right of where it
961/// starts, as far as it goes.
962#[unsafe(no_mangle)]
963pub extern "C" fn sb_view_insert_space(view: &mut View) {
964 let _ = view.canvas.page.insert_space();
965}
966
967#[derive(serde::Serialize)]
968struct Choices {
969 /// The page colour as sRGB, or null.
970 color: Option<[u8; 3]>,
971 /// Index into `rules` of the page's rule lines, or null for none or others.
972 ruled: Option<usize>,
973 /// OneNote's page colours, rule lines and templates, by name; colours with their sRGB.
974 colors: Vec<(&'static str, [u8; 3])>,
975 rules: Vec<&'static str>,
976 templates: Vec<&'static str>,
977}
978
979fn srgb(colorref: u32) -> [u8; 3] {
980 let [red, green, blue, _] = colorref.to_le_bytes();
981 [red, green, blue]
982}
983
984/// The page's paper and what it can take, as JSON: `color`, `ruled`, and the choices
985/// `colors` (each `[name, [r, g, b]]`), `rules` and `templates`, by name.
986#[unsafe(no_mangle)]
987pub extern "C" fn sb_view_paper(view: &View) -> *mut c_char {
988 use canvas::template::{PAGE_COLORS, RULE_LINES, TEMPLATES};
989 let editor = &view.canvas.page.editor;
990 let rules = editor.rule_lines();
991 library::json(Ok(Choices {
992 color: editor.page_color().map(srgb),
993 ruled: RULE_LINES
994 .iter()
995 .position(|(_, lines)| Some(*lines) == rules),
996 colors: PAGE_COLORS
997 .iter()
998 .map(|(name, color)| (*name, srgb(*color)))
999 .collect(),
1000 rules: RULE_LINES.iter().map(|(name, _)| *name).collect(),
1001 templates: TEMPLATES.iter().map(|template| template.name).collect(),
1002 }))
1003}
1004
1005/// Gives the page the colour `red`, `green`, `blue`, or none when `red` is negative, and
1006/// rule lines `ruled` of `sb_view_paper`'s `rules`, or none when negative, as one undo step.
1007#[unsafe(no_mangle)]
1008pub extern "C" fn sb_view_set_paper(
1009 view: &mut View,
1010 red: i16,
1011 green: u8,
1012 blue: u8,
1013 ruled: i8,
1014) -> bool {
1015 let color = u8::try_from(red)
1016 .ok()
1017 .map(|red| u32::from_le_bytes([red, green, blue, 0]));
1018 let result = view.canvas.set_paper(color, usize::try_from(ruled).ok());
1019 view.stored(result)
1020}
1021
1022/// Puts template `name`'s art behind the page in place of any it had, or none when `name`
1023/// is null, as one undo step; its colour and rule lines stay.
1024///
1025/// # Safety
1026/// A non-null `name` is NUL-terminated UTF-8.
1027#[unsafe(no_mangle)]
1028pub unsafe extern "C" fn sb_view_set_art(view: &mut View, name: *const c_char) -> bool {
1029 let result = view.canvas.set_art(library::optional(name).as_deref());
1030 view.stored(result)
1031}
1032
1033/// OneNote's default tags as JSON, in `sb_view_apply`'s order from 16: each `[name, shape,
1034/// highlight]`, the shape being the symbol `sb_tag_icon` draws and the highlight sRGB or
1035/// null.
1036#[unsafe(no_mangle)]
1037pub extern "C" fn sb_tags() -> *mut c_char {
1038 library::json(Ok(NoteTag::defaults()
1039 .into_iter()
1040 .map(|tag| (tag.label, tag.shape, tag.highlight.map(srgb)))
1041 .collect::<Vec<_>>()))
1042}
1043
1044/// Draws tag symbol `shape`, a check box `checked`, into `rgba`, `pixels` square of
1045/// premultiplied RGBA; false for shape 0, a highlighting tag, which has none.
1046///
1047/// # Safety
1048/// `rgba` has room for `pixels * pixels * 4` bytes.
1049#[unsafe(no_mangle)]
1050pub unsafe extern "C" fn sb_tag_icon(
1051 shape: u16,
1052 checked: bool,
1053 pixels: u32,
1054 rgba: *mut u8,
1055) -> bool {
1056 use resvg::{tiny_skia, usvg};
1057 let Some(icon) = canvas::outline::TagIcon::of(shape, checked) else {
1058 return false;
1059 };
1060 let Some(mut pixmap) = tiny_skia::Pixmap::new(pixels, pixels) else {
1061 return false;
1062 };
1063 for source in canvas::gpu::tag_sources(icon) {
1064 let Some(tree) =
1065 report(usvg::Tree::from_str(source, &usvg::Options::default()).map_err(Into::into))
1066 else {
1067 return false;
1068 };
1069 let size = tree.size();
1070 let scale = pixels as f32 / size.width().max(size.height());
1071 resvg::render(
1072 &tree,
1073 tiny_skia::Transform::from_scale(scale, scale),
1074 &mut pixmap.as_mut(),
1075 );
1076 }
1077 // SAFETY: the caller's contract.
1078 unsafe { std::ptr::copy_nonoverlapping(pixmap.data().as_ptr(), rgba, pixmap.data().len()) };
1079 true
1080}
1081
8961082/// The page's text, for the clipboard when an edit is refused; freed with `sb_string_free`.
8971083#[unsafe(no_mangle)]
8981084pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char {
crates/mobile/src/library.rs+286-35
......@@ -3,11 +3,11 @@
33//! cache, which stores each edit at once and publishes it in the background.
44
55use crate::{Result, owned, report, string};
6use canvas::search::{Entry, Index, Query};
6use canvas::search::{Entry, Index, Query, Tagged};
77use notebook::{
88 Remote, Replica,
99 discover::{Folder, SectionState},
10 session::{self, Event, Notebook},
10 session::{self, Background, Event, Notebook, SyncStatus},
1111 smb::{Client, Credentials},
1212};
1313use onestore::{
......@@ -21,8 +21,8 @@ use std::{
2121 io,
2222 path::{Path, PathBuf},
2323 sync::{
24 Arc, Mutex, OnceLock,
25 atomic::{AtomicU8, Ordering},
24 Arc, Mutex, OnceLock, Weak,
25 atomic::{AtomicBool, AtomicU8, Ordering},
2626 },
2727 time::{Duration, Instant},
2828};
......@@ -33,6 +33,24 @@ const TIMEOUT: Duration = Duration::from_secs(10);
3333const LIMIT: usize = 256 * 1024 * 1024;
3434/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
3535const SECTION_COLOR: u32 = 0x00e4_a88a;
36/// How often sections no page shows are polled, as the desktop polls them.
37const BACKGROUND: Duration = Duration::from_secs(15);
38
39/// Called on a background thread when a notebook's sync status may have changed.
40static SYNC_WAKE: OnceLock<extern "C" fn()> = OnceLock::new();
41
42/// Has the host call `wake` whenever a notebook's closed sections report; set once, before
43/// any library opens.
44#[unsafe(no_mangle)]
45pub extern "C" fn sb_set_sync_wake(wake: extern "C" fn()) {
46 let _ = SYNC_WAKE.set(wake);
47}
48
49fn sync_woken() {
50 if let Some(wake) = SYNC_WAKE.get() {
51 wake();
52 }
53}
3654
3755/// Runs `body(context)` within the host's coordinated reading or writing of `path`, as
3856/// `NSFileCoordinator` has other processes (file providers) wait for it, or not at all when
......@@ -183,6 +201,12 @@ pub struct Library {
183201 place: Place,
184202 cache: PathBuf,
185203 index: Mutex<(HashMap<String, Checked>, Index)>,
204 /// Syncs the sections no session holds; none for a lone section.
205 background: Option<Background>,
206 /// The sections open for editing, by catalog path, whose sessions sync them.
207 open: Mutex<Vec<(String, Weak<Shared>)>>,
208 /// Work Offline, which sections opened later follow too.
209 offline: AtomicBool,
186210}
187211
188212#[derive(serde::Serialize, serde::Deserialize)]
......@@ -244,12 +268,20 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
244268
245269impl Library {
246270 pub(crate) fn open(path: &Path, cache: &Path) -> Result<Self> {
247 let (notebook, place) = if path.is_file() {
248 (None, Place::File(path.to_owned()))
271 let (notebook, place, background) = if path.is_file() {
272 (None, Place::File(path.to_owned()), None)
249273 } else {
274 let notebook = Notebook::open(path, cache)?;
275 let background = notebook.background_with(
276 BACKGROUND,
277 |file| Coordinated(file.to_owned()),
278 sync_woken,
279 )?;
280 background.watch(notebook.replicas());
250281 (
251 Some(Notebook::open(path, cache)?),
282 Some(notebook),
252283 Place::Folder(path.to_owned()),
284 Some(background),
253285 )
254286 };
255287 Ok(Self {
......@@ -257,30 +289,61 @@ impl Library {
257289 place,
258290 cache: cache.to_owned(),
259291 index: Mutex::default(),
292 background,
293 open: Mutex::default(),
294 offline: AtomicBool::new(false),
260295 })
261296 }
262297
263298 /// The notebook folder `root` on `server`; while the server cannot be reached, its sections
264299 /// as last listed, if it was listed before.
265300 pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> {
301 let server = Arc::new(server);
302 let connect = Arc::clone(&server);
303 let background = Background::smb(
304 root,
305 LIMIT,
306 BACKGROUND,
307 move || connect.connect(),
308 sync_woken,
309 )?;
266310 let library = Self {
267311 notebook: Mutex::default(),
268312 place: Place::Share {
269 server: Arc::new(server),
313 server,
270314 client: Mutex::default(),
271315 root: root.to_owned(),
272316 },
273317 cache: cache.to_owned(),
274318 index: Mutex::default(),
319 background: Some(background),
320 open: Mutex::default(),
321 offline: AtomicBool::new(false),
275322 };
276 if let Err(error) = library.with_notebook(false, |_| Ok(()))
277 && library.listed().is_none()
278 {
279 return Err(error);
323 let reached = library.with_notebook(false, |_| Ok(()));
324 match library.listed() {
325 Some(listed) => library.watch(&listed.files),
326 None => reached?,
280327 }
281328 Ok(library)
282329 }
283330
331 /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them.
332 fn watch(&self, files: &BTreeMap<String, String>) {
333 let Some(background) = &self.background else {
334 return;
335 };
336 let smb = self.cache.join("smb");
337 background.watch(
338 files
339 .iter()
340 .map(|(path, identity)| {
341 (path.clone(), Some(smb.join(format!("{identity}.sqlite"))))
342 })
343 .collect(),
344 );
345 }
346
284347 /// Where a share notebook's last listing is kept.
285348 fn listing(&self) -> Option<PathBuf> {
286349 let Place::Share { server, root, .. } = &self.place else {
......@@ -366,6 +429,9 @@ impl Library {
366429 };
367430 tabs(notebook.catalog(), &mut listed.tabs);
368431 files(notebook.catalog(), &mut listed.files);
432 if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) {
433 background.watch(notebook.replicas());
434 }
369435 Ok(listed)
370436 });
371437 let Some(listing) = self.listing() else {
......@@ -375,14 +441,42 @@ impl Library {
375441 Ok(listed) => {
376442 std::fs::create_dir_all(listing.parent().ok_or("No cache")?)?;
377443 std::fs::write(&listing, serde_json::to_vec(&listed)?)?;
444 self.watch(&listed.files);
378445 Ok(listed.tabs)
379446 }
380447 Err(error) => Ok(self.listed().ok_or(error)?.tabs),
381448 }
382449 }
383450
384 /// The section at catalog `path`, through a replica in the cache.
451 /// The section at catalog `path`, through a replica in the cache, following Work
452 /// Offline. The background may hold the replica for a step, which takes a network round
453 /// trip, so a busy replica is tried again for a while.
385454 pub(crate) fn section(
455 &self,
456 path: &str,
457 notify: impl Fn() + Send + Sync + 'static,
458 ) -> Result<session::Section> {
459 let deadline = Instant::now() + TIMEOUT * 3;
460 let notify = Arc::new(notify);
461 let section = loop {
462 let notify = Arc::clone(&notify);
463 match self.section_once(path, move || notify()) {
464 Err(error)
465 if Instant::now() < deadline
466 && error
467 .downcast_ref::<notebook::Error>()
468 .is_some_and(notebook::Error::busy) =>
469 {
470 std::thread::sleep(Duration::from_millis(50));
471 }
472 opened => break opened?,
473 }
474 };
475 section.set_offline(self.offline.load(Ordering::Relaxed));
476 Ok(section)
477 }
478
479 fn section_once(
386480 &self,
387481 path: &str,
388482 notify: impl Fn() + Send + 'static,
......@@ -518,6 +612,27 @@ fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {
518612}
519613
520614impl Section {
615 /// The section at catalog `path` of `library`, its edits naming `author`.
616 pub(crate) fn open(
617 library: Arc<Library>,
618 path: &str,
619 author: String,
620 notify: impl Fn() + Send + Sync + 'static,
621 ) -> Result<Self> {
622 let shared = Arc::new(Shared {
623 section: library.section(path, notify)?,
624 author,
625 status: AtomicU8::new(0),
626 });
627 let mut open = library
628 .open
629 .lock()
630 .unwrap_or_else(|error| error.into_inner());
631 open.push((path.to_owned(), Arc::downgrade(&shared)));
632 drop(open);
633 Ok(Self { shared, library })
634 }
635
521636 pub(crate) fn rows(&self) -> Result<Vec<Row>> {
522637 let section = &self.shared.section;
523638 let conflicts = section.conflicts()?;
......@@ -684,9 +799,50 @@ pub(crate) struct Found {
684799
685800impl Library {
686801 /// Pages of the notebook holding every word of `query` in their title or text, as the
687 /// desktop searches: the open section as its edits leave it, others as stored, read
688 /// again when their file changed.
802 /// desktop searches.
689803 pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result<Vec<Found>> {
804 self.indexed(open, |index| {
805 index
806 .search(&Query::new(query), |_| true)
807 .into_iter()
808 .map(|found| Found {
809 section: found.section,
810 page: found.space.to_string(),
811 title: found.title.replace(|char: char| char.is_control(), " "),
812 snippet: found.snippet,
813 })
814 .collect()
815 })
816 }
817
818 /// The notebook's tagged paragraphs in page order, as OneNote's Tags Summary lists
819 /// them: once for each of their tags.
820 pub(crate) fn tagged(&self, open: Option<(&str, &Section)>) -> Result<Vec<Tag>> {
821 self.indexed(open, |index| {
822 index
823 .tagged(|_| true)
824 .into_iter()
825 .map(|tagged: Tagged| Tag {
826 section: tagged.section,
827 page: tagged.space.to_string(),
828 title: tagged.title.replace(|char: char| char.is_control(), " "),
829 paragraph: tagged.paragraph.to_string(),
830 name: tagged.name,
831 shape: tagged.shape,
832 checked: tagged.checked,
833 text: tagged.text,
834 })
835 .collect()
836 })
837 }
838
839 /// `read` over the notebook's pages: the open section as its edits leave it, others as
840 /// stored, read again when their file changed.
841 fn indexed<T>(
842 &self,
843 open: Option<(&str, &Section)>,
844 read: impl FnOnce(&Index) -> T,
845 ) -> Result<T> {
690846 let tabs: Vec<String> = self
691847 .tabs()?
692848 .into_iter()
......@@ -714,18 +870,73 @@ impl Library {
714870 }
715871 }
716872 index.retain(|entry| tabs.contains(&entry.section));
717 Ok(index
718 .search(&Query::new(query), |_| true)
873 Ok(read(index))
874 }
875
876 /// Each section's sync status in the notebook's order: an open section's from its
877 /// session, the others' from the background.
878 pub(crate) fn sync_status(&self) -> Vec<Synced> {
879 let mut sections = self
880 .background
881 .as_ref()
882 .map(Background::status)
883 .unwrap_or_default();
884 for (path, shared) in self.open_sections() {
885 let Some(status) = report(shared.section.sync_status().map_err(Into::into)) else {
886 continue;
887 };
888 match sections.iter_mut().find(|(listed, _)| *listed == path) {
889 Some((_, listed)) => *listed = status,
890 None => sections.push((path, status)),
891 }
892 }
893 sections
719894 .into_iter()
720 .map(|found| Found {
721 section: found.section,
722 page: found.space.to_string(),
723 title: found.title.replace(|char: char| char.is_control(), " "),
724 snippet: found.snippet,
895 .map(|(path, status): (String, SyncStatus)| Synced {
896 state: status.state() as u8,
897 synced: status.synced.map(unix),
898 queued: status.queued,
899 error: status.error.map(|error| error.to_string()),
900 path,
725901 })
726 .collect())
902 .collect()
903 }
904
905 fn open_sections(&self) -> Vec<(String, Arc<Shared>)> {
906 let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner());
907 open.retain(|(_, shared)| shared.strong_count() > 0);
908 open.iter()
909 .filter_map(|(path, shared)| Some((path.clone(), shared.upgrade()?)))
910 .collect()
727911 }
912}
728913
914#[derive(serde::Serialize)]
915pub(crate) struct Synced {
916 pub(crate) path: String,
917 /// `SyncState` by its place in the enum, best first.
918 pub(crate) state: u8,
919 /// When the section file was last reached, in seconds since 1970.
920 pub(crate) synced: Option<i64>,
921 /// Changes the file does not hold yet.
922 pub(crate) queued: u64,
923 pub(crate) error: Option<String>,
924}
925
926/// A tagged paragraph, from `sb_library_tagged`.
927#[derive(serde::Serialize)]
928pub(crate) struct Tag {
929 pub(crate) section: String,
930 pub(crate) page: String,
931 pub(crate) title: String,
932 pub(crate) paragraph: String,
933 pub(crate) name: String,
934 pub(crate) shape: u16,
935 pub(crate) checked: bool,
936 pub(crate) text: String,
937}
938
939impl Library {
729940 /// Reads the section at catalog `path` into `index` again when its file changed, and
730941 /// checks at most every 30 seconds.
731942 fn reindex(
......@@ -814,12 +1025,12 @@ fn boxed<T>(result: Result<T>, error: *mut *mut c_char) -> *mut T {
8141025 }
8151026}
8161027
817fn json(value: Result<impl serde::Serialize>) -> *mut c_char {
1028pub(crate) fn json(value: Result<impl serde::Serialize>) -> *mut c_char {
8181029 report(value.and_then(|value| Ok(serde_json::to_string(&value)?)))
8191030 .map_or(std::ptr::null_mut(), owned)
8201031}
8211032
822fn optional(text: *const c_char) -> Option<String> {
1033pub(crate) fn optional(text: *const c_char) -> Option<String> {
8231034 (!text.is_null()).then(|| string(text))
8241035}
8251036
......@@ -925,6 +1136,55 @@ pub unsafe extern "C" fn sb_library_search(
9251136 json(library.search(open.map(|section| (path.as_str(), section)), &string(query)))
9261137}
9271138
1139/// The notebook's tagged paragraphs in page order as JSON, once for each of their tags:
1140/// each with `section`, `page`, `title`, `paragraph`, `name`, `shape` (the tag's symbol, 0
1141/// for a highlighting tag), `checked` and `text`. `open` and `path` as `sb_library_search`
1142/// takes them; reads the notebook's sections, so call it off the main thread.
1143///
1144/// # Safety
1145/// A non-null `path` is NUL-terminated UTF-8.
1146#[unsafe(no_mangle)]
1147pub unsafe extern "C" fn sb_library_tagged(
1148 library: &Library,
1149 open: Option<&Section>,
1150 path: *const c_char,
1151) -> *mut c_char {
1152 let path = optional(path).unwrap_or_default();
1153 json(library.tagged(open.map(|section| (path.as_str(), section))))
1154}
1155
1156/// Each section's sync status as JSON, in the notebook's order: `path`, `state` (0 up to
1157/// date, 1 syncing, 2 in use elsewhere, 3 not connected, 4 read-only, 5 failed), `synced`
1158/// (seconds since 1970, or null before the file was reached), `queued` changes and `error`.
1159#[unsafe(no_mangle)]
1160pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char {
1161 json(Ok(library.sync_status()))
1162}
1163
1164/// Works offline, or online again: edits wait in the cache until then or until
1165/// `sb_library_sync_now`. Sections opened later follow.
1166#[unsafe(no_mangle)]
1167pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) {
1168 library.offline.store(offline, Ordering::Relaxed);
1169 if let Some(background) = &library.background {
1170 background.set_offline(offline);
1171 }
1172 for (_, shared) in library.open_sections() {
1173 shared.section.set_offline(offline);
1174 }
1175}
1176
1177/// Syncs every section of the notebook now, working offline included.
1178#[unsafe(no_mangle)]
1179pub extern "C" fn sb_library_sync_now(library: &Library) {
1180 if let Some(background) = &library.background {
1181 background.wake();
1182 }
1183 for (_, shared) in library.open_sections() {
1184 shared.section.wake();
1185 }
1186}
1187
9281188/// # Safety
9291189/// `library` came from `sb_library_open` or `sb_library_server` and is not used again.
9301190#[unsafe(no_mangle)]
......@@ -1050,16 +1310,7 @@ pub unsafe extern "C" fn sb_section_open(
10501310 Arc::from_raw(library as *const Library)
10511311 };
10521312 boxed(
1053 library
1054 .section(&string(path), move || wake(token))
1055 .map(|section| Section {
1056 shared: Arc::new(Shared {
1057 section,
1058 author: string(author),
1059 status: AtomicU8::new(0),
1060 }),
1061 library,
1062 }),
1313 Section::open(library, &string(path), string(author), move || wake(token)),
10631314 error,
10641315 )
10651316}
crates/mobile/src/tests.rs+206-10
......@@ -1,4 +1,6 @@
11use super::*;
2use library::{sb_library_set_offline, sb_library_sync_now};
3use notebook::session::SyncState;
24use std::path::{Path, PathBuf};
35
46const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus");
......@@ -24,15 +26,7 @@ fn copy(path: &str) -> (tempfile::TempDir, PathBuf) {
2426}
2527
2628fn open(library: &Arc<Library>, path: &str) -> Section {
27 let section = library.section(path, || {}).unwrap();
28 Section {
29 shared: Arc::new(Shared {
30 section,
31 author: "Clover Test".into(),
32 status: Default::default(),
33 }),
34 library: Arc::clone(library),
35 }
29 Section::open(Arc::clone(library), path, "Clover Test".into(), || {}).unwrap()
3630}
3731
3832fn space(section: &Section, title: &str) -> ExGuid {
......@@ -230,7 +224,20 @@ fn undo_restores_typing() {
230224 let mut canvas = canvas(&section, "Paragraph controls");
231225 focus(&mut canvas, "Collapsed parent");
232226 let before = canvas.active().shown_text();
233 canvas.insert("hi".into()).unwrap();
227 // UIKit's keystrokes, one call each, with the selection set again where it is: one run.
228 for text in ["h", "i", "x"] {
229 canvas.insert(text.into()).unwrap();
230 let selection = canvas.selection().unwrap();
231 canvas.select(selection).unwrap();
232 }
233 assert!(
234 canvas
235 .page
236 .key(&Key::Named(NamedKey::Backspace), None)
237 .unwrap()
238 .changed
239 );
240 canvas.insert("!".into()).unwrap();
234241 assert_ne!(canvas.active().shown_text(), before);
235242 assert!(moved(canvas.page.undo(false).unwrap()));
236243 assert_eq!(canvas.active().shown_text(), before);
......@@ -629,3 +636,192 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
629636 assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026");
630637 assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before);
631638}
639
640/// A notebook copy with its library, and a section of it open for editing.
641fn notebook_open(path: &str) -> (tempfile::TempDir, PathBuf, Arc<Library>, Section) {
642 let (directory, root) = copy("m6/native-features-01/notebook");
643 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
644 let section = open(&library, path);
645 (directory, root, library, section)
646}
647
648/// Waits up to ten seconds for `ready`.
649fn eventually(mut ready: impl FnMut() -> bool) -> bool {
650 let deadline = Instant::now() + Duration::from_secs(10);
651 while Instant::now() < deadline {
652 if ready() {
653 return true;
654 }
655 std::thread::sleep(Duration::from_millis(50));
656 }
657 false
658}
659
660#[test]
661fn sync_status_lists_every_section_and_work_offline_holds_edits_until_sync_now() {
662 let (_directory, root, library, section) = notebook_open("Features.one");
663 // Every readable section, the open one's from its session.
664 assert!(eventually(|| {
665 let status = library.sync_status();
666 status.len()
667 == library
668 .tabs()
669 .unwrap()
670 .iter()
671 .filter(|tab| tab.readable)
672 .count()
673 && status
674 .iter()
675 .all(|sync| sync.state == SyncState::UpToDate as u8)
676 }));
677 sb_library_set_offline(&library, true);
678 let mut canvas = canvas(&section, "Paragraph controls");
679 focus(&mut canvas, "Collapsed parent");
680 canvas.insert("Offline ".into()).unwrap();
681 section
682 .shared
683 .apply(canvas.edit().unwrap().unwrap())
684 .unwrap();
685 let file = root.join("Features.one");
686 let queued = |library: &Library| {
687 library
688 .sync_status()
689 .into_iter()
690 .find(|sync| sync.path == "Features.one")
691 .unwrap()
692 .queued
693 };
694 assert!(eventually(|| queued(&library) == 1));
695 std::thread::sleep(Duration::from_millis(500));
696 assert!(!stored_text(&file, canvas.space).contains("Offline "));
697 sb_library_sync_now(&library);
698 assert!(eventually(|| queued(&library) == 0));
699 assert!(stored_text(&file, canvas.space).contains("Offline "));
700 sb_library_set_offline(&library, false);
701}
702
703#[test]
704fn sections_open_the_first_time_while_the_background_syncs() {
705 let (directory, root) = copy("m6/native-features-01/notebook");
706 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
707 let rounds = {
708 let library = Arc::clone(&library);
709 std::thread::spawn(move || {
710 for _ in 0..200 {
711 sb_library_sync_now(&library);
712 std::thread::sleep(Duration::from_millis(2));
713 }
714 })
715 };
716 for tab in library
717 .tabs()
718 .unwrap()
719 .into_iter()
720 .filter(|tab| tab.readable)
721 {
722 open(&library, &tab.path);
723 }
724 rounds.join().unwrap();
725 for tab in library
726 .tabs()
727 .unwrap()
728 .into_iter()
729 .filter(|tab| tab.readable)
730 {
731 open(&library, &tab.path);
732 }
733}
734
735#[test]
736fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {
737 let (directory, file) = copy("structural-probe/tag-gallery.one");
738 let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap());
739 let path = file.to_string_lossy().into_owned();
740 let section = open(&library, &path);
741 let tagged = library.tagged(Some((&path, &section))).unwrap();
742 assert!(tagged.len() >= 9, "{}", tagged.len());
743 assert!(
744 tagged
745 .iter()
746 .any(|tag| tag.name == "To Do" && tag.shape == 3)
747 );
748 let first = &tagged[0];
749 let space = first.page.parse().unwrap();
750 let (page, _) = section.shared.page(space).unwrap();
751 let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap();
752 assert!(
753 canvas
754 .select_paragraph(first.paragraph.parse().unwrap())
755 .unwrap()
756 );
757 let [start, end] = canvas.selection().unwrap();
758 assert!(canvas.text([start, end]).contains(first.text.trim()));
759}
760
761#[test]
762fn every_tag_applies_and_reports_and_remove_tag_clears_them() {
763 let (_directory, section) = features();
764 let mut canvas = canvas(&section, "Paragraph controls");
765 focus(&mut canvas, "Collapsed parent");
766 for index in [0, 12, 28] {
767 assert!(canvas.format(16 + index).unwrap());
768 assert_ne!(canvas.format_bits().unwrap() & 1 << (16 + index), 0);
769 }
770 assert!(canvas.format(8).unwrap());
771 assert_eq!(canvas.format_bits().unwrap() >> 16, 0);
772 assert!(canvas.format(16 + NoteTag::defaults().len() as u8).is_err());
773}
774
775#[test]
776fn tag_icons_draw_their_art_and_highlighting_tags_have_none() {
777 let mut rgba = vec![0; 32 * 32 * 4];
778 for tag in NoteTag::defaults() {
779 let drawn = unsafe { sb_tag_icon(tag.shape, true, 32, rgba.as_mut_ptr()) };
780 assert_eq!(drawn, tag.shape != 0, "{tag:?}");
781 if drawn {
782 assert!(rgba.chunks_exact(4).any(|pixel| pixel[3] > 0), "{tag:?}");
783 }
784 }
785}
786
787#[test]
788fn page_colour_rule_lines_and_art_are_stored_and_undo() {
789 let (_directory, section) = features();
790 let mut canvas = canvas(&section, "Paragraph controls");
791 assert!(canvas.set_paper(Some(0xfef5ed), Some(1)).unwrap());
792 assert!(canvas.set_art(Some("Bamboo")).unwrap());
793 section
794 .shared
795 .apply(canvas.edit().unwrap().unwrap())
796 .unwrap();
797 let art = |page: &Page| {
798 page.objects
799 .iter()
800 .filter(|object| {
801 matches!(object, onestore::page::PageObject::Image(image) if image.background)
802 })
803 .count()
804 };
805 let stored = section.shared.page(canvas.space).unwrap().0;
806 assert_eq!(stored.color, Some(0xfef5ed));
807 assert_eq!(stored.rule_lines, Some(canvas::template::RULE_LINES[1].1));
808 assert_eq!(art(&stored), 1);
809 assert!(canvas.set_art(None).unwrap());
810 assert!(moved(canvas.page.undo(false).unwrap()));
811 assert!(moved(canvas.page.undo(false).unwrap()));
812 section
813 .shared
814 .apply(canvas.edit().unwrap().unwrap())
815 .unwrap();
816 let stored = section.shared.page(canvas.space).unwrap().0;
817 assert_eq!((stored.color, art(&stored)), (Some(0xfef5ed), 0));
818}
819
820#[test]
821fn insert_space_turns_the_next_touch_into_a_drag() {
822 let (_directory, section) = features();
823 let mut canvas = canvas(&section, "Paragraph controls");
824 assert_eq!(canvas.target([300.0, 1500.0]), Target::Page);
825 let _ = canvas.page.insert_space();
826 assert_eq!(canvas.target([300.0, 1500.0]), Target::Grip);
827}
crates/notebook/src/lib.rs+23-4
......@@ -14,7 +14,7 @@ use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
1414use std::{
1515 fs::OpenOptions,
1616 io,
17 path::Path,
17 path::{Path, PathBuf},
1818 sync::{Arc, Mutex, MutexGuard, mpsc},
1919 time::Duration,
2020};
......@@ -106,6 +106,25 @@ impl Replica {
106106 /// An initialization error preserves the created file for inspection.
107107 pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> {
108108 validate(source)?;
109 let path = path.as_ref();
110 // Built under another name and linked into place whole, so that a cache that exists
111 // is complete: a background step or a failed creation never leaves one half made.
112 let mut building = path.as_os_str().to_owned();
113 building.push(".creating");
114 let building = PathBuf::from(building);
115 for stale in ["", "-wal", "-shm"] {
116 let mut file = building.as_os_str().to_owned();
117 file.push(stale);
118 let _ = std::fs::remove_file(file);
119 }
120 let built = Self::build(&building, source);
121 let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?));
122 let _ = std::fs::remove_file(&building);
123 linked?;
124 Self::start(cache_connection(path)?)
125 }
126
127 fn build(path: &Path, source: &[u8]) -> Result<()> {
109128 let mut options = OpenOptions::new();
110129 options.read(true).write(true).create_new(true);
111130 #[cfg(unix)]
......@@ -113,8 +132,8 @@ impl Replica {
113132 use std::os::unix::fs::OpenOptionsExt;
114133 options.mode(0o600);
115134 }
116 drop(options.open(path.as_ref())?);
117 let mut connection = cache_connection(path.as_ref())?;
135 drop(options.open(path)?);
136 let mut connection = cache_connection(path)?;
118137 write_ahead(&connection)?;
119138 let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?;
120139 let tables: i64 =
......@@ -133,7 +152,7 @@ impl Replica {
133152 schema::create(&transaction)?;
134153 base::write(&transaction, base::Image::Base, source)?;
135154 transaction.commit()?;
136 Self::start(connection)
155 Ok(connection.close().map_err(|(_, error)| error)?)
137156 }
138157
139158 /// Reopens an existing cache and its durable pending edits without network access.
crates/notebook/src/merge.rs+6-1
......@@ -696,7 +696,9 @@ impl Diff {
696696 let part = |index: &Index| {
697697 index.nodes.get(object).map(|node| match (&node.own, edit) {
698698 (
699 Own::Outline { layout, .. } | Own::Ink(Ink { layout, .. }),
699 Own::Outline { layout, .. }
700 | Own::Ink(Ink { layout, .. })
701 | Own::Attachment(Attachment { layout, .. }),
700702 OutlineEdit::Position { .. },
701703 ) => (layout.x, layout.y, None, None),
702704 (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => (
......@@ -1073,6 +1075,9 @@ impl Index {
10731075 PageObject::Image(image) => {
10741076 index.add(image.id, PAGE, Own::Image(image.clone()), Vec::new())
10751077 }
1078 PageObject::Attachment(file) => {
1079 index.add(file.id, PAGE, Own::Attachment(file.clone()), Vec::new())
1080 }
10761081 PageObject::Ink(ink) => index.add(ink.id, PAGE, Own::Ink(ink.clone()), Vec::new()),
10771082 PageObject::Unsupported(object) => index.add(
10781083 object.id,
crates/notebook/src/queue.rs+4
......@@ -47,6 +47,10 @@ fn object(object: &mut PageObject, visit: &mut impl FnMut(&mut Payload)) {
4747 }
4848 }
4949 PageObject::Image(image) => visit(&mut image.bytes),
50 PageObject::Attachment(attachment) => {
51 visit(&mut attachment.bytes);
52 visit(&mut attachment.preview);
53 }
5054 PageObject::Ink(_) | PageObject::Unsupported(_) => {}
5155 }
5256}
crates/notebook/src/session.rs+46-2
......@@ -919,6 +919,17 @@ impl Notebook {
919919 &self,
920920 interval: Duration,
921921 notify: impl Fn() + Send + 'static,
922 ) -> Result<Background> {
923 self.background_with(interval, |file| FileRemote(file.to_owned()), notify)
924 }
925
926 /// `background`, reaching each section file through the remote `remote` makes for it
927 /// (`section_with`).
928 pub fn background_with<R: Remote + 'static>(
929 &self,
930 interval: Duration,
931 remote: impl Fn(&Path) -> R + Clone + Send + 'static,
932 notify: impl Fn() + Send + 'static,
922933 ) -> Result<Background> {
923934 let Some(root) = self.root.clone() else {
924935 return Err(io::Error::new(
......@@ -930,8 +941,8 @@ impl Notebook {
930941 Background::start(
931942 interval,
932943 move || {
933 let root = root.clone();
934 Ok(move |path: &str| FileRemote(root.join(path)))
944 let (root, remote) = (root.clone(), remote.clone());
945 Ok(move |path: &str| remote(&root.join(path)))
935946 },
936947 notify,
937948 )
......@@ -1110,6 +1121,39 @@ pub struct SyncStatus {
11101121 pub queued: u64,
11111122}
11121123
1124/// What a `SyncStatus` comes to for the reader, from the best to the worst.
1125#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
1126pub enum SyncState {
1127 UpToDate,
1128 /// Not reached yet, or edits wait.
1129 Syncing,
1130 /// Another client holds the section file.
1131 InUse,
1132 /// The file or its server cannot be reached; edits wait until it can.
1133 NotConnected,
1134 /// The file cannot be written where it is stored.
1135 ReadOnly,
1136 Failed,
1137}
1138
1139impl SyncStatus {
1140 pub fn state(&self) -> SyncState {
1141 use io::ErrorKind::*;
1142 match self.error.as_ref().map(io::Error::kind) {
1143 Some(PermissionDenied | ReadOnlyFilesystem) => SyncState::ReadOnly,
1144 Some(WouldBlock | ResourceBusy) => SyncState::InUse,
1145 Some(
1146 NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected
1147 | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe
1148 | AddrNotAvailable,
1149 ) => SyncState::NotConnected,
1150 Some(_) => SyncState::Failed,
1151 None if self.synced.is_none() || self.queued > 0 => SyncState::Syncing,
1152 None => SyncState::UpToDate,
1153 }
1154 }
1155}
1156
11131157/// The last attempt's outcome; `None` before the first.
11141158type Observed = Option<Option<io::Error>>;
11151159
crates/notebook/tests/copy.rs+4-3
......@@ -11,11 +11,12 @@ use std::{
1111 time::{Duration, Instant},
1212};
1313
14/// Pages the writer authored for other rows: text with an attachment and its icon, a
15/// table, nested tables with cell subtrees, an inserted picture, a page-level ink drawing, tags, lists, equations
16/// and paragraph formatting.
14/// Pages the writer authored for other rows: text with an attachment and its icon, files on
15/// the page, a table, nested tables with cell subtrees, an inserted picture, a page-level ink
16/// drawing, tags, lists, equations and paragraph formatting.
1717const SOURCES: &[&str] = &[
1818 "attachment-edit/icon/candidate/files.one",
19 "attachment-floating/candidate/files.one",
1920 "table-edit/created/candidate/tables.one",
2021 "table-edit/nested/candidate/synthetic.one",
2122 "picture-edit/inserted/candidate/pictures.one",
crates/notebook/tests/sync_outline.rs+90
......@@ -614,3 +614,93 @@ fn a_new_native_wrap_reservation_keeps_the_local_width_on_a_conflict_page() {
614614 assert_eq!(layout.max_width, Some(144.0));
615615 assert!(layout.reserved_width.is_none());
616616}
617
618/// `source` with a file on its page, and the file's identity.
619fn with_file(source: &[u8], space: ExGuid) -> (Vec<u8>, ExGuid) {
620 let id = onestore::page::text::new_id().unwrap();
621 let file = onestore::page::Attachment {
622 id,
623 filename: "float.txt".into(),
624 source_path: None,
625 size: Some([24.0, 24.0]),
626 layout: onestore::document::Layout {
627 x: Some(342.0),
628 y: Some(284.4),
629 max_width: Some(54.0),
630 max_height: Some(63.0),
631 width_set_by_user: Some(false),
632 reserved_width: None,
633 },
634 bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])),
635 preview: None,
636 recording: None,
637 };
638 let image = remote_with(source, space, |page| {
639 page.objects.push(PageObject::Attachment(file))
640 });
641 (image, id)
642}
643
644fn file_mut(page: &mut Page, id: ExGuid) -> &mut onestore::page::Attachment {
645 page.objects
646 .iter_mut()
647 .find_map(|object| match object {
648 PageObject::Attachment(file) if file.id == id => Some(file),
649 _ => None,
650 })
651 .unwrap()
652}
653
654/// A file on the page merges as an outline's position does: a local move and a remote rename
655/// both land, and competing moves keep the local one on a conflict page.
656#[test]
657fn files_on_the_page_move_through_sync_and_competing_moves_conflict() {
658 let (source, space, _, text_id) = fixture();
659 let (source, file) = with_file(&source, space);
660 let place = |x, y| {
661 move |page: &mut Page| {
662 let layout = &mut file_mut(page, file).layout;
663 [layout.x, layout.y] = [Some(x), Some(y)];
664 }
665 };
666 let directory = tempfile::tempdir().unwrap();
667 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
668 let id = save(&cache, text_id, place(126.0, 410.4)).unwrap().unwrap();
669 let mut server = Server::new(&remote_with(&source, space, |page| {
670 file_mut(page, file).filename = "renamed.txt".into();
671 }));
672 published(&cache, &mut server, id);
673 let mut durable = page_of(&server.durable, space);
674 let merged = file_mut(&mut durable, file);
675 assert_eq!(
676 (merged.layout.x, merged.layout.y, merged.filename.as_str()),
677 (Some(126.0), Some(410.4), "renamed.txt")
678 );
679 assert_eq!(merged.bytes.as_deref(), Some(&b"Floating bytes"[..]));
680
681 let directory = tempfile::tempdir().unwrap();
682 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
683 save(&cache, text_id, place(126.0, 410.4)).unwrap().unwrap();
684 let remote = remote_with(&source, space, place(36.0, 50.4));
685 let mut server = Server::new(&remote);
686 assert!(conflicted(&cache, &mut server, space));
687 let mut durable = page_of(&server.durable, space);
688 assert_eq!(file_mut(&mut durable, file).layout.x, Some(36.0));
689 let arena = onestore::Arena::default();
690 let mut section = onestore::Section::open(&arena, server.durable.clone()).unwrap();
691 let conflict = section.conflicts().unwrap()[0].1[0].space;
692 let kept = section.page(conflict).unwrap();
693 let copies: Vec<_> = kept
694 .objects
695 .iter()
696 .filter_map(|object| match object {
697 PageObject::Attachment(file) => Some(file),
698 _ => None,
699 })
700 .collect();
701 let [copy] = copies.as_slice() else {
702 panic!("{kept:?}")
703 };
704 assert_eq!((copy.layout.x, copy.layout.y), (Some(126.0), Some(410.4)));
705 assert_eq!(copy.bytes.as_deref(), Some(&b"Floating bytes"[..]));
706}
crates/onestore/README.md+2-2
......@@ -46,7 +46,7 @@ harness also accepts `--client-profile release`.
4646| `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 |
4747| `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 |
4848| `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 |
49| `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` |
49| `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` |
5050| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
5151| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
5252| `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`.
5656| `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 |
5757| `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 |
5858| `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
59| `OutlineEdit`, `PageOp::Outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
59| `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 |
6060| `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 |
6161| `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 |
6262| `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 |
crates/onestore/src/op/apply.rs+34-24
......@@ -1090,25 +1090,7 @@ impl<'a> Writer<'_, 'a> {
10901090 self.picture(image, Some(paragraph.id))
10911091 }
10921092 ParagraphContent::Attachment(attachment) => {
1093 let Some(bytes) = &attachment.bytes else {
1094 return Err(OpError::Unsupported("A new attachment needs its payload").into());
1095 };
1096 let ids = AttachmentIds {
1097 object: attachment.id,
1098 file: fresh()?,
1099 payload: crate::write::fresh_guid()?,
1100 preview: match &attachment.preview {
1101 Some(_) => Some((crate::write::fresh_guid()?, fresh()?)),
1102 None => None,
1103 },
1104 };
1105 let mut payloads: Vec<([u8; 16], &[u8])> = vec![(ids.payload, bytes)];
1106 if let (Some((payload, _)), Some(icon)) = (ids.preview, &attachment.preview) {
1107 payloads.push((payload, icon));
1108 }
1109 self.write_with(&payloads, |page| {
1110 content::attachment_changes(page, attachment, &ids, paragraph.id)
1111 })
1093 self.attachment(attachment, Some(paragraph.id))
11121094 }
11131095 ParagraphContent::Ink(ink) => self.ink(ink, Some(paragraph.id)),
11141096 ParagraphContent::Unsupported(_) => {
......@@ -1136,6 +1118,32 @@ impl<'a> Writer<'_, 'a> {
11361118 })
11371119 }
11381120
1121 fn attachment(
1122 &mut self,
1123 attachment: &Attachment,
1124 holder: Option<ExGuid>,
1125 ) -> Result<(), Failure> {
1126 let Some(bytes) = &attachment.bytes else {
1127 return Err(OpError::Unsupported("A new attachment needs its payload").into());
1128 };
1129 let ids = AttachmentIds {
1130 object: attachment.id,
1131 file: fresh()?,
1132 payload: crate::write::fresh_guid()?,
1133 preview: match &attachment.preview {
1134 Some(_) => Some((crate::write::fresh_guid()?, fresh()?)),
1135 None => None,
1136 },
1137 };
1138 let mut payloads: Vec<([u8; 16], &[u8])> = vec![(ids.payload, bytes)];
1139 if let (Some((payload, _)), Some(icon)) = (ids.preview, &attachment.preview) {
1140 payloads.push((payload, icon));
1141 }
1142 self.write_with(&payloads, |page| {
1143 content::attachment_changes(page, attachment, &ids, holder)
1144 })
1145 }
1146
11391147 fn ink(&mut self, ink: &Ink, holder: Option<ExGuid>) -> Result<(), Failure> {
11401148 for stroke in &ink.strokes {
11411149 self.free(stroke.id)?;
......@@ -1166,13 +1174,14 @@ impl<'a> Writer<'_, 'a> {
11661174 self.free(id)?;
11671175 }
11681176 }
1169 if first.parent.is_some() || first.level != 1 {
1170 return Err(OpError::Unsupported("An outline starts at its first level").into());
1177 if first.parent.is_some() {
1178 return Err(OpError::Unsupported("An outline's first paragraph has no parent").into());
11711179 }
11721180 self.paragraph(outline.id, None, first, Some((outline.id, x, y)))?;
1173 // Later paragraphs follow the first, their levels as the outline gives them.
1174 if !rest.is_empty() {
1175 let mut targets = vec![(first.id, 1)];
1181 // Later paragraphs follow the first, their levels as the outline gives them; a
1182 // deeper first paragraph goes in a group, as OneNote stores a tabbed one.
1183 if !rest.is_empty() || first.level != 1 {
1184 let mut targets = vec![(first.id, first.level)];
11761185 for paragraph in rest {
11771186 let parent = paragraph.parent.unwrap_or(outline.id);
11781187 let parent_level = rest
......@@ -1226,6 +1235,7 @@ impl<'a> Writer<'_, 'a> {
12261235 }
12271236 self.picture(image, None)?;
12281237 }
1238 PageObject::Attachment(attachment) => self.attachment(attachment, None)?,
12291239 PageObject::Ink(ink) => self.ink(ink, None)?,
12301240 PageObject::Title(_) | PageObject::Unsupported(_) => {
12311241 return Err(
crates/onestore/src/op/content.rs+23-6
......@@ -218,7 +218,9 @@ pub(crate) fn picture_changes(
218218 [0xff, 0xd8, 0xff, ..] => ".jpg",
219219 [b'G', b'I', b'F', b'8', ..] => ".gif",
220220 [b'B', b'M', ..] => ".bmp",
221 _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")),
221 // OneNote keeps an imported TIFF as it is (`corpus/m6/native-features-01`).
222 [b'I', b'I', 0x2a, 0x00, ..] | [b'M', b'M', 0x00, 0x2a, ..] => ".tif",
223 _ => return Err(invalid("Choose a PNG, JPEG, GIF, BMP or TIFF picture")),
222224 };
223225 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
224226 let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
......@@ -303,12 +305,13 @@ pub(crate) struct AttachmentIds {
303305
304306/// What OneNote stores for an inserted file: an embedded-file container declaring the
305307/// payload and an attachment object naming the file, which paragraph `holder` holds as
306/// content. The caller embeds the payloads.
308/// content or the page lists as a child at the attachment's position
309/// (`corpus/attachment-floating`). The caller embeds the payloads.
307310pub(crate) fn attachment_changes(
308311 active: &ActivePage<'_>,
309312 attachment: &Attachment,
310313 ids: &AttachmentIds,
311 holder: ExGuid,
314 holder: Option<ExGuid>,
312315) -> Result<Changes, Error> {
313316 let name = attachment.filename.as_str();
314317 if name.is_empty() || name.contains(['\0', '/', '\\']) {
......@@ -351,13 +354,27 @@ pub(crate) fn attachment_changes(
351354 .flat_map(|v| v.to_le_bytes())
352355 .collect(),
353356 ));
354 values.push((0x14001c3e, 1u32.to_le_bytes().to_vec()));
355 values.push((0x14001c84, 1u32.to_le_bytes().to_vec()));
357 let positioned = attachment.layout.x.is_some() || attachment.layout.y.is_some();
358 if holder.is_some() == positioned {
359 return Err(invalid(
360 "A file on the page has a position and a paragraph's file has none",
361 ));
362 }
363 // LayoutAlignmentInParent and LayoutAlignmentSelf: a paragraph's file is left-aligned in
364 // its paragraph; one on the page is aligned by its left margin (9), as OneNote stores.
365 match holder {
366 Some(_) => {
367 values.push((0x14001c3e, 1u32.to_le_bytes().to_vec()));
368 values.push((0x14001c84, 1u32.to_le_bytes().to_vec()));
369 }
370 None => values.push((0x14001c84, 9u32.to_le_bytes().to_vec())),
371 }
356372 values.push((0x1c001c22, crate::create::string(name)));
357373 values.push((0x1c001d9c, crate::create::string(name)));
358374 if let Some(path) = &attachment.source_path {
359375 values.push((0x1c001d9d, crate::create::string(path)));
360376 }
377 values.extend(layout_values(&Default::default(), &attachment.layout)?.0);
361378 let mut changed = BTreeMap::new();
362379 let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?;
363380 file.jcid = crate::write::EMBEDDED_FILE_JCID;
......@@ -379,7 +396,7 @@ pub(crate) fn attachment_changes(
379396 node.set(&[(0x20001c3f, &reference)])?;
380397 }
381398 changed.insert(ids.object, node);
382 hold(active, &mut changed, ids.object, Some(holder), &modified)?;
399 hold(active, &mut changed, ids.object, holder, &modified)?;
383400 Ok(changed)
384401}
385402
crates/onestore/src/op/lower.rs+79-9
......@@ -103,7 +103,10 @@ impl<'a> View<'a> {
103103 view.outline(outline)?;
104104 }
105105 }
106 PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => {}
106 PageObject::Image(_)
107 | PageObject::Attachment(_)
108 | PageObject::Ink(_)
109 | PageObject::Unsupported(_) => {}
107110 }
108111 }
109112 Ok(view)
......@@ -191,9 +194,10 @@ impl<'a> View<'a> {
191194 let outlines: Vec<&Outline> = match object {
192195 PageObject::Outline(outline) => vec![outline],
193196 PageObject::Title(title) => title.outlines.iter().collect(),
194 PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => {
195 Vec::new()
196 }
197 PageObject::Image(_)
198 | PageObject::Attachment(_)
199 | PageObject::Ink(_)
200 | PageObject::Unsupported(_) => Vec::new(),
197201 };
198202 for outline in outlines {
199203 containers.push(outline.id);
......@@ -262,7 +266,10 @@ pub(crate) fn validate(
262266 title.layout,
263267 title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()
264268 )),
265 PageObject::Outline(_) | PageObject::Image(_) | PageObject::Ink(_) => None,
269 PageObject::Outline(_)
270 | PageObject::Image(_)
271 | PageObject::Attachment(_)
272 | PageObject::Ink(_) => None,
266273 })
267274 .collect();
268275 fixed.sort();
......@@ -667,7 +674,18 @@ pub(crate) fn paragraph_fields(
667674 space_before: field!(space_before),
668675 space_after: field!(space_after),
669676 line_spacing: field!(line_spacing),
670 language: field!(language),
677 // An unset language is the writer's default, not LCID 0, which OneNote never stores
678 // on a text object and which runs without their own language would then read.
679 language: first.language.filter(|value| {
680 target
681 .spans()
682 .iter()
683 .all(|span| span.format.language == Some(*value))
684 && stored
685 .spans()
686 .iter()
687 .any(|span| span.format.language != Some(*value))
688 }),
671689 })
672690}
673691
......@@ -986,6 +1004,54 @@ impl Lowering {
9861004 })?;
9871005 }
9881006 }
1007 let files = |page: &Page| -> BTreeMap<ExGuid, crate::page::Attachment> {
1008 page.objects
1009 .iter()
1010 .filter_map(|object| match object {
1011 PageObject::Attachment(file) => Some((file.id, file.clone())),
1012 _ => None,
1013 })
1014 .collect()
1015 };
1016 let stored = files(old.page);
1017 for (id, file) in files(new.page) {
1018 let Some(previous) = stored.get(&id) else {
1019 continue;
1020 };
1021 let (from, to) = (&previous.layout, &file.layout);
1022 if (
1023 from.max_width,
1024 from.max_height,
1025 from.width_set_by_user,
1026 from.reserved_width,
1027 ) != (
1028 to.max_width,
1029 to.max_height,
1030 to.width_set_by_user,
1031 to.reserved_width,
1032 ) {
1033 return Err(invalid("A file on the page keeps its extent"));
1034 }
1035 if (from.x, from.y) != (to.x, to.y) {
1036 let (Some(x), Some(y)) = (to.x, to.y) else {
1037 return Err(invalid("A file position needs both coordinates"));
1038 };
1039 self.emit(PageOp::Outline {
1040 object: id,
1041 edit: crate::OutlineEdit::Position { x, y },
1042 })?;
1043 }
1044 if (&previous.filename, &previous.source_path, previous.size)
1045 != (&file.filename, &file.source_path, file.size)
1046 {
1047 self.emit(PageOp::Attachment {
1048 attachment: id,
1049 filename: file.filename.clone(),
1050 source_path: file.source_path.clone(),
1051 size: file.size,
1052 })?;
1053 }
1054 }
9891055 let inks = |page: &Page| -> BTreeMap<ExGuid, crate::page::Ink> {
9901056 page.objects
9911057 .iter()
......@@ -1051,7 +1117,9 @@ impl Lowering {
10511117 ..(*outline).clone()
10521118 })
10531119 }
1054 PageObject::Image(_) | PageObject::Ink(_) => (*object).clone(),
1120 PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Ink(_) => {
1121 (*object).clone()
1122 }
10551123 PageObject::Title(_) | PageObject::Unsupported(_) => {
10561124 return Err(invalid(
10571125 "Titles and unsupported objects cannot be edited through the page model",
......@@ -1513,8 +1581,10 @@ impl Lowering {
15131581 }
15141582 let kept: BTreeSet<ExGuid> = new.page.objects.iter().map(PageObject::id).collect();
15151583 for object in &old.page.objects {
1516 if matches!(object, PageObject::Image(_) | PageObject::Ink(_))
1517 && !kept.contains(&object.id())
1584 if matches!(
1585 object,
1586 PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Ink(_)
1587 ) && !kept.contains(&object.id())
15181588 {
15191589 self.emit(PageOp::Delete {
15201590 object: object.id(),
crates/onestore/src/op/mod.rs+3-3
......@@ -114,7 +114,7 @@ pub enum PageOp {
114114 right: ExGuid,
115115 },
116116 /// Moves a subtree before a direct child of `parent`, or last; `None` names the page,
117 /// whose children are outlines, pictures and ink. A paragraph keeps its level where it
117 /// whose children are outlines, pictures, files and ink. A paragraph keeps its level where it
118118 /// lies deeper than its new parent.
119119 Move {
120120 object: ExGuid,
......@@ -163,8 +163,8 @@ pub enum PageOp {
163163 tags: Vec<Tag>,
164164 definitions: Vec<(ExGuid, Definition)>,
165165 },
166 /// Adds an outline with its paragraphs, a picture with its payload, or ink, before a
167 /// page child or on top.
166 /// Adds an outline with its paragraphs, a picture or file with its payloads, or ink,
167 /// before a page child or on top.
168168 Add {
169169 object: PageObject,
170170 before: Option<ExGuid>,
crates/onestore/src/op/model.rs+44-15
......@@ -194,7 +194,9 @@ pub(crate) fn holds(page: &Page, id: ExGuid) -> bool {
194194 .iter()
195195 .any(|o| o.id == id || identities(&o.paragraphs).contains(&id)),
196196 PageObject::Ink(ink) => ink.strokes.iter().any(|s| s.id == id),
197 PageObject::Image(_) | PageObject::Unsupported(_) => false,
197 PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Unsupported(_) => {
198 false
199 }
198200 }
199201 })
200202}
......@@ -926,6 +928,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
926928 OutlineEdit::Position { x, y } => {
927929 let layout = match page.objects.iter_mut().find(|o| o.id() == *object) {
928930 Some(PageObject::Ink(ink)) => &mut ink.layout,
931 Some(PageObject::Attachment(file)) => &mut file.layout,
929932 _ => &mut outline_mut(page, *object)?.layout,
930933 };
931934 layout.x = Some(stored(*x));
......@@ -1090,12 +1093,29 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
10901093 PageObject::Outline(outline)
10911094 }
10921095 PageObject::Ink(ink) => PageObject::Ink(inked(ink)),
1096 PageObject::Attachment(file) => {
1097 let mut file = file.clone();
1098 let layout = &mut file.layout;
1099 for value in [
1100 &mut layout.x,
1101 &mut layout.y,
1102 &mut layout.max_width,
1103 &mut layout.max_height,
1104 ] {
1105 *value = value.map(stored);
1106 }
1107 layout.width_set_by_user = layout
1108 .max_width
1109 .map(|_| layout.width_set_by_user == Some(true));
1110 PageObject::Attachment(file)
1111 }
10931112 object => object.clone(),
10941113 };
10951114 let outline = matches!(object, PageObject::Outline(_));
10961115 let at = page_position(page, *before)?;
10971116 page.objects.insert(at, object);
1098 if outline {
1117 // Placing an object before another moves it there, which retitles as a move does.
1118 if outline || before.is_some() {
10991119 retitle(page, None);
11001120 }
11011121 }
......@@ -1123,19 +1143,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
11231143 source_path,
11241144 size,
11251145 } => {
1126 let (path, index) = lists(page)
1127 .into_iter()
1128 .find_map(|(path, _, list)| {
1129 list.iter()
1130 .position(|p| matches!(&p.content, ParagraphContent::Attachment(a) if a.id == *attachment))
1131 .map(|index| (path, index))
1132 })
1133 .ok_or_else(|| invalid("The attachment is not on the page"))?;
1134 let ParagraphContent::Attachment(stored_attachment) =
1135 &mut list_at(page, &path)[index].content
1136 else {
1137 unreachable!()
1138 };
1146 let stored_attachment = attachment_mut(page, *attachment)?;
11391147 stored_attachment.filename = filename.clone();
11401148 stored_attachment.source_path = source_path.clone();
11411149 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
13051313 Ok(image)
13061314}
13071315
1316fn attachment_mut(page: &mut Page, id: ExGuid) -> Result<&mut crate::page::Attachment, Error> {
1317 if let Some(at) = page.objects.iter().position(|o| o.id() == id) {
1318 let PageObject::Attachment(file) = &mut page.objects[at] else {
1319 return Err(invalid("Select an attachment"));
1320 };
1321 return Ok(file);
1322 }
1323 let (path, index) = lists(page)
1324 .into_iter()
1325 .find_map(|(path, _, list)| {
1326 list.iter()
1327 .position(|p| matches!(&p.content, ParagraphContent::Attachment(a) if a.id == id))
1328 .map(|index| (path, index))
1329 })
1330 .ok_or_else(|| invalid("The attachment is not on the page"))?;
1331 let ParagraphContent::Attachment(file) = &mut list_at(page, &path)[index].content else {
1332 unreachable!()
1333 };
1334 Ok(file)
1335}
1336
13081337fn ink_mut(page: &mut Page, id: ExGuid) -> Result<&mut crate::page::Ink, Error> {
13091338 if let Some(at) = page.objects.iter().position(|o| o.id() == id) {
13101339 let PageObject::Ink(ink) = &mut page.objects[at] else {
crates/onestore/src/op/properties.rs+7-1
......@@ -141,11 +141,17 @@ fn tag_definition_values(definition: &Definition) -> Result<Values, Error> {
141141 else {
142142 return Err(invalid("A note tag must reference a tag definition"));
143143 };
144 // NoteTagPropertyStatus: hasLabel, hasFontColor, hasHighlightColor and hasIcon, as
145 // OneNote's Customize Tags writes them.
146 let status = u32::from(label.is_some())
147 | u32::from(color.is_some()) << 1
148 | u32::from(highlight.is_some()) << 2
149 | u32::from(shape.is_some_and(|shape| shape != 0)) << 3;
144150 let mut values: Values = vec![
145151 (0x0c003473, vec![0]),
146152 (0x10003463, action_type.unwrap_or(0).to_le_bytes().to_vec()),
147153 (0x10003464, shape.unwrap_or(0).to_le_bytes().to_vec()),
148 (0x14003467, 0u32.to_le_bytes().to_vec()),
154 (0x14003467, status.to_le_bytes().to_vec()),
149155 ];
150156 if let Some(label) = label {
151157 values.push((0x1c003468, crate::create::string(label)));
crates/onestore/src/op/tests.rs+18
......@@ -1105,6 +1105,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
11051105 filename: "notes.txt".into(),
11061106 source_path: Some(r"C:\notes.txt".into()),
11071107 size: None,
1108 layout: Layout::default(),
11081109 bytes: Some(b"attached bytes".as_slice().into()),
11091110 preview: None,
11101111 recording: None,
......@@ -2957,3 +2958,20 @@ fn rule_lines_store_what_onenote_stores() {
29572958 .unwrap();
29582959 }
29592960}
2961
2962/// Text added without a language stores the writer's, and lowering it back must not write
2963/// LCID 0 on the text object, which runs later written without a language would read.
2964#[test]
2965fn an_unset_language_is_not_paragraph_formatting() {
2966 let language = |language| Format {
2967 language,
2968 ..Default::default()
2969 };
2970 let stored = Paragraph::new(String::new(), language(Some(0x409)));
2971 let fields = |target| lower::paragraph_fields(&stored, &target).unwrap().language;
2972 assert_eq!(fields(Paragraph::new("Fresh".into(), language(None))), None);
2973 assert_eq!(
2974 fields(Paragraph::new("Frais".into(), language(Some(0x40c)))),
2975 Some(0x40c)
2976 );
2977}
crates/onestore/src/outline.rs+8-5
......@@ -13,8 +13,9 @@ use std::collections::{BTreeMap, BTreeSet};
1313#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1414#[serde(deny_unknown_fields)]
1515pub enum OutlineEdit {
16 /// Moves an ordinary page outline to coordinates measured in points, or offsets a page's
17 /// ink drawing from its strokes' coordinates by that much, as OneNote 2010 moves one.
16 /// Moves an ordinary page outline or a file on the page to coordinates measured in
17 /// points, or offsets a page's ink drawing from its strokes' coordinates by that much,
18 /// as OneNote 2010 moves one.
1819 /// Refreshes automatic page titles when the leading outline changes.
1920 Position { x: f32, y: f32 },
2021 /// Changes an ordinary outline's maximum width in points, at least 36.
......@@ -65,12 +66,14 @@ impl OutlineEdit {
6566 }
6667 }
6768 Self::Position { .. } => {
68 if !matches!(node.kind, Kind::Outline { .. } | Kind::Ink { .. })
69 || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())
69 if !matches!(
70 node.kind,
71 Kind::Outline { .. } | Kind::Ink { .. } | Kind::Attachment { .. }
72 ) || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())
7073 || !view.nodes[page].children.contains(&object)
7174 {
7275 return Err(invalid(
73 "Select an ordinary outline or ink drawing directly on the page",
76 "Select an ordinary outline, file or ink drawing directly on the page",
7477 ));
7578 }
7679 }
crates/onestore/src/page/mod.rs+16-1
......@@ -179,6 +179,8 @@ pub enum PageObject {
179179 Outline(Outline),
180180 Title(Title),
181181 Image(Image),
182 /// A file on a blank part of the page, as OneNote places one attached or dropped there.
183 Attachment(Attachment),
182184 Ink(Ink),
183185 Unsupported(Unsupported),
184186}
......@@ -189,6 +191,7 @@ impl PageObject {
189191 Self::Outline(value) => value.id,
190192 Self::Title(value) => value.id,
191193 Self::Image(value) => value.id,
194 Self::Attachment(value) => value.id,
192195 Self::Ink(value) => value.id,
193196 Self::Unsupported(value) => value.id,
194197 }
......@@ -198,6 +201,7 @@ impl PageObject {
198201 Self::Outline(value) => &value.layout,
199202 Self::Title(value) => &value.layout,
200203 Self::Image(value) => &value.layout,
204 Self::Attachment(value) => &value.layout,
201205 Self::Ink(value) => &value.layout,
202206 Self::Unsupported(value) => &value.layout,
203207 }
......@@ -208,6 +212,7 @@ impl PageObject {
208212 Self::Outline(value) => &mut value.layout,
209213 Self::Title(value) => &mut value.layout,
210214 Self::Image(value) => &mut value.layout,
215 Self::Attachment(value) => &mut value.layout,
211216 Self::Ink(value) => &mut value.layout,
212217 Self::Unsupported(value) => &mut value.layout,
213218 }
......@@ -268,8 +273,11 @@ pub struct Attachment {
268273 pub filename: String,
269274 /// Where the file was inserted from, when recorded.
270275 pub source_path: Option<String>,
271 /// The icon OneNote rendered for the file, when present.
276 /// The icon's size in points.
272277 pub size: Option<[f32; 2]>,
278 /// On the page, where the file's column sits and its extent; a paragraph's file has none.
279 #[serde(default)]
280 pub layout: Layout,
273281 #[serde(with = "payload")]
274282 pub bytes: Option<Arc<[u8]>>,
275283 #[serde(with = "payload")]
......@@ -301,6 +309,7 @@ impl PartialEq for Attachment {
301309 && self.filename == other.filename
302310 && self.source_path == other.source_path
303311 && self.size == other.size
312 && self.layout == other.layout
304313 && self.recording == other.recording
305314 }
306315}
......@@ -343,6 +352,7 @@ impl Attachment {
343352 filename: filename.clone().unwrap_or_default(),
344353 source_path: source_path.clone(),
345354 size: icon_width.zip(*icon_height).map(|(w, h)| [w, h]),
355 layout: node.layout.clone(),
346356 bytes: payload(container)?,
347357 preview: payload(preview)?,
348358 recording: recording_id.map(|id| Recording {
......@@ -818,6 +828,11 @@ impl Page {
818828 page.objects
819829 .push(PageObject::Image(Image::read(revision, id, node)?));
820830 }
831 Kind::Attachment { .. } => {
832 page.objects.push(PageObject::Attachment(Attachment::read(
833 revision, id, node,
834 )?));
835 }
821836 Kind::Ink { .. } => {
822837 page.objects
823838 .push(PageObject::Ink(Ink::read(revision, id, node)?));
crates/onestore/src/tree.rs+4-1
......@@ -89,7 +89,10 @@ impl TreeEdit {
8989 .get(1)
9090 .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?;
9191 let compatible = |parent: ExGuid| match view.nodes[&self.object].kind {
92 Kind::Outline { .. } | Kind::Image { .. } | Kind::Ink { .. } => parent == *page,
92 Kind::Outline { .. }
93 | Kind::Image { .. }
94 | Kind::Attachment { .. }
95 | Kind::Ink { .. } => parent == *page,
9396 Kind::Paragraph { .. } => matches!(
9497 view.nodes[&parent].kind,
9598 Kind::Outline { .. }
crates/onestore/tests/page_attachments.rs+138
......@@ -112,6 +112,7 @@ fn insert_attachment(section: &str, preview: Option<Arc<[u8]>>) -> Vec<u8> {
112112 filename: "notes 🦀.txt".into(),
113113 source_path: None,
114114 size: Some([24.0, 24.0]),
115 layout: Default::default(),
115116 bytes: Some(Arc::from(PAYLOAD)),
116117 preview: preview.clone(),
117118 recording: None,
......@@ -182,6 +183,7 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() {
182183 filename: "sub/dir.txt".into(),
183184 source_path: None,
184185 size: None,
186 layout: Default::default(),
185187 bytes: Some(Arc::from(PAYLOAD)),
186188 preview: None,
187189 recording: None,
......@@ -230,3 +232,139 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() {
230232 .unwrap();
231233 }
232234}
235
236const FLOATING: &[u8] = include_bytes!("../../../corpus/attachment-floating/native/files.one");
237
238fn floating(page: &Page) -> Vec<&Attachment> {
239 page.objects
240 .iter()
241 .filter_map(|object| match object {
242 PageObject::Attachment(file) => Some(file),
243 _ => None,
244 })
245 .collect()
246}
247
248#[test]
249fn files_onenote_places_on_the_page_read_as_page_objects_sharing_one_payload() {
250 let store = Store::parse(FLOATING).unwrap();
251 let index = RevisionIndex::parse(&store).unwrap();
252 let document = Document::parse(&index).unwrap();
253 let (space, _) = document.pages().unwrap()[1];
254 let page = Page::from_space(&document, space).unwrap();
255 let files = floating(&page);
256 let [moved, attached] = files.as_slice() else {
257 panic!("{page:?}");
258 };
259 // Inserted through COM then dragged, and attached at a click on blank page.
260 assert_eq!(
261 [moved.layout.x, moved.layout.y],
262 [Some(288.0), Some(140.40001)]
263 );
264 assert_eq!(moved.layout.max_width, None);
265 assert_eq!(
266 [attached.layout.x, attached.layout.y],
267 [Some(342.0), Some(284.4)]
268 );
269 assert_eq!(
270 [attached.layout.max_width, attached.layout.max_height],
271 [Some(54.0), Some(62.999985)]
272 );
273 for file in files {
274 assert_eq!(file.filename, "float.txt");
275 assert_eq!(
276 file.bytes.as_deref(),
277 Some(b"Floating file, stored at page level.\r\n".as_slice())
278 );
279 assert_eq!(file.size, Some([24.0, 24.0]));
280 }
281}
282
283/// `ONESTORE_FLOATING_EXPORT` names a directory receiving the section for a cold reopen.
284#[test]
285fn a_file_on_the_page_is_attached_moved_renamed_and_removed_as_ops() {
286 let source = onestore::create_section("files.one", "Floating", "Author").unwrap();
287 let (space, before) = first_page(&source);
288 let (_, native) = first_page(NATIVE);
289 let file = Attachment {
290 id: new_id().unwrap(),
291 filename: "float 🦀.txt".into(),
292 source_path: Some(r"C:\inputs\float 🦀.txt".into()),
293 size: Some([24.0, 24.0]),
294 layout: onestore::document::Layout {
295 x: Some(342.0),
296 y: Some(284.4),
297 max_width: Some(54.0),
298 max_height: Some(63.0),
299 width_set_by_user: Some(false),
300 reserved_width: None,
301 },
302 bytes: Some(Arc::from(PAYLOAD)),
303 preview: attachments(&native)[0].preview.clone(),
304 recording: None,
305 };
306 let steps: [&dyn Fn(&mut Page); 4] = [
307 &|page| page.objects.push(PageObject::Attachment(file.clone())),
308 &|page| {
309 let PageObject::Attachment(file) = page.objects.last_mut().unwrap() else {
310 panic!()
311 };
312 [file.layout.x, file.layout.y] = [Some(126.0), Some(410.4)];
313 },
314 &|page| {
315 let PageObject::Attachment(file) = page.objects.last_mut().unwrap() else {
316 panic!()
317 };
318 file.filename = "renamed.txt".into();
319 },
320 &|page| {
321 page.objects.pop();
322 },
323 ];
324 let mut image = source.clone();
325 let mut expected = before.clone();
326 for (step, edit) in steps.iter().enumerate() {
327 edit(&mut expected);
328 let arena = onestore::Arena::default();
329 let section = onestore::Section::open(&arena, image.clone()).unwrap();
330 let stored = section.page(space).unwrap();
331 let lowered = onestore::op::lower_page(&stored, &expected).unwrap();
332 let mut predicted = stored.clone();
333 for op in &lowered {
334 onestore::op::predict(&mut predicted, op).unwrap();
335 }
336 image = ops::page_edited(&image, space, lowered).unwrap();
337 let read = page_in(&image, space);
338 assert_eq!(read, predicted, "step {step}");
339 assert_eq!(read, expected, "step {step}");
340 if let [file] = floating(&read)[..] {
341 assert_eq!(file.bytes.as_deref(), Some(PAYLOAD));
342 assert_eq!(file.preview, attachments(&native)[0].preview);
343 }
344 if step == 1
345 && let Some(directory) = std::env::var_os("ONESTORE_FLOATING_EXPORT")
346 {
347 let directory = std::path::PathBuf::from(directory);
348 std::fs::create_dir_all(&directory).unwrap();
349 std::fs::write(directory.join("files.one"), &image).unwrap();
350 let file_id = Store::parse(&image).unwrap().header.file_id;
351 std::fs::write(
352 directory.join("Open Notebook.onetoc2"),
353 onestore::create_table_of_contents(
354 "Open Notebook.onetoc2",
355 &[("files.one", file_id)],
356 )
357 .unwrap(),
358 )
359 .unwrap();
360 }
361 }
362 assert!(floating(&page_in(&image, space)).is_empty());
363 // A file on the page has a position; one in a paragraph has none.
364 let mut unplaced = before.clone();
365 unplaced.objects.push(PageObject::Attachment(Attachment {
366 layout: Default::default(),
367 ..file.clone()
368 }));
369 assert!(ops::saved(&source, space, &unplaced).is_err());
370}
crates/onestore/tests/page_model.rs+4-1
......@@ -60,7 +60,10 @@ fn identities(page: &Page) -> Vec<onestore::ExGuid> {
6060 ids.extend(outline.unsupported.iter().map(|u| u.id));
6161 }
6262 }
63 PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => {}
63 PageObject::Image(_)
64 | PageObject::Attachment(_)
65 | PageObject::Ink(_)
66 | PageObject::Unsupported(_) => {}
6467 }
6568 }
6669 ids
crates/onestore/tests/protected.rs+1
......@@ -175,6 +175,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() {
175175 filename: "sealed.txt".into(),
176176 source_path: None,
177177 size: Some([24.0, 24.0]),
178 layout: Default::default(),
178179 bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])),
179180 preview: None,
180181 recording: None,
crates/onestore/tests/support/page_edits.rs+1
......@@ -142,6 +142,7 @@ fn projection(page: &Page) -> String {
142142 }
143143 PageObject::Title(title) => out.push_str(&format!("title {}\n", title.id)),
144144 PageObject::Image(image) => out.push_str(&format!("image {}\n", image.id)),
145 PageObject::Attachment(file) => out.push_str(&format!("file {}\n", file.id)),
145146 PageObject::Ink(ink) => out.push_str(&format!("ink {}\n", ink.id)),
146147 PageObject::Unsupported(u) => out.push_str(&format!("unsupported {}\n", u.id)),
147148 }
crates/snowbound/src/art.rs-3
......@@ -66,6 +66,3 @@ pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy");
6666pub const SYNC_DONE: &[&str] = art!("icons/sync-done");
6767pub const SYNC_ERROR: &[&str] = art!("icons/sync-error");
6868pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline");
69
70pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
71pub const TAG_DEFINITION: &[&str] = art!("tags/definition");
crates/snowbound/src/attachment.rs created+94
......@@ -0,0 +1,94 @@
1//! Files on the page, as OneNote 2010 attaches, opens and saves them.
2
3use crate::{State, platform};
4use onestore::page::Attachment;
5use std::{error::Error, path::Path};
6
7/// The size OneNote 2010 shows a file's icon at, in points.
8const ICON_SIZE: [f32; 2] = [24.0, 24.0];
9
10impl State {
11 /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes
12 /// at the caret or at `at`, with the icon the system shows for it.
13 pub(crate) fn attach(
14 &mut self,
15 path: &Path,
16 at: Option<[f32; 2]>,
17 ) -> Result<(), Box<dyn Error>> {
18 if self.session.as_ref().is_some_and(crate::Session::read_only) {
19 return Ok(());
20 }
21 let (Some(name), Ok(bytes)) = (
22 path.file_name().and_then(|name| name.to_str()),
23 std::fs::read(path),
24 ) else {
25 platform::alert("Couldn't attach the file", "Choose a file you can open.");
26 return Ok(());
27 };
28 let file = Attachment {
29 id: onestore::page::text::new_id()?,
30 filename: name.to_owned(),
31 source_path: path.to_str().map(str::to_owned),
32 size: Some(ICON_SIZE),
33 layout: Default::default(),
34 bytes: Some(bytes.into()),
35 preview: platform::file_icon(path).map(Into::into),
36 recording: None,
37 };
38 let response = match at {
39 Some(point) => {
40 let scale = self.ui.scale();
41 let corner = self.ui.rect(crate::page()).unwrap_or_default();
42 self.view.drop_attachment(
43 [
44 (point[0] - corner[0]) * scale,
45 (point[1] - corner[1]) * scale,
46 ],
47 file,
48 )?
49 }
50 None => self.view.insert_attachment(file)?,
51 };
52 self.respond(response);
53 Ok(())
54 }
55
56 /// Open: a copy of the file, in a folder of its own, with the system's application for it.
57 pub(crate) fn open_attachment(&self, file: &Attachment) -> Result<(), Box<dyn Error>> {
58 let Some(bytes) = &file.bytes else {
59 unstored();
60 return Ok(());
61 };
62 let root = std::env::temp_dir().join("Snowbound Attachments");
63 std::fs::create_dir_all(&root)?;
64 // A copy opened earlier may still be open, edited, in its application.
65 let folder = (0..1000)
66 .map(|index| root.join(index.to_string()))
67 .find(|folder| std::fs::create_dir(folder).is_ok())
68 .ok_or("No folder is free for a copy of the file")?;
69 let path = folder.join(&file.filename);
70 std::fs::write(&path, bytes)?;
71 platform::open_file(&path);
72 Ok(())
73 }
74
75 /// Save As: the file's bytes where the user chooses, under its name by default.
76 pub(crate) fn save_attachment(&self, file: &Attachment) -> Result<(), Box<dyn Error>> {
77 let Some(bytes) = &file.bytes else {
78 unstored();
79 return Ok(());
80 };
81 if let Some(path) = platform::pick_new("Save As", &file.filename, "Save") {
82 std::fs::write(path, bytes)?;
83 }
84 Ok(())
85 }
86}
87
88/// A file another program stored beside the section rather than in it.
89fn unstored() {
90 platform::alert(
91 "Couldn't open the file",
92 "Open this page in OneNote to open or save the file.",
93 );
94}
crates/snowbound/src/commands.rs+82-69
......@@ -2,7 +2,7 @@
22//! chords on each platform, when it applies and what it does.
33
44use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};
5use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, NoteTag, Toggle};
5use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Toggle};
66use canvas::interaction::Request;
77use draw::edit::{Key, Modifiers, NamedKey, Platform};
88use std::{error::Error, sync::Arc};
......@@ -63,7 +63,9 @@ pub enum Id {
6363 Indent,
6464 Outdent,
6565 ClearFormatting,
66 Tag(NoteTag),
66 /// The tag at this place in the user's tag list.
67 Tag(usize),
68 CustomizeTags,
6769 RemoveTags,
6870 FindTags,
6971 Help,
......@@ -176,15 +178,6 @@ macro_rules! row {
176178 };
177179}
178180
179macro_rules! tag {
180 ($tag:expr, $digit:literal) => {
181 row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)])
182 };
183 ($tag:expr) => {
184 row!(Id::Tag($tag), $tag.label(), NONE, NONE)
185 };
186}
187
188181const NONE: &[Chord] = &[];
189182
190183/// OneNote 2010's chords off macOS; there, AppKit's and OneNote for Mac's.
......@@ -378,35 +371,7 @@ pub const COMMANDS: &[Command] = &[
378371 &[cmd('n').shift()],
379372 &[cmd('n').shift()]
380373 ),
381 tag!(NoteTag::ToDo, '1'),
382 tag!(NoteTag::Important, '2'),
383 tag!(NoteTag::Question, '3'),
384 tag!(NoteTag::RememberForLater, '4'),
385 tag!(NoteTag::Definition, '5'),
386 tag!(NoteTag::Highlight, '6'),
387 tag!(NoteTag::Contact, '7'),
388 tag!(NoteTag::Address, '8'),
389 tag!(NoteTag::PhoneNumber, '9'),
390 tag!(NoteTag::WebSiteToVisit),
391 tag!(NoteTag::Idea),
392 tag!(NoteTag::Password),
393 tag!(NoteTag::Critical),
394 tag!(NoteTag::ProjectA),
395 tag!(NoteTag::ProjectB),
396 tag!(NoteTag::MovieToSee),
397 tag!(NoteTag::BookToRead),
398 tag!(NoteTag::MusicToListenTo),
399 tag!(NoteTag::SourceForArticle),
400 tag!(NoteTag::RememberForBlog),
401 tag!(NoteTag::DiscussWithPersonA),
402 tag!(NoteTag::DiscussWithPersonB),
403 tag!(NoteTag::DiscussWithManager),
404 tag!(NoteTag::SendInEmail),
405 tag!(NoteTag::ScheduleMeeting),
406 tag!(NoteTag::CallBack),
407 tag!(NoteTag::ToDoPriority1),
408 tag!(NoteTag::ToDoPriority2),
409 tag!(NoteTag::ClientRequest),
374 row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE),
410375 row!(
411376 Id::RemoveTags,
412377 "Remove Tag",
......@@ -417,6 +382,9 @@ pub const COMMANDS: &[Command] = &[
417382 row!(Id::Help, "Snowbound Help", NONE, NONE),
418383];
419384
385/// The most tags a list holds: MS-ONE's action types 0 to 99 number them.
386pub const TAGS: usize = 100;
387
420388/// Where Snowbound Help leads.
421389const HELP: &str = "https://shale.paperclover.net/snowbound";
422390
......@@ -443,6 +411,7 @@ pub fn choices() -> impl Iterator<Item = Choice> {
443411 .map(|command| Choice::Command(command.id))
444412 .chain(FONTS.map(|name| Choice::Font(name.to_owned())))
445413 .chain(SIZES.into_iter().map(Choice::Size))
414 .chain((0..TAGS).map(|place| Choice::Command(Id::Tag(place))))
446415 .chain(std::iter::once(Choice::Highlight(None)))
447416 .chain(
448417 HIGHLIGHTS
......@@ -455,6 +424,7 @@ pub fn choices() -> impl Iterator<Item = Choice> {
455424#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
456425pub fn title(choice: Choice) -> String {
457426 match choice {
427 Choice::Command(Id::Tag(_)) => unreachable!("The tag list names its tags"),
458428 Choice::Command(id) => command(id).title.to_owned(),
459429 Choice::Font(name) => name,
460430 Choice::Size(size) => format!("{size}"),
......@@ -483,11 +453,30 @@ pub fn index(choice: &Choice) -> usize {
483453
484454/// The command `key` with `modifiers` runs here.
485455pub fn find(key: &Key, modifiers: Modifiers) -> Option<Id> {
486 let pressed = pressed(key, modifiers, Platform::CURRENT)?;
456 ran(
457 pressed(key, modifiers, Platform::CURRENT)?,
458 Platform::CURRENT,
459 )
460}
461
462/// The command `chord` runs on `platform`.
463fn ran(chord: Chord, platform: Platform) -> Option<Id> {
487464 COMMANDS
488465 .iter()
489 .find(|command| command.chords(Platform::CURRENT).contains(&pressed))
466 .find(|command| command.chords(platform).contains(&chord))
490467 .map(|command| command.id)
468 .or_else(|| {
469 (0..9)
470 .find(|&place| tag_chord(place) == Some(chord))
471 .map(Id::Tag)
472 })
473}
474
475/// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to Ctrl+9,
476/// as OneNote's do.
477pub fn tag_chord(place: usize) -> Option<Chord> {
478 let digit = u8::try_from(place).ok().filter(|place| *place < 9)?;
479 Some(cmd(char::from(b'1' + digit)))
491480}
492481
493482fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> {
......@@ -599,10 +588,11 @@ impl Chord {
599588
600589/// The chord a menu or hint shows for `id` here, empty where it has none.
601590pub fn shortcut(id: Id) -> String {
602 command(id)
603 .chords(Platform::CURRENT)
604 .first()
605 .map_or_else(String::new, |chord| chord.label(Platform::CURRENT))
591 let chord = match id {
592 Id::Tag(place) => tag_chord(place),
593 id => command(id).chords(Platform::CURRENT).first().copied(),
594 };
595 chord.map_or_else(String::new, |chord| chord.label(Platform::CURRENT))
606596}
607597
608598impl State {
......@@ -627,7 +617,7 @@ impl State {
627617 pub(crate) fn status(&self, choice: &Choice, format: &FormatState) -> Status {
628618 let session = self.session.as_ref();
629619 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();
630 let modal = self.options.is_some() || self.link.is_some();
620 let modal = self.options.is_some() || self.link.is_some() || self.tag_list.is_some();
631621 let page = (session.is_some() || self.temporary) && !modal;
632622 let writable = page && !session.is_some_and(|session| session.read_only());
633623 let text = writable && self.view.accepts_text();
......@@ -655,9 +645,12 @@ impl State {
655645 Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor,
656646 };
657647 match id {
658 Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::CommandPalette | Id::Help => {
659 enabled(!modal)
660 }
648 Id::Settings
649 | Id::NewNotebook
650 | Id::OpenNotebook
651 | Id::CommandPalette
652 | Id::CustomizeTags
653 | Id::Help => enabled(!modal),
661654 Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()),
662655 Id::NewSection | Id::NewSectionGroup => enabled(
663656 !modal
......@@ -715,14 +708,15 @@ impl State {
715708 },
716709 Id::PageColor => enabled(writable && session.is_some()),
717710 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),
718 // Attach File awaits #23, Record Audio and Video #36.
719 Id::Attachment | Id::RecordAudio | Id::RecordVideo => enabled(false),
711 // Record Audio and Video await #36.
712 Id::RecordAudio | Id::RecordVideo => enabled(false),
720713 Id::InsertSpace => Status {
721714 enabled: writable,
722715 checked: self.view.inserting_space(),
723716 },
724717 Id::Table
725718 | Id::Picture
719 | Id::Attachment
726720 | Id::Symbol
727721 | Id::Link
728722 | Id::Equation
......@@ -739,7 +733,10 @@ impl State {
739733 Id::Bullets => checked(format.bullets),
740734 Id::Numbering => checked(format.numbering),
741735 Id::Align(alignment) => checked(format.alignment == Some(alignment)),
742 Id::Tag(tag) => checked(format.tags.contains(&tag)),
736 Id::Tag(place) => match self.tags.get(place) {
737 Some(tag) => checked(format.tags.contains(&(tag.clone(), place as u16))),
738 None => Status::default(),
739 },
743740 }
744741 }
745742
......@@ -815,14 +812,19 @@ impl State {
815812 return Ok(());
816813 }
817814 Choice::PageColor(color) => {
818 return self.paper_page(color, self.view.editor.rule_lines());
815 return self.paper_page(color, self.view.editor.rule_lines(), None);
819816 }
820817 Choice::Art(name) => {
821 return self.art_page(name.and_then(canvas::template::find));
818 let editor = &self.view.editor;
819 return self.paper_page(
820 editor.page_color(),
821 editor.rule_lines(),
822 Some(name.and_then(canvas::template::find)),
823 );
822824 }
823825 Choice::RuleLines(lines) => {
824826 let lines = lines.map(|index| canvas::template::RULE_LINES[index].1);
825 return self.paper_page(self.view.editor.page_color(), lines);
827 return self.paper_page(self.view.editor.page_color(), lines, None);
826828 }
827829 };
828830 let field = self.ui.focused().filter(|focus| *focus != page());
......@@ -951,11 +953,18 @@ impl State {
951953 return Ok(());
952954 }
953955 Id::Picture => {
954 let Some(path) = platform::pick_picture() else {
956 let Some(path) = platform::pick_file("Insert Picture", &crate::PICTURE_TYPES)
957 else {
955958 return Ok(());
956959 };
957960 return self.insert_picture(std::fs::read(path)?);
958961 }
962 Id::Attachment => {
963 let Some(path) = platform::pick_file("Attach File", &[]) else {
964 return Ok(());
965 };
966 return self.attach(&path, None);
967 }
959968 Id::ScreenClipping => {
960969 #[cfg(target_os = "macos")]
961970 platform::clip_screen(self.proxy.clone());
......@@ -971,7 +980,7 @@ impl State {
971980 self.save_settings();
972981 return Ok(());
973982 }
974 Id::Attachment | Id::RecordAudio | Id::RecordVideo => {
983 Id::RecordAudio | Id::RecordVideo => {
975984 return Ok(());
976985 }
977986 Id::InsertSpace => {
......@@ -1016,7 +1025,14 @@ impl State {
10161025 Id::Indent => return format(self, Formatting::Indent),
10171026 Id::Outdent => return format(self, Formatting::Outdent),
10181027 Id::ClearFormatting => return format(self, Formatting::Clear),
1019 Id::Tag(tag) => return format(self, Formatting::Tag(tag)),
1028 Id::Tag(place) => {
1029 let tag = self.tags[place].clone();
1030 return format(self, Formatting::Tag(tag, place as u16));
1031 }
1032 Id::CustomizeTags => {
1033 self.open_customize_tags();
1034 return Ok(());
1035 }
10201036 Id::RemoveTags => return format(self, Formatting::RemoveTags),
10211037 };
10221038 self.commands.push(response);
......@@ -1039,8 +1055,9 @@ mod tests {
10391055 command
10401056 .chords(platform)
10411057 .iter()
1042 .map(move |chord| (chord, command.title))
1058 .map(move |chord| (*chord, command.title))
10431059 })
1060 .chain((0..9).filter_map(|place| Some((tag_chord(place)?, "a tag"))))
10441061 .collect();
10451062 for (at, (chord, title)) in chords.iter().enumerate() {
10461063 if let Some((_, other)) =
......@@ -1081,11 +1098,10 @@ mod tests {
10811098 control,
10821099 command,
10831100 };
1084 let pressed = pressed(&Key::Character(key.into()), modifiers, platform)?;
1085 COMMANDS
1086 .iter()
1087 .find(|listed| listed.chords(platform).contains(&pressed))
1088 .map(|listed| listed.id)
1101 ran(
1102 pressed(&Key::Character(key.into()), modifiers, platform)?,
1103 platform,
1104 )
10891105 };
10901106 let mac = Platform::MacOs;
10911107 assert_eq!(
......@@ -1110,10 +1126,7 @@ mod tests {
11101126 find(mac, "0", false, false, true, true),
11111127 Some(Id::RemoveTags)
11121128 );
1113 assert_eq!(
1114 find(mac, "7", false, false, false, true),
1115 Some(Id::Tag(NoteTag::Contact))
1116 );
1129 assert_eq!(find(mac, "7", false, false, false, true), Some(Id::Tag(6)));
11171130 assert_eq!(find(mac, "q", false, false, false, true), None);
11181131 // The highlighter's chord runs the command that applies its last pick.
11191132 assert_eq!(
crates/snowbound/src/link.rs+48-40
......@@ -275,7 +275,7 @@ impl State {
275275 Ok(())
276276 }
277277
278 /// A secondary press on the page's text opens its context menu there.
278 /// A secondary press on the page's text or a file opens its context menu there.
279279 pub(crate) fn open_text_menu(&mut self) -> Result<(), Box<dyn Error>> {
280280 let Some((response, context)) = self.view.context()? else {
281281 return Ok(());
......@@ -288,7 +288,7 @@ impl State {
288288 }
289289
290290 /// The page's context menu while it is open, with OneNote 2010's commands for text,
291 /// links and equations.
291 /// links, equations and files.
292292 pub(crate) fn text_menu(&mut self) -> Result<(), Box<dyn Error>> {
293293 let Some((context, point)) = &self.text_menu else {
294294 return Ok(());
......@@ -301,48 +301,54 @@ impl State {
301301 text,
302302 ..Item::default()
303303 };
304 let mut items = vec![
305 Item {
306 disabled: !context.selected,
307 ..item("Cut")
308 },
309 Item {
310 disabled: !context.selected,
311 ..item("Copy")
312 },
313 item("Paste"),
314 ];
315 match &context.link {
316 Some(_) => items.extend([
317 Item {
318 separated: true,
319 ..item("Edit Link…")
320 },
321 item("Copy Link to Paragraph"),
322 Item {
323 separated: true,
324 ..item("Copy Link")
325 },
326 item("Select Link"),
327 item("Remove Link"),
328 ]),
329 None => items.extend([
304 let items = if context.attachment.is_some() {
305 // OneNote 2010's commands for the file itself; its clipboard holds text alone.
306 vec![item("Open"), item("Save As…")]
307 } else {
308 let mut items = vec![
330309 Item {
331 separated: true,
332 ..item("Link…")
310 disabled: !context.selected,
311 ..item("Cut")
333312 },
334 item("Copy Link to Paragraph"),
335 ]),
336 }
337 if context.equation {
338 items.extend([
339313 Item {
340 separated: true,
341 ..item("Professional")
314 disabled: !context.selected,
315 ..item("Copy")
342316 },
343 item("Linear"),
344 ]);
345 }
317 item("Paste"),
318 ];
319 match &context.link {
320 Some(_) => items.extend([
321 Item {
322 separated: true,
323 ..item("Edit Link…")
324 },
325 item("Copy Link to Paragraph"),
326 Item {
327 separated: true,
328 ..item("Copy Link")
329 },
330 item("Select Link"),
331 item("Remove Link"),
332 ]),
333 None => items.extend([
334 Item {
335 separated: true,
336 ..item("Link…")
337 },
338 item("Copy Link to Paragraph"),
339 ]),
340 }
341 if context.equation {
342 items.extend([
343 Item {
344 separated: true,
345 ..item("Professional")
346 },
347 item("Linear"),
348 ]);
349 }
350 items
351 };
346352 let Some(chosen) =
347353 ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None)
348354 else {
......@@ -352,6 +358,8 @@ impl State {
352358 return Ok(());
353359 };
354360 let response = match items[chosen].text {
361 "Open" => return self.open_attachment(&context.attachment.unwrap()),
362 "Save As…" => return self.save_attachment(&context.attachment.unwrap()),
355363 "Cut" => self.view.copy(true)?,
356364 "Copy" => self.view.copy(false)?,
357365 "Paste" => {
crates/snowbound/src/linux.rs+32-16
......@@ -593,6 +593,8 @@ pub fn install_menu() {}
593593/// The toolbar and keyboard stand in for a menu bar.
594594pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {}
595595
596pub fn update_tag_menu(_: &[canvas::editor::NoteTag]) {}
597
596598pub fn clear_marked_text(_: &Window) {}
597599
598600#[cfg(feature = "wgpu")]
......@@ -1056,24 +1058,23 @@ fn dialog<const Z: usize, const K: usize>(
10561058 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
10571059}
10581060
1059/// Asks for a picture to insert.
1060pub fn pick_picture() -> Option<PathBuf> {
1061 let patterns = crate::PICTURE_TYPES
1062 .map(|kind| format!("*.{kind}"))
1063 .join(" ");
1061/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`.
1062pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
1063 let patterns = match types {
1064 [] => "*".to_owned(),
1065 types => types
1066 .iter()
1067 .map(|kind| format!("*.{kind}"))
1068 .collect::<Vec<_>>()
1069 .join(" "),
1070 };
10641071 let asked = dialog(
10651072 [
10661073 "--file-selection",
1067 "--title=Insert Picture",
1068 &format!("--file-filter=Pictures | {patterns}"),
1069 ],
1070 [
1071 "--getopenfilename",
1072 ".",
1073 &patterns,
1074 "--title",
1075 "Insert Picture",
1074 &format!("--title={title}"),
1075 &format!("--file-filter={patterns}"),
10761076 ],
1077 ["--getopenfilename", ".", &patterns, "--title", title],
10771078 );
10781079 asked
10791080 .unwrap_or_else(|error| {
......@@ -1084,6 +1085,21 @@ pub fn pick_picture() -> Option<PathBuf> {
10841085 .map(PathBuf::from)
10851086}
10861087
1088/// No icon theme lookup; the page draws a blank page for the file.
1089pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {
1090 None
1091}
1092
1093/// winit reports drops without a position; the file goes to the caret.
1094pub fn drop_point(_: &Window) -> Option<[f32; 2]> {
1095 None
1096}
1097
1098/// Opens a copy of an attachment with the desktop's application for it.
1099pub fn open_file(path: &std::path::Path) {
1100 reveal(path);
1101}
1102
10871103/// Opens GNOME's character map, whose picks the user copies in.
10881104pub fn character_palette() {
10891105 if let Err(error) = Command::new("gnome-characters").spawn() {
......@@ -1151,8 +1167,8 @@ pub fn pick_notebook(title: &str) -> Option<PathBuf> {
11511167 .map(PathBuf::from)
11521168}
11531169
1154/// Asks where to create something named `name` by default.
1155pub fn pick_new(title: &str, name: &str) -> Option<PathBuf> {
1170/// Asks where to put something named `name` by default; the dialog names its own button.
1171pub fn pick_new(title: &str, name: &str, _action: &str) -> Option<PathBuf> {
11561172 let asked = dialog(
11571173 [
11581174 "--file-selection",
crates/snowbound/src/macos.rs+108-9
......@@ -11,7 +11,7 @@ use objc2::{
1111use objc2_app_kit::{
1212 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,
1313 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType,
14 NSMenuItem,
14 NSMenu, NSMenuItem,
1515};
1616use objc2_foundation::{
1717 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
......@@ -38,6 +38,7 @@ static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
3838thread_local! {
3939 static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) };
4040 static STATUSES: RefCell<Vec<commands::Status>> = const { RefCell::new(Vec::new()) };
41 static TAGS_MENU: RefCell<Option<Retained<NSMenu>>> = const { RefCell::new(None) };
4142}
4243
4344unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) {
......@@ -427,14 +428,14 @@ pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {
427428 }
428429}
429430
430/// Asks where to create something named `name` by default, with the system's save panel.
431pub fn pick_new(title: &str, name: &str) -> Option<std::path::PathBuf> {
431/// Asks where to `action` something named `name` by default, with the system's save panel.
432pub fn pick_new(title: &str, name: &str, action: &str) -> Option<std::path::PathBuf> {
432433 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
433434 unsafe {
434435 let panel = objc2_app_kit::NSSavePanel::savePanel(mtm);
435436 panel.setCanCreateDirectories(true);
436437 panel.setTitle(Some(&NSString::from_str(title)));
437 panel.setPrompt(Some(&NSString::from_str("Create")));
438 panel.setPrompt(Some(&NSString::from_str(action)));
438439 panel.setNameFieldStringValue(&NSString::from_str(name));
439440 if panel.runModal() != objc2_app_kit::NSModalResponseOK {
440441 return None;
......@@ -443,14 +444,18 @@ pub fn pick_new(title: &str, name: &str) -> Option<std::path::PathBuf> {
443444 }
444445}
445446
446/// Asks for a picture to insert, with the system's open panel.
447pub fn pick_picture() -> Option<std::path::PathBuf> {
447/// Asks for a file to insert, one of `types` (extensions) unless empty, with the system's
448/// open panel titled `title`.
449pub fn pick_file(title: &str, types: &[&str]) -> Option<std::path::PathBuf> {
448450 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
449451 unsafe {
450452 let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm);
451 let types = crate::PICTURE_TYPES.map(NSString::from_str);
452 #[allow(deprecated, reason = "Allowed content types need macOS 11")]
453 panel.setAllowedFileTypes(Some(&objc2_foundation::NSArray::from_vec(types.into())));
453 if !types.is_empty() {
454 let types = types.iter().map(|kind| NSString::from_str(kind)).collect();
455 #[allow(deprecated, reason = "Allowed content types need macOS 11")]
456 panel.setAllowedFileTypes(Some(&objc2_foundation::NSArray::from_vec(types)));
457 }
458 panel.setTitle(Some(&NSString::from_str(title)));
454459 panel.setPrompt(Some(&NSString::from_str("Insert")));
455460 if panel.runModal() != objc2_app_kit::NSModalResponseOK {
456461 return None;
......@@ -460,6 +465,88 @@ pub fn pick_picture() -> Option<std::path::PathBuf> {
460465 }
461466}
462467
468/// The icon Finder shows for the file at `path`, as a 32 pixel PNG like the one OneNote
469/// 2010 stores with an attachment.
470pub fn file_icon(path: &std::path::Path) -> Option<Vec<u8>> {
471 MainThreadMarker::new().expect("AppKit draws on the main thread");
472 let class = |name| AnyClass::get(name).expect("AppKit is linked");
473 unsafe {
474 let workspace: Retained<AnyObject> = msg_send_id![class("NSWorkspace"), sharedWorkspace];
475 let icon: Retained<AnyObject> =
476 msg_send_id![&workspace, iconForFile: &*NSString::from_str(path.to_str()?)];
477 let bitmap: Allocated<AnyObject> = msg_send_id![class("NSBitmapImageRep"), alloc];
478 let bitmap: Option<Retained<AnyObject>> = msg_send_id![
479 bitmap,
480 initWithBitmapDataPlanes: std::ptr::null_mut::<*mut u8>(),
481 pixelsWide: 32isize,
482 pixelsHigh: 32isize,
483 bitsPerSample: 8isize,
484 samplesPerPixel: 4isize,
485 hasAlpha: true,
486 isPlanar: false,
487 colorSpaceName: &*NSString::from_str("NSDeviceRGBColorSpace"),
488 bytesPerRow: 0isize,
489 bitsPerPixel: 0isize
490 ];
491 let bitmap = bitmap?;
492 let graphics = class("NSGraphicsContext");
493 let context: Option<Retained<AnyObject>> =
494 msg_send_id![graphics, graphicsContextWithBitmapImageRep: &*bitmap];
495 let _: () = msg_send![graphics, saveGraphicsState];
496 let _: () = msg_send![graphics, setCurrentContext: context.as_deref()];
497 let rect = NSRect::new(
498 NSPoint::new(0.0, 0.0),
499 objc2_foundation::NSSize::new(32.0, 32.0),
500 );
501 // NSCompositingOperationSourceOver.
502 let _: () = msg_send![&icon, drawInRect: rect, fromRect: NSRect::ZERO, operation: 2usize, fraction: 1.0f64];
503 let _: () = msg_send![graphics, restoreGraphicsState];
504 let properties: Retained<AnyObject> = msg_send_id![class("NSDictionary"), dictionary];
505 // NSBitmapImageFileTypePNG.
506 let data: Option<Retained<AnyObject>> =
507 msg_send_id![&bitmap, representationUsingType: 4usize, properties: &*properties];
508 let data = data?;
509 let length: usize = msg_send![&data, length];
510 let bytes: *const std::ffi::c_void = msg_send![&data, bytes];
511 Some(std::slice::from_raw_parts(bytes.cast::<u8>(), length).to_vec())
512 }
513}
514
515/// Where the pointer is in the window, in points from the content's top left corner, for a
516/// drop, which AppKit reports without a position.
517pub fn drop_point(window: &Window) -> Option<[f32; 2]> {
518 let window = ns_window(window);
519 unsafe {
520 let point: NSPoint = msg_send![&window, mouseLocationOutsideOfEventStream];
521 let content: Retained<AnyObject> = msg_send_id![&window, contentView];
522 let frame: NSRect = msg_send![&content, frame];
523 Some([point.x as f32, (frame.size.height - point.y) as f32])
524 }
525}
526
527/// Opens a copy of an attachment with its application, quarantined as a download is, so
528/// Gatekeeper checks it where OneNote 2010 warns before opening one.
529pub fn open_file(path: &std::path::Path) {
530 use std::os::unix::ffi::OsStrExt;
531 let seconds = std::time::SystemTime::now()
532 .duration_since(std::time::UNIX_EPOCH)
533 .map_or(0, |since| since.as_secs());
534 let value = format!("0081;{seconds:08x};Snowbound;");
535 if let Ok(file) = std::ffi::CString::new(path.as_os_str().as_bytes()) {
536 unsafe {
537 libc::setxattr(
538 file.as_ptr(),
539 c"com.apple.quarantine".as_ptr(),
540 value.as_ptr().cast(),
541 value.len(),
542 0,
543 0,
544 );
545 }
546 }
547 reveal(path);
548}
549
463550/// Lets the user drag out part of the screen, then sends it as a PNG.
464551pub fn clip_screen(proxy: EventLoopProxy<crate::UserEvent>) {
465552 std::thread::spawn(move || {
......@@ -910,6 +997,18 @@ pub fn install_menu() {
910997 app.setWindowsMenu(Some(&menus.window));
911998 app.setHelpMenu(Some(&menus.help));
912999 }
1000 TAGS_MENU.set(Some(menus.tags));
1001}
1002
1003/// Lists `tags` in the menu bar's Tags menu.
1004pub fn update_tag_menu(tags: &[canvas::editor::NoteTag]) {
1005 let mtm = MainThreadMarker::new().expect("Menus belong to the main thread");
1006 let app = NSApplication::sharedApplication(mtm);
1007 TAGS_MENU.with_borrow(|menu| {
1008 if let Some(menu) = menu {
1009 crate::menubar::tags(mtm, &app, menu, tags);
1010 }
1011 });
9131012}
9141013
9151014/// The table's statuses, in `commands::choices` order, which the menu bar's items show.
crates/snowbound/src/main.rs+120-33
......@@ -1,6 +1,7 @@
11#[cfg(target_os = "macos")]
22mod aqua;
33mod art;
4mod attachment;
45mod background;
56mod commands;
67#[cfg(test)]
......@@ -28,6 +29,7 @@ mod sidebar;
2829#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]
2930mod surface;
3031mod sync;
32mod tags;
3133mod templates;
3234
3335use canvas::gpu::page::PageScene;
......@@ -524,6 +526,10 @@ struct State {
524526 options: Option<options::Options>,
525527 /// The Link dialog's fields while it is open.
526528 link: Option<link::LinkDialog>,
529 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
530 tags: Vec<canvas::editor::NoteTag>,
531 /// The Customize Tags dialog's list while it is open.
532 tag_list: Option<tags::TagList>,
527533 /// The page's context menu while it is open: what it was opened on, and where.
528534 text_menu: Option<(canvas::interaction::Context, [f32; 2])>,
529535 color_scheme: settings::ColorScheme,
......@@ -815,6 +821,10 @@ impl State {
815821 title_focus: None,
816822 menu: None,
817823 link: None,
824 tags: stored
825 .tags
826 .unwrap_or_else(canvas::editor::NoteTag::defaults),
827 tag_list: None,
818828 text_menu: None,
819829 options: None,
820830 color_scheme: stored.color_scheme,
......@@ -860,6 +870,7 @@ impl State {
860870 accessibility: accessibility::Accessibility::default(),
861871 };
862872 state.title();
873 platform::update_tag_menu(&state.tags);
863874 Ok(state)
864875 }
865876
......@@ -930,6 +941,7 @@ impl State {
930941 let (section, open_tab, open_page) = self.build()?;
931942 self.options_dialog();
932943 self.link_dialog()?;
944 self.customize_tags();
933945 self.palette();
934946 self.sync_popup()?;
935947 self.text_menu()?;
......@@ -1468,21 +1480,16 @@ impl State {
14681480 /// taking notes fold last, and those with chords or a place in the menu bar first.
14691481 fn tools(&mut self, theme: &Theme) {
14701482 use Entry::{Open, Rule, Run, Soon};
1471 use canvas::editor::{
1472 Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
1473 };
1483 use canvas::editor::{Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, Toggle};
14741484 use commands::{Choice, Id as Cmd};
14751485 use ui::shell::TOOL;
14761486 // A focused picture has no text to show a format for.
14771487 let state = self.format_state();
14781488 let statuses: Vec<_> = commands::COMMANDS
14791489 .iter()
1480 .map(|command| {
1481 (
1482 command.id,
1483 self.status(&Choice::Command(command.id), &state),
1484 )
1485 })
1490 .map(|command| command.id)
1491 .chain((0..self.tags.len()).map(Cmd::Tag))
1492 .map(|id| (id, self.status(&Choice::Command(id), &state)))
14861493 .collect();
14871494 let status_of = |id| {
14881495 statuses
......@@ -1499,7 +1506,7 @@ impl State {
14991506 let label = format!("{:.0}%", self.view.zoom() * 100.0);
15001507 let ui = &mut self.ui;
15011508 let text = theme.text;
1502 let tags = NoteTag::ALL.map(Cmd::Tag);
1509 let tags: Vec<_> = self.tags.iter().enumerate().collect();
15031510 let mut choice = group(
15041511 ui,
15051512 "navigate",
......@@ -1890,11 +1897,13 @@ impl State {
18901897 )
18911898 .or(choice);
18921899 // The gallery's menu: every tag, then managing them.
1893 let mut all_tags: Vec<_> = tags.into_iter().map(Run).collect();
1900 let mut all_tags: Vec<_> = tags
1901 .iter()
1902 .map(|&(place, tag)| Entry::Tag(place, tag))
1903 .collect();
18941904 all_tags.extend([
18951905 Rule,
1896 // Customize Tags awaits #8.
1897 Soon("Customize Tags…", None),
1906 Run(Cmd::CustomizeTags),
18981907 Run(Cmd::RemoveTags),
18991908 Rule,
19001909 Run(Cmd::FindTags),
......@@ -1906,12 +1915,18 @@ impl State {
19061915 |ui| {
19071916 divider(ui, theme);
19081917 let mut choice = None;
1909 for id in &tags[..3] {
1910 choice = tool(ui, *id, status_of(*id)).or(choice);
1918 for &(place, tag) in tags.iter().take(3) {
1919 choice = tag_tool(ui, place, tag, status_of(Cmd::Tag(place))).or(choice);
19111920 }
19121921 dropdown(ui, "tags", Head::More, &all_tags, status_of).or(choice)
19131922 },
1914 |ui| dropdown(ui, "menu", Head::Split(tags[0]), &all_tags, status_of),
1923 |ui| {
1924 let head = match tags.first() {
1925 Some(&(place, tag)) => Head::Tag(place, tag),
1926 None => Head::More,
1927 };
1928 dropdown(ui, "menu", head, &all_tags, status_of)
1929 },
19151930 )
19161931 .or(choice);
19171932 let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation];
......@@ -2342,6 +2357,7 @@ impl State {
23422357 self.respond(response);
23432358 }
23442359 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
2360 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
23452361 Command::Choose(choice) => self.run(choice)?,
23462362 }
23472363 Ok(())
......@@ -3789,8 +3805,10 @@ fn group(
37893805
37903806/// A row of a toolbar menu.
37913807#[derive(Clone, Copy)]
3792enum Entry {
3808enum Entry<'a> {
37933809 Run(commands::Id),
3810 /// Applies the tag at this place in the tag list.
3811 Tag(usize, &'a canvas::editor::NoteTag),
37943812 /// Opens the toolbar's popup `name`, which applies the command, under the button the
37953813 /// popup's own control would open it from, or where that is folded away, this menu's.
37963814 Open(&'static str, commands::Id),
......@@ -3802,8 +3820,10 @@ enum Entry {
38023820
38033821/// What opens a toolbar menu: the arrow of a button running a command, a button showing
38043822/// artwork, or a bare arrow.
3805enum Head {
3823enum Head<'a> {
38063824 Split(commands::Id),
3825 /// A button applying the tag at this place in the tag list.
3826 Tag(usize, &'a canvas::editor::NoteTag),
38073827 Menu(&'static [&'static str]),
38083828 More,
38093829}
......@@ -3831,8 +3851,17 @@ fn dropdown(
38313851 tip(ui, id);
38323852 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
38333853 }
3854 Head::Tag(place, tag) if status_of(commands::Id::Tag(place)).enabled => {
3855 let on = status_of(commands::Id::Tag(place)).checked;
3856 if ui::shell::split_button(ui, part, tag_art(tag), None, on, menu).clicked {
3857 choice = Some(Choice::Command(commands::Id::Tag(place)));
3858 }
3859 tag_tip(ui, place, tag);
3860 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
3861 }
38343862 // Where its command does not apply, the button only opens the menu.
38353863 Head::Split(id) => ui::shell::menu_button(ui, part, artwork(id).unwrap_or_default(), menu),
3864 Head::Tag(_, tag) => ui::shell::menu_button(ui, part, tag_art(tag), menu),
38363865 Head::Menu(icon) => ui::shell::menu_button(ui, part, icon, menu),
38373866 Head::More => {
38383867 let open = ui.popup_open(menu);
......@@ -3847,6 +3876,7 @@ fn dropdown(
38473876 .iter()
38483877 .map(|entry| match entry {
38493878 Entry::Run(id) => commands::shortcut(*id),
3879 Entry::Tag(place, _) => commands::shortcut(commands::Id::Tag(*place)),
38503880 _ => String::new(),
38513881 })
38523882 .collect();
......@@ -3861,12 +3891,22 @@ fn dropdown(
38613891 Entry::Run(id) => ui::popup::Item {
38623892 text: commands::command(id).title,
38633893 icon: artwork(id),
3864 colored: matches!(id, commands::Id::Tag(_)),
38653894 shortcut: key,
38663895 checked: status_of(id).checked,
38673896 disabled: !status_of(id).enabled,
38683897 ..Default::default()
38693898 },
3899 Entry::Tag(place, tag) => ui::popup::Item {
3900 text: &tag.label,
3901 icon: TagIcon::of(tag.shape, false).map(tag_sources),
3902 ink: tag.color.map(colorref),
3903 highlight: tag.highlight.map(colorref),
3904 colored: true,
3905 shortcut: key,
3906 checked: status_of(commands::Id::Tag(place)).checked,
3907 disabled: !status_of(commands::Id::Tag(place)).enabled,
3908 ..Default::default()
3909 },
38703910 Entry::Open(_, id) => ui::popup::Item {
38713911 text: commands::command(id).title,
38723912 icon: artwork(id),
......@@ -3889,6 +3929,7 @@ fn dropdown(
38893929 }
38903930 match ui::popup::menu(ui, menu, anchor, &items, None).map(|index| actions[index]) {
38913931 Some(Entry::Run(id)) => Some(Choice::Command(id)),
3932 Some(Entry::Tag(place, _)) => Some(Choice::Command(commands::Id::Tag(place))),
38923933 Some(Entry::Open(name, _)) => {
38933934 ui.open_popup(toolbar_popup(name));
38943935 None
......@@ -3904,12 +3945,7 @@ fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<comma
39043945 .iter()
39053946 .position(|command| command.id == id);
39063947 let icon = artwork(id).unwrap_or_default();
3907 // Artwork in its own colours takes white.
3908 let tint = if matches!(id, commands::Id::Tag(_)) {
3909 [1.0; 4]
3910 } else {
3911 ui.theme.text
3912 };
3948 let tint = ui.theme.text;
39133949 if !status.enabled {
39143950 ui::shell::unavailable(ui, part, icon, tint, false);
39153951 return None;
......@@ -3919,6 +3955,25 @@ fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<comma
39193955 clicked.then_some(commands::Choice::Command(id))
39203956}
39213957
3958/// A tool button applying the tag at `place` in the tag list, as `tool` builds a command's.
3959fn tag_tool(
3960 ui: &mut Ui,
3961 place: usize,
3962 tag: &canvas::editor::NoteTag,
3963 status: commands::Status,
3964) -> Option<commands::Choice> {
3965 let part = ("tag", place);
3966 // Artwork in its own colours takes white.
3967 let tint = [1.0; 4];
3968 if !status.enabled {
3969 ui::shell::unavailable(ui, part, tag_art(tag), tint, false);
3970 return None;
3971 }
3972 let clicked = ui::shell::tool_button(ui, part, tag_art(tag), tint, status.checked).clicked;
3973 tag_tip(ui, place, tag);
3974 clicked.then_some(commands::Choice::Command(commands::Id::Tag(place)))
3975}
3976
39223977/// The artwork the toolbar and its menus show for command `id`.
39233978fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {
39243979 use canvas::editor::{Alignment, Toggle};
......@@ -3948,7 +4003,6 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {
39484003 Cmd::Align(Alignment::Right) => art::ALIGN_RIGHT,
39494004 Cmd::Outdent => art::OUTDENT,
39504005 Cmd::Indent => art::INDENT,
3951 Cmd::Tag(tag) => tag_icon(tag),
39524006 Cmd::FindTags => art::FIND_TAGS,
39534007 Cmd::Table => art::TABLE,
39544008 Cmd::Picture => art::PICTURE,
......@@ -3970,14 +4024,36 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {
39704024 })
39714025}
39724026
3973/// The artwork the toolbar shows for `tag`.
3974fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] {
3975 use canvas::editor::NoteTag;
3976 match tag {
3977 NoteTag::RememberForLater => art::TAG_REMEMBER,
3978 NoteTag::Definition => art::TAG_DEFINITION,
3979 tag => tag_sources(TagIcon::of(tag.shape(), false).unwrap_or(TagIcon::Other)),
4027/// The artwork the toolbar shows for `tag`: its symbol, or for a tag that only colours
4028/// text, a chip of lines in its font colour on its highlight.
4029fn tag_art(tag: &canvas::editor::NoteTag) -> &'static [&'static str] {
4030 use std::collections::BTreeMap;
4031 use std::sync::Mutex;
4032 // Artwork is borrowed for the program's life, so each colour pair is made once.
4033 type Chips = BTreeMap<(Option<u32>, Option<u32>), &'static [&'static str]>;
4034 static CHIPS: Mutex<Chips> = Mutex::new(BTreeMap::new());
4035 if let Some(icon) = TagIcon::of(tag.shape, false) {
4036 return tag_sources(icon);
39804037 }
4038 let hex = |colorref: u32| {
4039 let [red, green, blue, _] = colorref.to_le_bytes();
4040 format!("#{red:02x}{green:02x}{blue:02x}")
4041 };
4042 let mut chips = CHIPS
4043 .lock()
4044 .unwrap_or_else(|poisoned| poisoned.into_inner());
4045 chips.entry((tag.color, tag.highlight)).or_insert_with(|| {
4046 let ink = tag.color.map_or_else(|| "#000000".into(), hex);
4047 let fill = tag.highlight.map_or_else(|| "#ffffff".into(), hex);
4048 let svg = format!(
4049 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
4050 <path d="M2 3H14A1 1 0 0 1 15 4V12A1 1 0 0 1 14 13H2A1 1 0 0 1 1 12V4A1 1 0 0 1 2 3Z" fill="#000000" fill-opacity="0.35"/>
4051 <path d="M2.25 4H13.75A0.25 0.25 0 0 1 14 4.25V11.75A0.25 0.25 0 0 1 13.75 12H2.25A0.25 0.25 0 0 1 2 11.75V4.25A0.25 0.25 0 0 1 2.25 4Z" fill="{fill}"/>
4052 <path d="M3.5 6H12.5V7.25H3.5ZM3.5 8.75H10V10H3.5Z" fill="{ink}"/>
4053</svg>"##
4054 );
4055 Box::leak(Box::new([&*svg.leak()]))
4056 })
39814057}
39824058
39834059/// The toolbar's popup `name`, which the menu bar opens too.
......@@ -3985,6 +4061,12 @@ fn toolbar_popup(name: &str) -> Id {
39854061 Id::ROOT.child(("popup", name))
39864062}
39874063
4064/// A tooltip naming the tag at `place` and its chord on the box built last.
4065fn tag_tip(ui: &mut Ui, place: usize, tag: &canvas::editor::NoteTag) {
4066 let key = commands::shortcut(commands::Id::Tag(place));
4067 ui::popup::tooltip(ui, &tag.label, &key, None);
4068}
4069
39884070/// A tooltip naming command `id` and its chord on the box built last. It names the command,
39894071/// not a dialog it opens, so it leaves off the menu's ellipsis.
39904072fn tip(ui: &mut Ui, id: commands::Id) {
......@@ -4344,6 +4426,11 @@ impl ApplicationHandler<UserEvent> for App {
43444426 })
43454427 }
43464428 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),
4429 WindowEvent::DroppedFile(path) => {
4430 let at = platform::drop_point(&state.window);
4431 state.attach(&path, at)?;
4432 state.window.request_redraw();
4433 }
43474434 WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?,
43484435 // Smart zoom: to 200% about the fingers, or back to 100%.
43494436 WindowEvent::DoubleTapGesture { .. } => {
crates/snowbound/src/manage.rs+81-68
......@@ -7,7 +7,7 @@ use notebook::session::Notebook;
77use onestore::{
88 ExGuid, PageCreation, PageEdit,
99 op::{Edit, Op, PageOp, SectionOp},
10 page::{Image, Page, PageObject},
10 page::{Page, PageObject},
1111};
1212use std::{error::Error, sync::Arc};
1313
......@@ -134,26 +134,19 @@ impl State {
134134 self.edit_page(ops)
135135 }
136136
137 /// Gives the open page `template`'s art, or none, in place of the art it had; its colour
138 /// and rule lines stay.
139 pub(crate) fn art_page(
140 &mut self,
141 template: Option<&'static Template>,
142 ) -> Result<(), Box<dyn Error>> {
143 self.persist()?;
144 let session = self.session.as_ref().ok_or("No section is open")?;
145 let ops = art_ops(&session.section.page(session.space)?, template)?;
146 self.edit_page(ops)
147 }
148
149 /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, and
150 /// `rule_lines` as one undo step; its art stays.
137 /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF,
138 /// `rule_lines` and, when given, `template`'s art or none in place of the art it had, as
139 /// one undo step.
151140 pub(crate) fn paper_page(
152141 &mut self,
153142 color: Option<u32>,
154143 rule_lines: Option<onestore::page::RuleLines>,
144 art: Option<Option<&Template>>,
155145 ) -> Result<(), Box<dyn Error>> {
156 let response = self.view.set_paper(color, rule_lines)?;
146 let art = art
147 .map(|template| template.map_or(Ok(Vec::new()), Template::pictures))
148 .transpose()?;
149 let response = self.view.set_paper(color, rule_lines, art)?;
157150 self.respond(response);
158151 Ok(())
159152 }
......@@ -177,7 +170,7 @@ impl State {
177170
178171 /// Asks where to create a notebook and creates it, as File, New does in OneNote.
179172 pub(crate) fn new_notebook(&mut self) -> Result<(), Box<dyn Error>> {
180 let Some(root) = platform::pick_new("New Notebook", "My Notebook") else {
173 let Some(root) = platform::pick_new("New Notebook", "My Notebook", "Create") else {
181174 return Ok(());
182175 };
183176 let page = self.dated_page(None)?;
......@@ -503,25 +496,9 @@ pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>,
503496 _ => true,
504497 })
505498 .map(PageObject::id);
506 for art in template.map_or(&[][..], |template| template.art) {
507 let bytes =
508 canvas::gpu::page::template_picture(art.art).ok_or("That template's art is missing")?;
499 for image in template.map_or(Ok(Vec::new()), Template::pictures)? {
509500 ops.push(PageOp::Add {
510 object: PageObject::Image(Image {
511 id: onestore::page::text::new_id()?,
512 layout: onestore::document::Layout {
513 x: Some(art.position[0]),
514 y: Some(art.position[1]),
515 max_width: art.size.map(|size| size[0]),
516 max_height: art.size.map(|size| size[1]),
517 width_set_by_user: art.size.map(|_| true),
518 ..Default::default()
519 },
520 size: art.size,
521 bytes: Some(bytes.into()),
522 alt: None,
523 background: true,
524 }),
501 object: PageObject::Image(image),
525502 before: under,
526503 });
527504 }
......@@ -873,9 +850,10 @@ mod tests {
873850 }
874851
875852 /// Backgrounds the Page Color menu gives OneNote's ruled pages
876 /// (`corpus/rule-lines/native`): art on a page with rule lines, keeping them, art taken
877 /// away, and a colour outside OneNote's menu. `SNOWBOUND_BACKGROUND_EXPORT` names a new
878 /// directory that receives the notebook for a cold reopen in OneNote 2010.
853 /// (`corpus/rule-lines/native`) through the editor, each step one edit: art on a page
854 /// with rule lines, keeping them; art taken away, undone, redone and replaced; a colour
855 /// outside OneNote's menu; and art on a coloured page. `SNOWBOUND_BACKGROUND_EXPORT`
856 /// names a new directory that receives the notebook for a cold reopen in OneNote 2010.
879857 #[test]
880858 fn backgrounds_go_on_and_off_pages_that_have_content() {
881859 let temporary =
......@@ -896,38 +874,73 @@ mod tests {
896874 .unwrap()
897875 .0
898876 };
899 let on_page = |space, ops: Vec<PageOp>| {
900 edit(
901 &file,
902 ops.into_iter().map(|op| Op::Page { space, op }).collect(),
903 )
877 enum Step {
878 Art(Option<&'static str>),
879 Color(u32),
880 Undo,
881 Redo,
882 }
883 use Step::*;
884 let mut engine = canvas::layout::TextEngine::default();
885 let mut run = |title: &str, steps: &[Step]| {
886 let space = titled(title);
887 let mut editor =
888 canvas::editor::CanvasEditor::from_page(page(&file, space), &mut engine).unwrap();
889 for step in steps {
890 let (color, lines) = (editor.page_color(), editor.rule_lines());
891 let changed = match step {
892 Art(name) => {
893 let art = name.map_or(Ok(Vec::new()), |name| {
894 canvas::template::find(name).unwrap().pictures()
895 });
896 editor.set_paper(color, lines, Some(art.unwrap()))
897 }
898 Color(color) => editor.set_paper(Some(*color), lines, None),
899 Undo => editor.undo(&mut engine).unwrap(),
900 Redo => editor.redo(&mut engine).unwrap(),
901 };
902 assert!(changed);
903 let ops = editor.take_ops().unwrap();
904 assert!(!ops.is_empty());
905 edit(
906 &file,
907 ops.into_iter().map(|op| Op::Page { space, op }).collect(),
908 );
909 }
910 page(&file, space)
904911 };
905 let ivy = canvas::template::find("Ivy");
906 let (standard, wide, grid) = (titled("Standard"), titled("Wide"), titled("SmallGrid"));
907 on_page(standard, art_ops(&page(&file, standard), ivy).unwrap());
908 on_page(wide, art_ops(&page(&file, wide), ivy).unwrap());
909 on_page(wide, art_ops(&page(&file, wide), None).unwrap());
910 // A warm grey no menu offers.
911 on_page(grid, vec![PageOp::Color(Some(0x00c8d8e8))]);
912
913 let standard = page(&file, standard);
914 assert!(standard.rule_lines.is_some());
915 assert_eq!(
916 standard
917 .objects
912 let lemon = canvas::template::PAGE_COLORS[2];
913 assert_eq!(lemon.0, "Lemon");
914 let pages = [
915 run("Standard", &[Art(Some("Ivy"))]),
916 run("Wide", &[Art(Some("Ivy")), Art(None)]),
917 // A warm grey no menu offers.
918 run("SmallGrid", &[Color(0x00c8d8e8)]),
919 run("College", &[Art(Some("Ivy")), Undo]),
920 run("MediumGrid", &[Art(Some("Ivy")), Undo, Redo]),
921 run("LargeGrid", &[Art(Some("Ivy")), Art(Some("Tulips")), Undo]),
922 run("VeryLargeGrid", &[Color(lemon.1), Art(Some("Sparks"))]),
923 ];
924 let art = |page: &Page| -> Vec<f32> {
925 page.objects
918926 .iter()
919 .filter(|object| matches!(object, PageObject::Image(image) if image.background))
920 .count(),
921 ivy.unwrap().art.len()
922 );
923 let wide = page(&file, wide);
924 assert!(
925 !wide
926 .objects
927 .iter()
928 .any(|object| matches!(object, PageObject::Image(_)))
929 );
930 assert_eq!(page(&file, grid).color, Some(0x00c8d8e8));
927 .filter_map(|object| match object {
928 PageObject::Image(image) if image.background => image.layout.max_width,
929 _ => None,
930 })
931 .collect()
932 };
933 let [standard, wide, grid, college, medium, large, very_large] = &pages;
934 let [ivy, sparks] = ["Ivy", "Sparks"]
935 .map(|name| canvas::template::find(name).unwrap().art[0].size.unwrap()[0]);
936 assert_eq!(art(standard), [ivy]);
937 assert!(pages.iter().all(|page| page.rule_lines.is_some()));
938 assert!(art(wide).is_empty() && art(college).is_empty());
939 assert_eq!(grid.color, Some(0x00c8d8e8));
940 assert_eq!(art(medium), [ivy]);
941 assert_eq!(art(large), [ivy]);
942 assert_eq!(art(very_large), [sparks]);
943 assert_eq!(very_large.color, Some(lemon.1));
931944 if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") {
932945 copy(&temporary, Path::new(&directory));
933946 }
crates/snowbound/src/menubar.rs+37-42
......@@ -156,48 +156,8 @@ const MENUS: &[Item] = &[
156156 C(Id::ClearFormatting),
157157 ],
158158 ),
159 Item::Menu(
160 "Tags",
161 &[
162 C(Id::Tag(NoteTag::ToDo)),
163 C(Id::Tag(NoteTag::Important)),
164 C(Id::Tag(NoteTag::Question)),
165 C(Id::Tag(NoteTag::RememberForLater)),
166 C(Id::Tag(NoteTag::Definition)),
167 C(Id::Tag(NoteTag::Highlight)),
168 C(Id::Tag(NoteTag::Contact)),
169 C(Id::Tag(NoteTag::Address)),
170 C(Id::Tag(NoteTag::PhoneNumber)),
171 Item::Menu(
172 "More Tags",
173 &[
174 C(Id::Tag(NoteTag::WebSiteToVisit)),
175 C(Id::Tag(NoteTag::Idea)),
176 C(Id::Tag(NoteTag::Password)),
177 C(Id::Tag(NoteTag::Critical)),
178 C(Id::Tag(NoteTag::ProjectA)),
179 C(Id::Tag(NoteTag::ProjectB)),
180 C(Id::Tag(NoteTag::MovieToSee)),
181 C(Id::Tag(NoteTag::BookToRead)),
182 C(Id::Tag(NoteTag::MusicToListenTo)),
183 C(Id::Tag(NoteTag::SourceForArticle)),
184 C(Id::Tag(NoteTag::RememberForBlog)),
185 C(Id::Tag(NoteTag::DiscussWithPersonA)),
186 C(Id::Tag(NoteTag::DiscussWithPersonB)),
187 C(Id::Tag(NoteTag::DiscussWithManager)),
188 C(Id::Tag(NoteTag::SendInEmail)),
189 C(Id::Tag(NoteTag::ScheduleMeeting)),
190 C(Id::Tag(NoteTag::CallBack)),
191 C(Id::Tag(NoteTag::ToDoPriority1)),
192 C(Id::Tag(NoteTag::ToDoPriority2)),
193 C(Id::Tag(NoteTag::ClientRequest)),
194 ],
195 ),
196 S,
197 C(Id::RemoveTags),
198 C(Id::FindTags),
199 ],
200 ),
159 // Filled from the tag list by `tags`.
160 Item::Menu("Tags", &[]),
201161 Item::Menu(
202162 "Window",
203163 &[
......@@ -216,6 +176,7 @@ pub struct Menus {
216176 pub services: Retained<NSMenu>,
217177 pub window: Retained<NSMenu>,
218178 pub help: Retained<NSMenu>,
179 pub tags: Retained<NSMenu>,
219180}
220181
221182/// 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 {
241202 .expect("The bar has the menus AppKit fills")
242203 };
243204 Menus {
205 tags: named("Tags"),
244206 window: named("Window"),
245207 help: named("Help"),
246208 services,
......@@ -302,6 +264,38 @@ fn menu(
302264 menu
303265}
304266
267/// Fills the Tags menu from the user's tag list, as OneNote's gallery lists it: the first
268/// nine with their chords, the rest under More Tags, then managing them.
269pub fn tags(mtm: MainThreadMarker, target: &AnyObject, menu: &NSMenu, tags: &[NoteTag]) {
270 unsafe { menu.removeAllItems() };
271 let choose = |menu: &NSMenu, id: Id, title: &str, chord: Option<Chord>| {
272 let item = entry(mtm, title, Some(objc2::sel!(choose:)), chord);
273 unsafe {
274 item.setTarget(Some(target));
275 item.setTag(commands::index(&Choice::Command(id)) as isize);
276 }
277 menu.addItem(&item);
278 };
279 let more = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("More Tags")) };
280 for (place, tag) in tags.iter().enumerate().take(commands::TAGS) {
281 let chord = commands::tag_chord(place);
282 let shown = if chord.is_some() { menu } else { &*more };
283 choose(shown, Id::Tag(place), &tag.label, chord);
284 }
285 if tags.len() > 9 {
286 let holder = entry(mtm, "More Tags", None, None);
287 holder.setSubmenu(Some(&more));
288 menu.addItem(&holder);
289 }
290 if !tags.is_empty() {
291 menu.addItem(&NSMenuItem::separatorItem(mtm));
292 }
293 for id in [Id::CustomizeTags, Id::RemoveTags, Id::FindTags] {
294 let command = commands::command(id);
295 choose(menu, id, command.title, command.mac.first().copied());
296 }
297}
298
305299fn entry(
306300 mtm: MainThreadMarker,
307301 title: &str,
......@@ -418,6 +412,7 @@ mod tests {
418412 let target: Retained<AnyObject> =
419413 unsafe { objc2::msg_send_id![objc2::class!(NSObject), new] };
420414 let menus = build(mtm, &target);
415 tags(mtm, &target, &menus.tags, &NoteTag::defaults());
421416 let (mut tree, mut chords) = (String::new(), Vec::new());
422417 dump(&menus.bar, 0, &mut tree, &mut chords);
423418 println!("{tree}");
crates/snowbound/src/palette.rs+9
......@@ -44,6 +44,15 @@ impl State {
4444 target: Some(Target::Command(command.id)),
4545 })
4646 .collect();
47 rows.extend(self.tags.iter().enumerate().map(|(place, tag)| {
48 let id = commands::Id::Tag(place);
49 Row {
50 text: tag.label.clone(),
51 after: commands::shortcut(id),
52 disabled: !self.status(&Choice::Command(id), &format).enabled,
53 target: Some(Target::Command(id)),
54 }
55 }));
4756 let heading = |text: &str| Row {
4857 text: text.to_owned(),
4958 after: String::new(),
crates/snowbound/src/settings.rs+9
......@@ -24,6 +24,8 @@ pub struct Settings {
2424 pub light_pages: bool,
2525 /// Where searches look first, as "Set This Scope as Default" chose.
2626 pub search_scope: crate::search::Scope,
27 /// The tag list Customize Tags edits; none keeps OneNote's.
28 pub tags: Option<Vec<canvas::editor::NoteTag>>,
2729}
2830
2931/// What the toolbar's buttons apply from their menus' last picks.
......@@ -128,6 +130,7 @@ impl crate::State {
128130 color_scheme: self.color_scheme,
129131 light_pages: self.light_pages,
130132 search_scope: self.search.default,
133 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),
131134 };
132135 if let Err(error) = settings.save(path) {
133136 eprintln!("Cannot save the settings in {}: {error}", path.display());
......@@ -177,6 +180,12 @@ mod tests {
177180 color_scheme: ColorScheme::Dark,
178181 light_pages: true,
179182 search_scope: crate::search::Scope::Notebook,
183 tags: Some(vec![canvas::editor::NoteTag {
184 label: "Snow check".into(),
185 shape: 61,
186 color: Some(0x0000_0080),
187 highlight: Some(0x00ff_cc00),
188 }]),
180189 };
181190 settings.save(&path).unwrap();
182191 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sync.rs+10-29
......@@ -2,8 +2,8 @@
22//! Synchronization for the open section's notebook and each of its sections.
33
44use crate::{Session, State, art, filetime, library, platform};
5use notebook::session::SyncStatus;
6use std::{error::Error, io::ErrorKind};
5use notebook::session::{SyncState, SyncStatus};
6use std::error::Error;
77use ui::{
88 Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL,
99};
......@@ -20,7 +20,6 @@ fn button() -> Id {
2020
2121/// The status's label and icon, and what the reader can do about an error.
2222fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) {
23 use ErrorKind::*;
2423 if library::offline() {
2524 return (
2625 "Working offline",
......@@ -28,48 +27,30 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option
2827 Some("Changes stay on this computer until you sync."),
2928 );
3029 }
31 match sync.error.as_ref().map(std::io::Error::kind) {
32 Some(PermissionDenied | ReadOnlyFilesystem) => (
30 match sync.state() {
31 SyncState::ReadOnly => (
3332 "Unable to sync",
3433 art::SYNC_ERROR,
3534 Some(
3635 "You can’t change this notebook where it’s stored. Changes stay on this computer.",
3736 ),
3837 ),
39 Some(WouldBlock | ResourceBusy) => (
38 SyncState::InUse => (
4039 "Section in use",
4140 art::SYNC_BUSY,
4241 Some("Someone else is saving this section. Sync continues when they finish."),
4342 ),
44 Some(
45 NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected
46 | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe
47 | AddrNotAvailable,
48 ) => (
43 SyncState::NotConnected => (
4944 "Not connected",
5045 art::SYNC_OFFLINE,
5146 Some("Changes stay on this computer and sync when the notebook is back."),
5247 ),
53 Some(_) => ("Unable to sync", art::SYNC_ERROR, None),
54 None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", art::SYNC_BUSY, None),
55 None => ("Up to date", art::SYNC_DONE, None),
48 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None),
49 SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None),
50 SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None),
5651 }
5752}
5853
59/// Labels from the best state to the worst, which a notebook's status shows.
60const ORDER: [&str; 5] = [
61 "Up to date",
62 "Syncing…",
63 "Section in use",
64 "Not connected",
65 "Unable to sync",
66];
67
68fn rank(sync: &SyncStatus) -> usize {
69 let label = describe(sync).0;
70 ORDER.iter().position(|shown| *shown == label).unwrap_or(0)
71}
72
7354/// Every section of the open section's notebook with its status, in catalog order: the
7455/// open one's from its session, the others' from the notebook's background sync.
7556fn sections(session: &Session) -> Vec<(String, SyncStatus)> {
......@@ -102,7 +83,7 @@ fn overall(sections: &[(String, SyncStatus)]) -> SyncStatus {
10283 .iter()
10384 .map(|(_, sync)| sync)
10485 .filter(|sync| sync.error.is_some())
105 .max_by_key(|sync| rank(sync));
86 .max_by_key(|sync| sync.state());
10687 SyncStatus {
10788 synced: sections
10889 .iter()
crates/snowbound/src/tags.rs created+810
......@@ -0,0 +1,810 @@
1//! OneNote 2010's Customize Tags dialog and its New Tag and Modify Tag dialog, which edit
2//! the user's tag list: the tags the toolbar, the menus and Ctrl+1 to Ctrl+9 apply.
3
4use crate::{State, commands, platform};
5use canvas::editor::NoteTag;
6use canvas::gpu::{colorref, tag_sources};
7use canvas::outline::{TagIcon, symbol_name};
8use draw::edit::Platform;
9use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px};
10use winit::keyboard::NamedKey;
11
12const WIDTH: f32 = 300.0;
13const EDITOR_WIDTH: f32 = 280.0;
14const ROW: f32 = 20.0;
15/// Rows the list shows at once; the wheel scrolls the rest.
16const ROWS: f32 = 19.0;
17const SIDE: f32 = 16.0;
18const CELL: f32 = 23.0;
19
20/// OneNote 2010's symbol gallery: ten rows of five groups of three, 0 for None, which spans
21/// the first group's top row, and for the empty cells at the end.
22const GALLERY: [[u16; 15]; 10] = [
23 [
24 0, 0, 0, 40, 13, 61, 41, 82, 62, 106, 107, 108, 124, 125, 126,
25 ],
26 [3, 2, 1, 43, 84, 64, 34, 75, 54, 109, 18, 110, 127, 128, 27],
27 [6, 5, 4, 36, 77, 56, 46, 87, 67, 14, 15, 17, 129, 130, 131],
28 [9, 8, 7, 42, 83, 63, 47, 88, 68, 21, 24, 111, 132, 133, 134],
29 [
30 12, 11, 10, 39, 81, 60, 38, 79, 58, 112, 22, 23, 135, 136, 137,
31 ],
32 [
33 28, 69, 48, 29, 70, 49, 16, 80, 59, 113, 25, 26, 138, 139, 140,
34 ],
35 [
36 30, 71, 50, 31, 72, 51, 37, 78, 57, 114, 115, 116, 141, 142, 143,
37 ],
38 [32, 73, 52, 33, 74, 53, 45, 86, 66, 117, 118, 119, 0, 0, 0],
39 [94, 95, 96, 35, 76, 55, 100, 101, 102, 120, 19, 20, 0, 0, 0],
40 [
41 97, 98, 99, 44, 85, 65, 103, 104, 105, 121, 122, 123, 0, 0, 0,
42 ],
43];
44
45/// Font Color's swatches, Office's forty in their grid, as COLORREFs.
46const FONT_COLORS: [u32; 40] = [
47 0x000000, 0x003399, 0x003333, 0x003300, 0x663300, 0x800000, 0x993333, 0x333333, //
48 0x000080, 0x0066ff, 0x008080, 0x008000, 0x808000, 0xff0000, 0x996666, 0x808080, //
49 0x0000ff, 0x0099ff, 0x00cc99, 0x669933, 0xcccc33, 0xff6633, 0x800080, 0x969696, //
50 0xff00ff, 0x00ccff, 0x00ffff, 0x00ff00, 0xffff00, 0xffcc00, 0x663399, 0xc0c0c0, //
51 0xcc99ff, 0x99ccff, 0x99ffff, 0xccffcc, 0xffffcc, 0xffcc99, 0xff99cc, 0xffffff,
52];
53
54/// Highlight Color's swatches as COLORREFs, which differ from the highlighter's in Sky Blue
55/// and Dark Green.
56const HIGHLIGHTS: [u32; 15] = [
57 0x00ffff, 0x00ff00, 0xffcc00, 0xff00ff, 0xff0000, //
58 0x0000ff, 0x800000, 0x808000, 0x003300, 0x800080, //
59 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000,
60];
61
62/// The Customize Tags dialog's list while it is open.
63pub struct TagList {
64 tags: Vec<NoteTag>,
65 selected: Option<usize>,
66 /// The New Tag or Modify Tag dialog's fields, and the place Modify Tag replaces.
67 editor: Option<(NoteTag, Option<usize>)>,
68}
69
70fn id() -> Id {
71 Id::ROOT.child("customize-tags")
72}
73
74fn editor_id() -> Id {
75 id().child("tag")
76}
77
78fn name_field() -> Id {
79 editor_id().child("name")
80}
81
82fn popup(name: &str) -> Id {
83 editor_id().child(("popup", name))
84}
85
86/// A tag's artwork before its name: its symbol, or none.
87fn artwork(tag: &NoteTag) -> Option<&'static [&'static str]> {
88 TagIcon::of(tag.shape, false).map(tag_sources)
89}
90
91/// The tag's name in its colours, as the list and Preview show it.
92fn label(ui: &mut Ui, part: &str, text: &str, tag: &NoteTag) {
93 ui.leaf(
94 part,
95 Spec {
96 size: [fit(), px(ROW - 2.0)],
97 text: Some(text),
98 color: Some(tag.color.map_or(ui.theme.text, colorref)),
99 fill: tag.highlight.map(colorref),
100 pad: [2.0, 0.0],
101 ..Spec::default()
102 },
103 );
104}
105
106fn icon(ui: &mut Ui, part: &str, art: Option<&'static [&'static str]>) {
107 ui.leaf(
108 part,
109 Spec {
110 size: [px(SIDE + 4.0), px(ROW)],
111 icon: art,
112 color: Some([1.0; 4]),
113 center: true,
114 ..Spec::default()
115 },
116 );
117}
118
119/// A text button that takes no clicks while not `enabled`.
120fn button(ui: &mut Ui, part: &str, text: &str, enabled: bool) -> bool {
121 if enabled {
122 return ui::button(ui, part, text).clicked;
123 }
124 let theme = ui.theme.clone();
125 ui.leaf(
126 part,
127 Spec {
128 size: [fit(), px(theme.font_size * 2.0)],
129 text: Some(text),
130 color: Some(theme.text_dim),
131 fill: Some(theme.chip),
132 radius: 4.0,
133 pad: [theme.font_size * 0.75, 0.0],
134 center: true,
135 ..Spec::default()
136 },
137 );
138 false
139}
140
141/// A square button showing `art` that takes no clicks while not `enabled`, named by a
142/// tooltip.
143fn tool(ui: &mut Ui, part: &str, art: &'static [&'static str], tip: &str, enabled: bool) -> bool {
144 let text = ui.theme.text;
145 if !enabled {
146 ui::shell::unavailable(ui, part, art, text, false);
147 return false;
148 }
149 let clicked = ui::shell::tool_button(ui, part, art, text, false).clicked;
150 ui::popup::tooltip(ui, tip, "", None);
151 clicked
152}
153
154/// A button showing `art` in its own colours, with `bar` under it, and a menu arrow; opens
155/// popup `menu` and returns where it opens.
156fn picker(
157 ui: &mut Ui,
158 part: &str,
159 art: Option<&'static [&'static str]>,
160 bar: Option<[f32; 4]>,
161 menu: Id,
162 tint: [f32; 4],
163) -> Anchor {
164 let theme = ui.theme.clone();
165 let open = ui.popup_open(menu);
166 let button = ui.open(
167 part,
168 Spec {
169 flags: Flags::CLICKABLE,
170 size: [children(), px(ui::shell::TOOL)],
171 fill: open.then(|| theme.hover()),
172 hover_fill: Some(theme.hover()),
173 border: Some(theme.chip),
174 radius: 4.0,
175 ..Spec::default()
176 },
177 );
178 ui.open(
179 "icon",
180 Spec {
181 size: [px(ui::shell::TOOL), px(ui::shell::TOOL)],
182 icon: art,
183 color: Some(tint),
184 center: true,
185 ..Spec::default()
186 },
187 );
188 if let Some(color) = bar {
189 let top = (ui::shell::TOOL - 16.0) / 2.0;
190 ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0);
191 }
192 ui.close();
193 ui.leaf(
194 "arrow",
195 Spec {
196 size: [px(12.0), px(ui::shell::TOOL)],
197 icon: Some(ui::shell::CHEVRON),
198 color: Some(theme.text_dim),
199 center: true,
200 ..Spec::default()
201 },
202 );
203 ui.close();
204 if ui.signal(button).pressed {
205 ui.open_popup(menu);
206 }
207 Anchor::Below(ui.rect(button).unwrap_or_default())
208}
209
210/// The trailing row of a dialog: its buttons at the right.
211fn buttons(ui: &mut Ui) {
212 ui.open(
213 "buttons",
214 Spec {
215 size: [fill(), children()],
216 pad: [0.0, 8.0],
217 gap: 8.0,
218 ..Spec::default()
219 },
220 );
221 ui.leaf(
222 "space",
223 Spec {
224 size: [fill(), px(1.0)],
225 ..Spec::default()
226 },
227 );
228}
229
230/// The frame of a dialog titled `title`, centred in the window.
231fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) {
232 let theme = ui.theme.clone();
233 ui.open_as(
234 id,
235 Spec {
236 axis: Axis::Y,
237 size: [px(width), children()],
238 fill: Some(theme.popup),
239 border: Some(theme.chip),
240 shadow: Some(theme.shadow),
241 radius: 8.0,
242 pad: [14.0, 10.0],
243 gap: 6.0,
244 anchor: Some(Anchor::Dialog),
245 ..Spec::default()
246 },
247 );
248 ui.leaf(
249 "title",
250 Spec {
251 size: [fill(), px(ROW + 4.0)],
252 text: Some(title),
253 bold: true,
254 ..Spec::default()
255 },
256 );
257}
258
259impl State {
260 /// Customize Tags opens on the tag list with its first tag selected, as OneNote's does.
261 pub(crate) fn open_customize_tags(&mut self) {
262 self.tag_list = Some(TagList {
263 tags: self.tags.clone(),
264 selected: (!self.tags.is_empty()).then_some(0),
265 editor: None,
266 });
267 self.ui.open_popup(id());
268 }
269
270 /// Builds the Customize Tags dialog while it is open. OK keeps a changed list, which the
271 /// toolbar, the menus and the chords then apply; Cancel, Escape or a press outside leave
272 /// it.
273 pub(crate) fn customize_tags(&mut self) {
274 let Some(list) = &mut self.tag_list else {
275 return;
276 };
277 let ui = &mut self.ui;
278 if !ui.popup_open(id()) {
279 self.tag_list = None;
280 return;
281 }
282 let theme = ui.theme.clone();
283 dialog(ui, id(), "Customize Tags", WIDTH);
284 ui.leaf(
285 "all",
286 Spec {
287 size: [fill(), px(ROW)],
288 text: Some("All Tags:"),
289 ..Spec::default()
290 },
291 );
292 ui.open(
293 "body",
294 Spec {
295 size: [fill(), children()],
296 gap: 6.0,
297 ..Spec::default()
298 },
299 );
300 ui.open(
301 "list",
302 Spec {
303 flags: Flags::SCROLL | Flags::CLIP,
304 axis: Axis::Y,
305 size: [fill(), px(ROW * ROWS + 4.0)],
306 fill: Some(theme.base),
307 border: Some(theme.chip),
308 radius: 4.0,
309 pad: [2.0, 2.0],
310 ..Spec::default()
311 },
312 );
313 let mut modify = None;
314 for (place, tag) in list.tags.iter().enumerate() {
315 let selected = list.selected == Some(place);
316 let row = ui.open(
317 ("tag", place),
318 Spec {
319 flags: Flags::CLICKABLE,
320 size: [fill(), px(ROW)],
321 fill: selected.then_some(theme.selection),
322 hover_fill: Some(theme.hover()),
323 radius: 3.0,
324 ..Spec::default()
325 },
326 );
327 icon(ui, "icon", artwork(tag));
328 let text = match commands::tag_chord(place) {
329 Some(chord) => format!("{} ({})", tag.label, chord.label(Platform::CURRENT)),
330 None => tag.label.clone(),
331 };
332 label(ui, "label", &text, tag);
333 ui.close();
334 let signal = ui.signal(row);
335 if signal.pressed {
336 list.selected = Some(place);
337 }
338 if signal.pressed && signal.unit == draw::edit::SelectionUnit::Word {
339 modify = Some(place);
340 }
341 }
342 ui.close();
343 ui.open(
344 "order",
345 Spec {
346 axis: Axis::Y,
347 size: [children(), children()],
348 gap: 4.0,
349 ..Spec::default()
350 },
351 );
352 let last = list.tags.len().saturating_sub(1);
353 let up = tool(
354 ui,
355 "up",
356 crate::art::CHEVRON_UP,
357 "Move Tag Up",
358 list.selected.is_some_and(|place| place > 0),
359 );
360 let down = tool(
361 ui,
362 "down",
363 ui::shell::CHEVRON,
364 "Move Tag Down",
365 list.selected.is_some_and(|place| place < last),
366 );
367 let remove = tool(
368 ui,
369 "remove",
370 crate::art::CLOSE,
371 "Remove",
372 list.selected.is_some(),
373 );
374 ui.close();
375 ui.close();
376 ui.open(
377 "edit",
378 Spec {
379 size: [fill(), children()],
380 gap: 8.0,
381 ..Spec::default()
382 },
383 );
384 let new = button(
385 ui,
386 "new",
387 "New Tag…",
388 list.tags.len() < commands::TAGS && list.editor.is_none(),
389 );
390 modify = modify.or_else(|| {
391 button(ui, "modify", "Modify Tag…", list.selected.is_some())
392 .then_some(list.selected)
393 .flatten()
394 });
395 ui.close();
396 buttons(ui);
397 let ok = button(ui, "ok", "OK", list.tags != self.tags);
398 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
399 ui.close();
400 if let Some(place) = list.selected {
401 if up {
402 list.tags.swap(place, place - 1);
403 list.selected = Some(place - 1);
404 } else if down {
405 list.tags.swap(place, place + 1);
406 list.selected = Some(place + 1);
407 } else if remove {
408 list.tags.remove(place);
409 list.selected = (!list.tags.is_empty()).then(|| place.min(list.tags.len() - 1));
410 }
411 }
412 if new {
413 // A new tag has no symbol, and takes the top of the list.
414 let tag = NoteTag {
415 label: "Undefined".into(),
416 shape: 0,
417 color: None,
418 highlight: None,
419 };
420 list.editor = Some((tag, None));
421 } else if let Some(place) = modify {
422 list.editor = Some((list.tags[place].clone(), Some(place)));
423 }
424 if (new || modify.is_some()) && !ui.popup_open(editor_id()) {
425 ui.open_popup(editor_id());
426 ui.set_focus(Some(name_field()));
427 }
428 if let Some((tag, place)) = tag_editor(ui, &mut list.editor) {
429 match place {
430 Some(place) => list.tags[place] = tag,
431 None => {
432 list.tags.insert(0, tag);
433 list.selected = Some(0);
434 }
435 }
436 }
437 ui.close();
438 if ok {
439 self.tags = list.tags.clone();
440 self.save_settings();
441 platform::update_tag_menu(&self.tags);
442 } else if !cancel {
443 return;
444 }
445 self.ui.close_popup(id());
446 self.ui.set_focus(Some(crate::page()));
447 self.tag_list = None;
448 }
449}
450
451/// Builds the New Tag or Modify Tag dialog while it is open over Customize Tags. Returns the
452/// tag and the place it replaces once OK keeps it.
453fn tag_editor(
454 ui: &mut Ui,
455 editor: &mut Option<(NoteTag, Option<usize>)>,
456) -> Option<(NoteTag, Option<usize>)> {
457 if !ui.popup_open(editor_id()) {
458 *editor = None;
459 return None;
460 }
461 let (tag, place) = editor.as_mut()?;
462 let theme = ui.theme.clone();
463 let entered =
464 ui::popup::navigation(ui, &[name_field()], &[NamedKey::Enter]).contains(&NamedKey::Enter);
465 let title = if place.is_some() {
466 "Modify Tag"
467 } else {
468 "New Tag"
469 };
470 dialog(ui, editor_id(), title, EDITOR_WIDTH);
471 let caption = |ui: &mut Ui, part: &str, text: &str| {
472 ui.leaf(
473 part,
474 Spec {
475 size: [fill(), px(ROW)],
476 text: Some(text),
477 color: Some(theme.text_dim),
478 ..Spec::default()
479 },
480 );
481 };
482 caption(ui, "format", "Format");
483 ui.leaf(
484 "name-label",
485 Spec {
486 size: [fill(), px(ROW)],
487 text: Some("Display name:"),
488 ..Spec::default()
489 },
490 );
491 ui::text_field(
492 ui,
493 name_field(),
494 &mut tag.label,
495 "",
496 Spec {
497 size: [fill(), px(ROW + 4.0)],
498 fill: Some(theme.base),
499 border: Some(theme.accent),
500 radius: 4.0,
501 pad: [6.0, 0.0],
502 ..Spec::default()
503 },
504 );
505 ui.open(
506 "pickers",
507 Spec {
508 size: [fill(), children()],
509 gap: 10.0,
510 ..Spec::default()
511 },
512 );
513 let column = |ui: &mut Ui, part: &str, text: &str| {
514 ui.open(
515 part,
516 Spec {
517 axis: Axis::Y,
518 size: [children(), children()],
519 gap: 2.0,
520 ..Spec::default()
521 },
522 );
523 ui.leaf(
524 "label",
525 Spec {
526 size: [fit(), px(ROW)],
527 text: Some(text),
528 ..Spec::default()
529 },
530 );
531 };
532 column(ui, "symbol", "Symbol:");
533 let symbol = artwork(tag).unwrap_or(crate::art::FONT_COLOR);
534 let tint = if tag.shape == 0 { theme.text } else { [1.0; 4] };
535 let anchor = picker(ui, "picker", Some(symbol), None, popup("symbol"), tint);
536 if let Some(shape) = gallery(ui, anchor) {
537 tag.shape = shape;
538 }
539 ui.close();
540 column(ui, "color", "Font Color:");
541 let bar = tag.color.map_or(theme.text, colorref);
542 let anchor = picker(
543 ui,
544 "picker",
545 Some(crate::art::FONT_COLOR),
546 Some(bar),
547 popup("color"),
548 theme.text,
549 );
550 if let Some(color) = swatch(ui, "color", anchor, "Automatic", &FONT_COLORS, 8) {
551 tag.color = color;
552 }
553 ui.close();
554 column(ui, "highlight", "Highlight Color:");
555 let bar = tag.highlight.map(colorref);
556 let anchor = picker(
557 ui,
558 "picker",
559 Some(crate::art::HIGHLIGHTER),
560 bar,
561 popup("highlight"),
562 theme.text,
563 );
564 if let Some(color) = swatch(ui, "highlight", anchor, "None", &HIGHLIGHTS, 5) {
565 tag.highlight = color;
566 }
567 ui.close();
568 ui.close();
569 caption(ui, "preview-label", "Preview");
570 ui.open(
571 "preview",
572 Spec {
573 size: [fill(), px(ROW * 2.0)],
574 fill: Some(theme.base),
575 border: Some(theme.chip),
576 radius: 4.0,
577 pad: [8.0, ROW / 2.0],
578 ..Spec::default()
579 },
580 );
581 ui.leaf(
582 "space",
583 Spec {
584 size: [fill(), px(1.0)],
585 ..Spec::default()
586 },
587 );
588 icon(ui, "icon", artwork(tag));
589 label(ui, "label", &tag.label, tag);
590 ui.leaf(
591 "end",
592 Spec {
593 size: [fill(), px(1.0)],
594 ..Spec::default()
595 },
596 );
597 ui.close();
598 ui.leaf(
599 "note",
600 Spec {
601 size: [fill(), fit()],
602 text: Some("Customizations do not affect notes you have already tagged."),
603 color: Some(theme.text_dim),
604 overflow: ui::Overflow::Wrap,
605 ..Spec::default()
606 },
607 );
608 buttons(ui);
609 let named = !tag.label.trim().is_empty();
610 let ok = button(ui, "ok", "OK", named) || entered && named;
611 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
612 ui.close();
613 ui.close();
614 if ok {
615 ui.close_popup(editor_id());
616 return editor.take().map(|(mut tag, place)| {
617 tag.label = tag.label.trim().to_owned();
618 (tag, place)
619 });
620 }
621 if cancel {
622 ui.close_popup(editor_id());
623 *editor = None;
624 }
625 None
626}
627
628/// Symbol's gallery below `anchor` while it is open. Returns the symbol picked, 0 for None.
629fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {
630 let id = popup("symbol");
631 if !ui.popup_open(id) {
632 return None;
633 }
634 let theme = ui.theme.clone();
635 ui.open_as(
636 id,
637 Spec {
638 axis: Axis::Y,
639 size: [children(), children()],
640 fill: Some(theme.popup),
641 border: Some(theme.chip),
642 shadow: Some(theme.shadow),
643 radius: 6.0,
644 pad: [6.0, 6.0],
645 anchor: Some(anchor),
646 ..Spec::default()
647 },
648 );
649 let mut picked = None;
650 for (row, shapes) in GALLERY.iter().enumerate() {
651 ui.open(
652 ("row", row),
653 Spec {
654 size: [children(), px(CELL)],
655 ..Spec::default()
656 },
657 );
658 for (column, &shape) in shapes.iter().enumerate() {
659 // A rule between the groups of three.
660 let gap = if column % 3 == 0 && column > 0 {
661 8.0
662 } else {
663 0.0
664 };
665 if gap > 0.0 {
666 ui.leaf(
667 ("gap", column),
668 Spec {
669 size: [px(gap), px(CELL)],
670 ..Spec::default()
671 },
672 );
673 }
674 if shape == 0 && row == 0 && column > 0 {
675 continue;
676 }
677 let none = shape == 0 && row == 0;
678 let spec = if none {
679 Spec {
680 flags: Flags::CLICKABLE,
681 size: [px(CELL * 3.0), px(CELL)],
682 text: Some("None"),
683 hover_fill: Some(theme.hover()),
684 radius: 3.0,
685 center: true,
686 ..Spec::default()
687 }
688 } else if shape == 0 {
689 Spec {
690 size: [px(CELL), px(CELL)],
691 ..Spec::default()
692 }
693 } else {
694 Spec {
695 flags: Flags::CLICKABLE,
696 size: [px(CELL), px(CELL)],
697 icon: TagIcon::of(shape, false).map(tag_sources),
698 color: Some([1.0; 4]),
699 hover_fill: Some(theme.hover()),
700 radius: 3.0,
701 center: true,
702 ..Spec::default()
703 }
704 };
705 if ui.leaf(("cell", column), spec).clicked && (none || shape != 0) {
706 picked = Some(shape);
707 }
708 if let Some(name) = symbol_name(shape) {
709 ui::popup::tooltip(ui, name, "", None);
710 }
711 }
712 ui.close();
713 }
714 ui.close();
715 if picked.is_some() {
716 ui.close_popup(id);
717 }
718 picked
719}
720
721/// A colour popup of `swatches` below `anchor` while it is open. Returns the COLORREF
722/// picked, or `Some(None)` for the button labelled `none`.
723fn swatch(
724 ui: &mut Ui,
725 name: &str,
726 anchor: Anchor,
727 none: &str,
728 swatches: &[u32],
729 columns: usize,
730) -> Option<Option<u32>> {
731 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();
732 let chosen = ui::popup::colors(ui, popup(name), anchor, none, &colors, columns)?;
733 Some(chosen.and_then(|chosen| {
734 swatches
735 .iter()
736 .zip(&colors)
737 .find(|(_, color)| **color == chosen)
738 .map(|(stored, _)| *stored)
739 }))
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but
747 /// the follow-up flags it keeps for Outlook tasks.
748 /// OneNote 2010's gallery, cell by cell: its page "Cells" tags a paragraph with a tag
749 /// named for each cell's row and column, its symbol picked from that cell
750 /// (`corpus/custom-tags/native`).
751 #[test]
752 fn the_gallery_lays_symbols_out_as_onenotes_does() {
753 use onestore::{
754 RevisionIndex, Store,
755 document::{Document, Kind},
756 page::Page,
757 };
758 let bytes = include_bytes!("../../../corpus/custom-tags/native/shapes.one");
759 let store = Store::parse(bytes).unwrap();
760 let index = RevisionIndex::parse(&store).unwrap();
761 let document = Document::parse(&index).unwrap();
762 let cells = document
763 .pages()
764 .unwrap()
765 .into_iter()
766 .map(|(space, _)| Page::from_space(&document, space).unwrap())
767 .find(|page| page.title.starts_with("Cells"))
768 .unwrap();
769 let picked: std::collections::BTreeMap<String, u16> = cells
770 .definitions
771 .values()
772 .filter_map(|definition| match &definition.kind {
773 Kind::TagDefinition {
774 label: Some(label),
775 shape: Some(shape),
776 ..
777 } => Some((label.clone(), *shape)),
778 _ => None,
779 })
780 .collect();
781 let mut cells = 0;
782 for (row, shapes) in GALLERY.iter().enumerate() {
783 for (column, shape) in shapes.iter().enumerate() {
784 let stored = picked.get(&format!("c{row}-{column}"));
785 assert_eq!(
786 stored.copied().unwrap_or(0),
787 *shape,
788 "row {row}, column {column}"
789 );
790 cells += usize::from(stored.is_some());
791 }
792 }
793 assert_eq!(cells, 138);
794 }
795
796 #[test]
797 fn the_gallery_offers_every_symbol_once() {
798 let mut offered: Vec<u16> = GALLERY
799 .iter()
800 .flatten()
801 .copied()
802 .filter(|shape| *shape != 0)
803 .collect();
804 offered.sort_unstable();
805 let expected: Vec<u16> = (1..=143)
806 .filter(|shape| !(89..=93).contains(shape))
807 .collect();
808 assert_eq!(offered, expected);
809 }
810}
crates/ui/src/lib.rs+36-5
......@@ -606,6 +606,12 @@ enum Display {
606606 /// Top and bottom.
607607 colors: [[f32; 4]; 2],
608608 },
609 Shadow {
610 rect: [f32; 4],
611 radius: f32,
612 blur: f32,
613 color: [f32; 4],
614 },
609615 Icon {
610616 sources: &'static [&'static str],
611617 origin: [f32; 2],
......@@ -1452,12 +1458,26 @@ impl Ui {
14521458 spread,
14531459 } in shadows
14541460 {
1461 let origin = [painted[0] - spread, painted[1] + drop - spread];
14551462 let grown = size.map(|side| side + 2.0 * spread);
1456 self.display.push(Display::Path {
1457 data: outline(node.shape, grown, node.radius + spread),
1458 origin: [painted[0] - spread, painted[1] + drop - spread],
1459 style: PathStyle::Shadow(blur),
1460 colors: [color; 2],
1463 self.display.push(match node.shape {
1464 Shape::Rounded => Display::Shadow {
1465 rect: [
1466 origin[0],
1467 origin[1],
1468 origin[0] + grown[0],
1469 origin[1] + grown[1],
1470 ],
1471 radius: node.radius + spread,
1472 blur,
1473 color,
1474 },
1475 Shape::Tab { .. } => Display::Path {
1476 data: outline(node.shape, grown, node.radius + spread),
1477 origin,
1478 style: PathStyle::Shadow(blur),
1479 colors: [color; 2],
1480 },
14611481 });
14621482 }
14631483 }
......@@ -1723,6 +1743,17 @@ impl Ui {
17231743 style: *style,
17241744 colors: *colors,
17251745 }),
1746 &Display::Shadow {
1747 rect,
1748 radius,
1749 blur,
1750 color,
1751 } => primitives.push(Primitive::Shadow {
1752 rect,
1753 radius,
1754 blur,
1755 color,
1756 }),
17261757 Display::Segment { from, to, color } => primitives.push(Primitive::Segment {
17271758 from: *from,
17281759 to: *to,
crates/ui/src/popup.rs+57-13
......@@ -27,6 +27,8 @@ const PALETTE: f32 = 560.0;
2727const TIP_WIDTH: f32 = 280.0;
2828/// A colour grid's cell, around its swatch.
2929const CELL: f32 = 22.0;
30/// Inset of an item's text from the highlight it previews.
31const SAMPLE_PAD: f32 = 2.0;
3032/// Seconds a popup takes to ease to the height of its results.
3133const RESIZE: f32 = 0.15;
3234thread_local! {
......@@ -50,6 +52,10 @@ pub struct Item<'a> {
5052 pub text: &'a str,
5153 /// The family the text previews, as in a font menu.
5254 pub font: Option<&'a str>,
55 /// The colour and highlight the text previews, as a tag without a symbol shows in
56 /// OneNote's Tags menu.
57 pub ink: Option<[f32; 4]>,
58 pub highlight: Option<[f32; 4]>,
5359 pub icon: Option<&'static [&'static str]>,
5460 /// The icon has colours of its own, so the text's colour does not tint it.
5561 pub colored: bool,
......@@ -87,7 +93,14 @@ pub fn menu(
8793 };
8894 let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| {
8995 [
90 text.max(measure(item.text)),
96 text.max(
97 measure(item.text)
98 + if item.highlight.is_some() {
99 2.0 * SAMPLE_PAD
100 } else {
101 0.0
102 },
103 ),
91104 shortcut.max(measure(item.shortcut)),
92105 ]
93106 });
......@@ -414,18 +427,49 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {
414427 },
415428 );
416429 }
417 ui.leaf(
418 "text",
419 Spec {
420 size: [fill(), fill()],
421 text: Some(item.text),
422 font: item.font,
423 font_size: Some(style.font_size - if item.heading { 2.0 } else { 0.0 }),
424 bold: item.heading,
425 color: Some(color),
426 ..Spec::default()
427 },
428 );
430 let text = Spec {
431 size: [fill(), fill()],
432 text: Some(item.text),
433 font: item.font,
434 font_size: Some(style.font_size - if item.heading { 2.0 } else { 0.0 }),
435 bold: item.heading,
436 color: Some(match (item.ink, item.highlight) {
437 (None, None) => color,
438 // Automatic text on a highlight is black, as on OneNote's page.
439 (ink, highlight) => {
440 let ink = ink.unwrap_or([0.0, 0.0, 0.0, 1.0]);
441 if item.disabled {
442 mix(ink, highlight.unwrap_or(style.fill), 0.5)
443 } else {
444 ink
445 }
446 }
447 }),
448 ..Spec::default()
449 };
450 match item.highlight {
451 Some(highlight) => {
452 ui.leaf(
453 "text",
454 Spec {
455 size: [fit(), px(style.row - 4.0)],
456 fill: Some(highlight),
457 pad: [SAMPLE_PAD, 0.0],
458 ..text
459 },
460 );
461 ui.leaf(
462 "rest",
463 Spec {
464 size: [fill(), fill()],
465 ..Spec::default()
466 },
467 );
468 }
469 None => {
470 ui.leaf("text", text);
471 }
472 }
429473 if !item.shortcut.is_empty() {
430474 ui.leaf(
431475 "shortcut",
tools/native/read.ps1+3-1
......@@ -99,7 +99,9 @@ try {
9999 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
103105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104106 sha256 = $hash; bytes = $bytes.Length }
105107 }
tools/test_attachment_floating.py created+43
......@@ -0,0 +1,43 @@
1from pathlib import Path
2import hashlib
3import json
4import runpy
5import shutil
6from tempfile import TemporaryDirectory
7import unittest
8import xml.etree.ElementTree as ET
9
10from native_xml import ns
11
12ROOT = Path(__file__).resolve().parent.parent
13FIXTURE = ROOT / 'corpus/attachment-floating'
14compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
15
16
17class AttachmentFloatingTest(unittest.TestCase):
18 def test_files_the_editor_put_on_the_page_open_in_onenote_where_they_were_placed(self):
19 read = FIXTURE / 'cold/read'
20 with TemporaryDirectory() as temporary:
21 copy = Path(temporary) / 'read'
22 shutil.copytree(read, copy)
23 compare(FIXTURE / 'candidate', copy)
24 page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml')))
25 self.assertEqual(page.findall('.//one:OE/one:InsertedFile', ns), [])
26 placed = {}
27 for inserted in page.findall('one:InsertedFile', ns):
28 position = inserted.find('one:Position', ns)
29 placed[inserted.get('preferredName')] = (
30 round(float(position.get('x')), 2), round(float(position.get('y')), 2))
31 # Attached at the caret a click on blank page left, the second then dragged.
32 self.assertEqual(placed, {'float 🦀.txt': (342.0, 284.4), 'large.bin': (288.0, 140.4)})
33 # The bytes OneNote hands an application when the file is opened.
34 payloads = json.loads((read / 'payloads.json').read_text(encoding='utf-8-sig'))
35 self.assertEqual(sorted((p['name'], p['bytes']) for p in payloads),
36 [('float 🦀.txt', 37), ('large.bin', 2 << 20)])
37 for payload in payloads:
38 data = (read / (payload['sha256'] + '.attachment')).read_bytes()
39 self.assertEqual(hashlib.sha256(data).hexdigest(), payload['sha256'])
40
41
42if __name__ == '__main__':
43 unittest.main()
tools/test_attachment_insert.py created+42
......@@ -0,0 +1,42 @@
1from pathlib import Path
2import hashlib
3import json
4import re
5import runpy
6import shutil
7from tempfile import TemporaryDirectory
8import unittest
9import xml.etree.ElementTree as ET
10
11from native_xml import ns
12
13ROOT = Path(__file__).resolve().parent.parent
14FIXTURE = ROOT / 'corpus/attachment-insert'
15compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
16
17
18class AttachmentInsertTest(unittest.TestCase):
19 def test_files_attached_through_the_editor_open_in_onenote_with_their_bytes(self):
20 read = FIXTURE / 'cold/read'
21 with TemporaryDirectory() as temporary:
22 copy = Path(temporary) / 'read'
23 shutil.copytree(read, copy)
24 compare(FIXTURE / 'candidate', copy)
25 page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml')))
26 paragraphs = []
27 for element in page.findall('.//one:OE', ns):
28 inserted = element.find('one:InsertedFile', ns)
29 text = element.find('one:T', ns)
30 paragraphs.append('[%s]' % inserted.get('preferredName') if inserted is not None
31 else re.sub(r'<[^>]*>', '', text.text or ''))
32 self.assertEqual(paragraphs, ['Before the file', '[notes 🦀.txt]', 'After the file', '[large.bin]', ''])
33 # The bytes OneNote hands an application when the file is opened.
34 payloads = json.loads((read / 'payloads.json').read_text(encoding='utf-8-sig'))
35 self.assertEqual(sorted(p['bytes'] for p in payloads), [30, 2 << 20])
36 for payload in payloads:
37 data = (read / (payload['sha256'] + '.attachment')).read_bytes()
38 self.assertEqual(hashlib.sha256(data).hexdigest(), payload['sha256'])
39
40
41if __name__ == '__main__':
42 unittest.main()
tools/test_custom_tags.py created+45
......@@ -0,0 +1,45 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7import xml.etree.ElementTree as ET
8
9from native_xml import ns
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/custom-tags'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16class CustomTagsTest(unittest.TestCase):
17 def test_customized_tags_render_natively_with_their_names_symbols_and_colours(self):
18 with TemporaryDirectory() as temporary:
19 read = Path(temporary) / 'read'
20 shutil.copytree(FIXTURE / 'cold/read', read)
21 compare(FIXTURE / 'candidate', read)
22 page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')))
23 definitions = [(d.get('type'), d.get('symbol'), d.get('name'), d.get('fontColor'), d.get('highlightColor'))
24 for d in page.findall('one:TagDef', ns)]
25 self.assertEqual(definitions, [
26 ('0', '61', 'Snow check', '#800000', '#00CCFF'),
27 ('1', '48', 'Snow task', 'automatic', 'none'),
28 ('2', '31', 'Snow circle', 'automatic', 'none'),
29 ('3', '0', 'Snow ink', '#0000FF', 'none'),
30 ])
31 types = {d.get('index'): d.get('type') for d in page.findall('one:TagDef', ns)}
32 tagged = [(sorted((types[t.get('index')], t.get('completed')) for t in oe.findall('one:Tag', ns)),
33 re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or ''))
34 for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None]
35 self.assertEqual(tagged, [
36 ([('0', 'true')], 'Custom symbol and colours'),
37 ([('1', 'false')], 'Custom check box'),
38 ([('1', 'true')], 'Custom check box, checked'),
39 ([('2', 'true'), ('3', 'true')], 'Two custom tags'),
40 ([], 'Untagged'),
41 ])
42
43
44if __name__ == '__main__':
45 unittest.main()
tools/test_page_background.py+18-8
......@@ -24,19 +24,29 @@ class PageBackgroundTest(unittest.TestCase):
2424 pages[page.get('name')] = page
2525
2626 def art(title):
27 return [image.get('backgroundImage') for image in pages[title].findall('one:Image', ns)]
27 return [(image.get('backgroundImage'), image.find('one:Size', ns).get('width'))
28 for image in pages[title].findall('one:Image', ns)]
2829
2930 def rules(title):
3031 return pages[title].find('one:PageSettings/one:RuleLines', ns).get('visible')
3132
32 # Art joins a ruled page, which keeps its lines; art taken away leaves none.
33 self.assertEqual(art('Standard'), ['true'])
34 self.assertEqual(rules('Standard'), 'true')
35 self.assertEqual(art('Wide'), [])
36 self.assertEqual(rules('Wide'), 'true')
37 # A colour no menu offers reads back as written.
38 self.assertEqual(pages['SmallGrid'].find('one:PageSettings', ns).get('color'), '#E8D8C8')
33 def color(title):
34 return pages[title].find('one:PageSettings', ns).get('color')
3935
36 ivy, sparks = [('true', '174.5')], [('true', '611.4000244140625')]
37 # Art joins a ruled page, which keeps its lines; art taken away or undone leaves none;
38 # redone, or undone after other art replaced it, the art is back.
39 self.assertEqual(art('Standard'), ivy)
40 self.assertEqual(art('Wide'), [])
41 self.assertEqual(art('College'), [])
42 self.assertEqual(art('MediumGrid'), ivy)
43 self.assertEqual(art('LargeGrid'), ivy)
44 for title in ('Standard', 'Wide', 'College', 'MediumGrid', 'LargeGrid', 'VeryLargeGrid'):
45 self.assertEqual(rules(title), 'true', title)
46 # A colour no menu offers reads back as written; art over a colour keeps it.
47 self.assertEqual(color('SmallGrid'), '#E8D8C8')
48 self.assertEqual(art('VeryLargeGrid'), sparks)
49 self.assertEqual(color('VeryLargeGrid'), '#FDFDDD')
4050
4151if __name__ == '__main__':
4252 unittest.main()
tools/verify-document.py+2-1
......@@ -178,7 +178,8 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads, a
178178 frame_size = stored_size + (1.44 if kind['printout'] else 0)
179179 assert abs(frame_size - native_size) < .002, ('Image display dimension differs', axis, frame_size, native_size)
180180 else:
181 expected = [p for p in native_payloads if p['page'] == page.get('ID') and p['object'] == parents[native].get('objectID')]
181 owner = native if parents[native] is page else parents[native]
182 expected = [p for p in native_payloads if p['page'] == page.get('ID') and p['object'] == owner.get('objectID')]
182183 assert len(expected) == 1, 'Native attachment association is ambiguous'
183184 assert hashlib.sha256(data).hexdigest() == expected[0]['sha256'], 'Associated attachment bytes differ'
184185 assert kind['filename'] == expected[0]['name'], 'Attachment filename differs'