| author | |
| committer | |
| log | 9da2660e0386b663c7472838e6e827c8d5ed5845 |
| tree | 5fafaf3ad7dcd5e5186e1489837ebfcaaaf43840 |
| parent | 24e89cb0f4fbe22a7b2d49f29ddfb7ec10755b4c |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Also: palette recents and actions, table columns that fit their text,
every OneNote content type editable, HTML paste with pictures, default
font, snap to grid, page tabs on the left, recent servers, recycle bin
purge, section and notebook colour, notebook properties, and .one file
associations.
Assisted-by: claude-opus-5.5446 files changed, 21304 insertions(+), 3056 deletions(-)
apps/ios/Info.plist+37| ... | @@ -4,6 +4,21 @@ | ... | @@ -4,6 +4,21 @@ |
| 4 | <dict> | 4 | <dict> |
| 5 | 	<key>CADisableMinimumFrameDurationOnPhone</key> | 5 | 	<key>CADisableMinimumFrameDurationOnPhone</key> |
| 6 | 	<true/> | 6 | 	<true/> |
| 7 | 	<key>CFBundleDocumentTypes</key> | ||
| 8 | 	<array> | ||
| 9 | 		<dict> | ||
| 10 | 			<key>CFBundleTypeName</key> | ||
| 11 | 			<string>OneNote Section</string> | ||
| 12 | 			<key>CFBundleTypeRole</key> | ||
| 13 | 			<string>Editor</string> | ||
| 14 | 			<key>LSHandlerRank</key> | ||
| 15 | 			<string>Alternate</string> | ||
| 16 | 			<key>LSItemContentTypes</key> | ||
| 17 | 			<array> | ||
| 18 | 				<string>com.microsoft.onenote.section</string> | ||
| 19 | 			</array> | ||
| 20 | 		</dict> | ||
| 21 | 	</array> | ||
| 7 | 	<key>LSSupportsOpeningDocumentsInPlace</key> | 22 | 	<key>LSSupportsOpeningDocumentsInPlace</key> |
| 8 | 	<true/> | 23 | 	<true/> |
| 9 | 	<key>NSCameraUsageDescription</key> | 24 | 	<key>NSCameraUsageDescription</key> |
| ... | @@ -38,5 +53,27 @@ | ... | @@ -38,5 +53,27 @@ |
| 38 | 		<string>UIInterfaceOrientationLandscapeLeft</string> | 53 | 		<string>UIInterfaceOrientationLandscapeLeft</string> |
| 39 | 		<string>UIInterfaceOrientationLandscapeRight</string> | 54 | 		<string>UIInterfaceOrientationLandscapeRight</string> |
| 40 | 	</array> | 55 | 	</array> |
| 56 | 	<key>UTImportedTypeDeclarations</key> | ||
| 57 | 	<array> | ||
| 58 | 		<dict> | ||
| 59 | 			<key>UTTypeConformsTo</key> | ||
| 60 | 			<array> | ||
| 61 | 				<string>public.data</string> | ||
| 62 | 			</array> | ||
| 63 | 			<key>UTTypeDescription</key> | ||
| 64 | 			<string>OneNote Section</string> | ||
| 65 | 			<key>UTTypeIdentifier</key> | ||
| 66 | 			<string>com.microsoft.onenote.section</string> | ||
| 67 | 			<key>UTTypeTagSpecification</key> | ||
| 68 | 			<dict> | ||
| 69 | 				<key>public.filename-extension</key> | ||
| 70 | 				<array> | ||
| 71 | 					<string>one</string> | ||
| 72 | 				</array> | ||
| 73 | 				<key>public.mime-type</key> | ||
| 74 | 				<string>application/onenote</string> | ||
| 75 | 			</dict> | ||
| 76 | 		</dict> | ||
| 77 | 	</array> | ||
| 41 | </dict> | 78 | </dict> |
| 42 | </plist> | 79 | </plist> |
apps/ios/Snowbound/App.swift+64| ... | @@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { | ... | @@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { |
| 19 | _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? | 19 | _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? |
| 20 | ) -> Bool { | 20 | ) -> Bool { |
| 21 | sb_set_coordinator(coordinate) | 21 | sb_set_coordinator(coordinate) |
| 22 | Editing.apply() | ||
| 22 | ICloud.start() | 23 | ICloud.start() |
| 23 | sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } } | 24 | sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } } |
| 24 | #if DEBUG | 25 | #if DEBUG |
| ... | @@ -70,6 +71,63 @@ enum Appearance { | ... | @@ -70,6 +71,63 @@ enum Appearance { |
| 70 | } | 71 | } |
| 71 | } | 72 | } |
| 72 | 73 | ||
| 74 | /// OneNote's Snap To Grid and Default font, kept between launches, which the pages opened | ||
| 75 | /// from then on take. | ||
| 76 | enum Editing { | ||
| 77 | /// OneNote 2010's Default font sizes, in points. | ||
| 78 | static let sizes: [Float] = [8, 9, 9.5, 10, 10.5, 11, 11.5, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72] | ||
| 79 | static let faces = ["Calibri", "Arial", "Times New Roman", "Courier New", "Georgia", "Verdana"] | ||
| 80 | |||
| 81 | static var snapToGrid: Bool { | ||
| 82 | get { !UserDefaults.standard.bool(forKey: "ignoreGrid") } | ||
| 83 | set { | ||
| 84 | UserDefaults.standard.set(!newValue, forKey: "ignoreGrid") | ||
| 85 | apply() | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | static var face: String { | ||
| 90 | get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" } | ||
| 91 | set { | ||
| 92 | UserDefaults.standard.set(newValue, forKey: "defaultFontFace") | ||
| 93 | apply() | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | static var size: Float { | ||
| 98 | get { UserDefaults.standard.object(forKey: "defaultFontSize") as? Float ?? 11 } | ||
| 99 | set { | ||
| 100 | UserDefaults.standard.set(newValue, forKey: "defaultFontSize") | ||
| 101 | apply() | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | static func apply() { | ||
| 106 | sb_set_snap_to_grid(snapToGrid) | ||
| 107 | sb_set_default_font(face, size) | ||
| 108 | } | ||
| 109 | |||
| 110 | /// Options' Default font: the face and size new text takes. | ||
| 111 | static func fontMenu() -> UIMenu { | ||
| 112 | let faces = faces.map { name in | ||
| 113 | let action = UIAction(title: name) { _ in face = name } | ||
| 114 | action.state = face == name ? .on : .off | ||
| 115 | return action | ||
| 116 | } | ||
| 117 | let sizes = sizes.map { points in | ||
| 118 | let action = UIAction(title: points.formatted()) { _ in size = points } | ||
| 119 | action.state = size == points ? .on : .off | ||
| 120 | return action | ||
| 121 | } | ||
| 122 | return UIMenu( | ||
| 123 | title: "Default Font", image: UIImage(systemName: "textformat"), | ||
| 124 | children: [ | ||
| 125 | UIMenu(options: .displayInline, children: faces), | ||
| 126 | UIMenu(title: "Size", children: sizes), | ||
| 127 | ]) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 73 | /// Where the reader was, reopened on the next launch as Notes reopens its last note. | 131 | /// Where the reader was, reopened on the next launch as Notes reopens its last note. |
| 74 | private struct Place: Codable { | 132 | private struct Place: Codable { |
| 75 | let notebook: String | 133 | let notebook: String |
| ... | @@ -123,6 +181,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro | ... | @@ -123,6 +181,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 123 | NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in | 181 | NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in |
| 124 | self?.notebooks.rescan { self?.restore($0) } | 182 | self?.notebooks.rescan { self?.restore($0) } |
| 125 | } | 183 | } |
| 184 | self.scene(scene, openURLContexts: options.urlContexts) | ||
| 185 | } | ||
| 186 | |||
| 187 | /// A section file Files opens in Snowbound, in place. | ||
| 188 | func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) { | ||
| 189 | for context in contexts where context.url.isFileURL { notebooks.open(document: context.url) } | ||
| 126 | } | 190 | } |
| 127 | 191 | ||
| 128 | /// Reopens the section and page shown last, or the scripted one. | 192 | /// Reopens the section and page shown last, or the scripted one. |
apps/ios/Snowbound/CanvasView.swift+23| ... | @@ -193,6 +193,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -193,6 +193,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 193 | object: nil) | 193 | object: nil) |
| 194 | registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() } | 194 | registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() } |
| 195 | formatBar.onApply = { [weak self] command in self?.apply(command) } | 195 | formatBar.onApply = { [weak self] command in self?.apply(command) } |
| 196 | formatBar.onStyle = { [weak self] place in self?.applyStyle(place) } | ||
| 197 | formatBar.stylePlace = { [weak self] in self?.stylePlace ?? -1 } | ||
| 196 | formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() } | 198 | formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() } |
| 197 | } | 199 | } |
| 198 | 200 | ||
| ... | @@ -879,6 +881,18 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -879,6 +881,18 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 879 | 881 | ||
| 880 | var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" } | 882 | var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" } |
| 881 | 883 | ||
| 884 | /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming | ||
| 885 | /// `section`. | ||
| 886 | func pdf(paper: CGSize, section: String) -> Data? { | ||
| 887 | guard let handle else { return nil } | ||
| 888 | var length = 0 | ||
| 889 | guard let bytes = sb_view_pdf(handle, Float(paper.width), Float(paper.height), section, &length) else { | ||
| 890 | return nil | ||
| 891 | } | ||
| 892 | defer { sb_bytes_free(bytes, length) } | ||
| 893 | return Data(bytes: bytes, count: length) | ||
| 894 | } | ||
| 895 | |||
| 882 | /// The selection's formatting, from `sb_view_format`. | 896 | /// The selection's formatting, from `sb_view_format`. |
| 883 | var format: UInt64 { handle.map(sb_view_format) ?? 0 } | 897 | var format: UInt64 { handle.map(sb_view_format) ?? 0 } |
| 884 | 898 | ||
| ... | @@ -934,6 +948,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -934,6 +948,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 934 | edit(external: true) { sb_view_apply(handle, command) } | 948 | edit(external: true) { sb_view_apply(handle, command) } |
| 935 | } | 949 | } |
| 936 | 950 | ||
| 951 | /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme. | ||
| 952 | func applyStyle(_ place: UInt8) { | ||
| 953 | guard let handle else { return } | ||
| 954 | edit(external: true) { sb_view_style(handle, place) } | ||
| 955 | } | ||
| 956 | |||
| 957 | /// The selection's style's place in the gallery, or -1. | ||
| 958 | var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 } | ||
| 959 | |||
| 937 | /// Shows the page as stored; with `discard`, after an edit was refused. | 960 | /// Shows the page as stored; with `discard`, after an edit was refused. |
| 938 | func reload(discard: Bool) { | 961 | func reload(discard: Bool) { |
| 939 | guard let handle else { return } | 962 | guard let handle else { return } |
apps/ios/Snowbound/FormatBar.swift+21| ... | @@ -19,6 +19,14 @@ final class FormatBar: UIInputView { | ... | @@ -19,6 +19,14 @@ final class FormatBar: UIInputView { |
| 19 | private var toggles: [(UIButton, UInt8)] = [] | 19 | private var toggles: [(UIButton, UInt8)] = [] |
| 20 | private var bits: UInt64 = 0 | 20 | private var bits: UInt64 = 0 |
| 21 | var onApply: ((UInt8) -> Void)? | 21 | var onApply: ((UInt8) -> Void)? |
| 22 | /// Applies a style of OneNote's gallery, by place; `stylePlace` names the selection's. | ||
| 23 | var onStyle: ((UInt8) -> Void)? | ||
| 24 | var stylePlace: (() -> Int32)? | ||
| 25 | /// OneNote 2010's Styles gallery, in its order. | ||
| 26 | private static let styles = [ | ||
| 27 | "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6", | ||
| 28 | "Page Title", "Citation", "Quote", "Code", "Normal", | ||
| 29 | ] | ||
| 22 | /// Asks for a picture from the camera (true) or the photo library. | 30 | /// Asks for a picture from the camera (true) or the photo library. |
| 23 | var onPicture: ((Bool) -> Void)? | 31 | var onPicture: ((Bool) -> Void)? |
| 24 | var onDismiss: (() -> Void)? | 32 | var onDismiss: (() -> Void)? |
| ... | @@ -33,6 +41,19 @@ final class FormatBar: UIInputView { | ... | @@ -33,6 +41,19 @@ final class FormatBar: UIInputView { |
| 33 | addSubview(backdrop) | 41 | addSubview(backdrop) |
| 34 | let row = UIStackView() | 42 | let row = UIStackView() |
| 35 | row.spacing = 2 | 43 | row.spacing = 2 |
| 44 | let styles = Self.button("textformat", "Styles") | ||
| 45 | styles.menu = UIMenu(children: [ | ||
| 46 | UIDeferredMenuElement.uncached { [weak self] done in | ||
| 47 | let current = self?.stylePlace?() ?? -1 | ||
| 48 | done(Self.styles.enumerated().map { place, name in | ||
| 49 | UIAction(title: name, state: Int32(place) == current ? .on : .off) { _ in | ||
| 50 | self?.onStyle?(UInt8(place)) | ||
| 51 | } | ||
| 52 | }) | ||
| 53 | } | ||
| 54 | ]) | ||
| 55 | styles.showsMenuAsPrimaryAction = true | ||
| 56 | row.addArrangedSubview(styles) | ||
| 36 | for (symbol, label, command) in Self.buttons { | 57 | for (symbol, label, command) in Self.buttons { |
| 37 | let button = Self.button(symbol, label) | 58 | let button = Self.button(symbol, label) |
| 38 | button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) | 59 | button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) |
apps/ios/Snowbound/ICloud.swift+14-1| ... | @@ -54,15 +54,27 @@ enum ICloud { | ... | @@ -54,15 +54,27 @@ enum ICloud { |
| 54 | /// Whether iCloud is signed in with iCloud Drive on, container or not. | 54 | /// Whether iCloud is signed in with iCloud Drive on, container or not. |
| 55 | static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil } | 55 | static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil } |
| 56 | 56 | ||
| 57 | /// Has the library merge conflict versions, and follows the account; call once at launch. | 57 | /// Has the library merge conflict versions, follows the account, and hears of notebooks |
| 58 | /// another device adds or removes; call once at launch. | ||
| 58 | static func start() { | 59 | static func start() { |
| 59 | sb_set_versions(listVersions, retireVersion) | 60 | sb_set_versions(listVersions, retireVersion) |
| 60 | NotificationCenter.default.addObserver( | 61 | NotificationCenter.default.addObserver( |
| 61 | forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main | 62 | forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main |
| 62 | ) { _ in lookUp() } | 63 | ) { _ in lookUp() } |
| 63 | lookUp() | 64 | lookUp() |
| 65 | query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] | ||
| 66 | query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey) | ||
| 67 | for name in [Notification.Name.NSMetadataQueryDidFinishGathering, .NSMetadataQueryDidUpdate] { | ||
| 68 | NotificationCenter.default.addObserver(forName: name, object: query, queue: .main) { _ in | ||
| 69 | if Notebooks.inCloudChanged { NotificationCenter.default.post(name: changed, object: nil) } | ||
| 70 | } | ||
| 71 | } | ||
| 72 | query.start() | ||
| 64 | } | 73 | } |
| 65 | 74 | ||
| 75 | /// Reports what iCloud Drive syncs of the container, as files come from other devices. | ||
| 76 | private static let query = NSMetadataQuery() | ||
| 77 | |||
| 66 | /// Looks the folder up off the main thread, as asking iCloud can take a while. | 78 | /// Looks the folder up off the main thread, as asking iCloud can take a while. |
| 67 | static func lookUp() { | 79 | static func lookUp() { |
| 68 | background({ () -> URL? in | 80 | background({ () -> URL? in |
| ... | @@ -77,4 +89,5 @@ enum ICloud { | ... | @@ -77,4 +89,5 @@ enum ICloud { |
| 77 | NotificationCenter.default.post(name: changed, object: nil) | 89 | NotificationCenter.default.post(name: changed, object: nil) |
| 78 | } | 90 | } |
| 79 | } | 91 | } |
| 92 | |||
| 80 | } | 93 | } |
apps/ios/Snowbound/Ink.swift+7-1| ... | @@ -240,8 +240,9 @@ final class InkPicker: UIView { | ... | @@ -240,8 +240,9 @@ final class InkPicker: UIView { |
| 240 | color.accessibilityLabel = "Colour, \(Self.name(shown.color))" | 240 | color.accessibilityLabel = "Colour, \(Self.name(shown.color))" |
| 241 | } | 241 | } |
| 242 | 242 | ||
| 243 | /// The shapes, then OneNote's Snap To Grid. | ||
| 243 | private func shapeMenu() -> [UIMenuElement] { | 244 | private func shapeMenu() -> [UIMenuElement] { |
| 244 | Self.shapes.enumerated().map { place, shape in | 245 | let shapes = Self.shapes.enumerated().map { place, shape in |
| 245 | let kind = UInt8(place) | 246 | let kind = UInt8(place) |
| 246 | let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in | 247 | let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in |
| 247 | self?.onPick?(.shape(kind)) | 248 | self?.onPick?(.shape(kind)) |
| ... | @@ -249,6 +250,11 @@ final class InkPicker: UIView { | ... | @@ -249,6 +250,11 @@ final class InkPicker: UIView { |
| 249 | action.state = tool == .shape(kind) ? .on : .off | 250 | action.state = tool == .shape(kind) ? .on : .off |
| 250 | return action | 251 | return action |
| 251 | } | 252 | } |
| 253 | let snap = UIAction(title: "Snap To Grid", image: UIImage(systemName: "grid")) { _ in | ||
| 254 | Editing.snapToGrid.toggle() | ||
| 255 | } | ||
| 256 | snap.state = Editing.snapToGrid ? .on : .off | ||
| 257 | return [UIMenu(options: .displayInline, children: shapes), UIMenu(options: .displayInline, children: [snap])] | ||
| 252 | } | 258 | } |
| 253 | 259 | ||
| 254 | /// The colours of the kind of pen shown, and for a pen its two widths. | 260 | /// The colours of the kind of pen shown, and for a pen its two widths. |
apps/ios/Snowbound/Lists.swift+31-4| ... | @@ -104,6 +104,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -104,6 +104,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 104 | Author.ask(from: self) {} | 104 | Author.ask(from: self) {} |
| 105 | }) | 105 | }) |
| 106 | more.append(Appearance.menu()) | 106 | more.append(Appearance.menu()) |
| 107 | more.append(Editing.fontMenu()) | ||
| 107 | provide(actions + [UIMenu(options: .displayInline, children: more)]) | 108 | provide(actions + [UIMenu(options: .displayInline, children: more)]) |
| 108 | } | 109 | } |
| 109 | ]) | 110 | ]) |
| ... | @@ -535,10 +536,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -535,10 +536,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 535 | 536 | ||
| 536 | private func add(_ notebook: Notebook) { | 537 | private func add(_ notebook: Notebook) { |
| 537 | Notebooks.add(notebook) | 538 | Notebooks.add(notebook) |
| 539 | openFirstSection(of: notebook) | ||
| 540 | } | ||
| 541 | |||
| 542 | private func openFirstSection(of notebook: Notebook) { | ||
| 538 | reload() | 543 | reload() |
| 539 | notebook.open { [weak self] in | 544 | notebook.open { [weak self] in |
| 540 | self?.reload() | 545 | self?.reload() |
| 541 | // A notebook just added opens at its first section. | ||
| 542 | if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) } | 546 | if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) } |
| 543 | } | 547 | } |
| 544 | } | 548 | } |
| ... | @@ -549,14 +553,21 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -549,14 +553,21 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 549 | guard let author = Author.name else { | 553 | guard let author = Author.name else { |
| 550 | return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) } | 554 | return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) } |
| 551 | } | 555 | } |
| 552 | let alert = UIAlertController(title: "New Notebook", message: nil, preferredStyle: .alert) | 556 | let alert = UIAlertController( |
| 557 | title: inCloud ? "New iCloud Notebook" : "New Notebook", message: nil, preferredStyle: .alert) | ||
| 553 | let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in | 558 | let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in |
| 554 | let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? "" | 559 | let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? "" |
| 555 | self?.create(name, by: author, inCloud: inCloud) | 560 | self?.create(name, by: author, inCloud: inCloud) |
| 556 | } | 561 | } |
| 557 | create.isEnabled = false | ||
| 558 | alert.addTextField { field in | 562 | alert.addTextField { field in |
| 559 | field.placeholder = "Name" | 563 | field.placeholder = "Name" |
| 564 | // A notebook in iCloud Drive starts named for it, so the list tells it apart. | ||
| 565 | if inCloud { | ||
| 566 | let taken = Set(Notebooks.inCloud.map(\.name)) | ||
| 567 | field.text = (1...).lazy.map { $0 == 1 ? "iCloud Notebook" : "iCloud Notebook \($0)" } | ||
| 568 | .first { !taken.contains($0) } | ||
| 569 | } | ||
| 570 | create.isEnabled = field.text?.isEmpty == false | ||
| 560 | field.autocapitalizationType = .words | 571 | field.autocapitalizationType = .words |
| 561 | NotificationCenter.default.addObserver( | 572 | NotificationCenter.default.addObserver( |
| 562 | forName: UITextField.textDidChangeNotification, object: field, queue: .main | 573 | forName: UITextField.textDidChangeNotification, object: field, queue: .main |
| ... | @@ -634,9 +645,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -634,9 +645,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 634 | } | 645 | } |
| 635 | 646 | ||
| 636 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { | 647 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { |
| 637 | guard let url = urls.first else { return } | 648 | if let url = urls.first { open(document: url) } |
| 649 | } | ||
| 650 | |||
| 651 | /// Opens the folder or section file at `url`, chosen here or opened from Files, at its first | ||
| 652 | /// section, listing it unless it is listed. | ||
| 653 | func open(document url: URL) { | ||
| 638 | let accessing = url.startAccessingSecurityScopedResource() | 654 | let accessing = url.startAccessingSecurityScopedResource() |
| 639 | defer { if accessing { url.stopAccessingSecurityScopedResource() } } | 655 | defer { if accessing { url.stopAccessingSecurityScopedResource() } } |
| 656 | let listed = Notebooks.all.first { notebook in | ||
| 657 | guard case .files(let bookmark) = notebook.source else { return false } | ||
| 658 | var stale = false | ||
| 659 | let location = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) | ||
| 660 | return location?.standardizedFileURL == url.standardizedFileURL | ||
| 661 | } | ||
| 662 | if let listed { | ||
| 663 | // A notebook still opening at launch lists no sections yet. | ||
| 664 | if let tab = listed.tabs.first(where: \.readable) { onOpen?(tab, listed) } else { openFirstSection(of: listed) } | ||
| 665 | return | ||
| 666 | } | ||
| 640 | guard let bookmark = try? url.bookmarkData() else { return } | 667 | guard let bookmark = try? url.bookmarkData() else { return } |
| 641 | add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark))) | 668 | add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark))) |
| 642 | } | 669 | } |
apps/ios/Snowbound/Notebooks.swift+39-6| ... | @@ -277,7 +277,8 @@ final class Notebook { | ... | @@ -277,7 +277,8 @@ final class Notebook { |
| 277 | } | 277 | } |
| 278 | 278 | ||
| 279 | /// Asks iCloud Drive for the notebook files it keeps elsewhere, which iOS lists as | 279 | /// Asks iCloud Drive for the notebook files it keeps elsewhere, which iOS lists as |
| 280 | /// `.Name.icloud`, and lists the sections again until they arrive. | 280 | /// `.Name.icloud` or dataless under their names, and lists the sections again until they |
| 281 | /// arrive. | ||
| 281 | private func download() { | 282 | private func download() { |
| 282 | let folder: URL? = | 283 | let folder: URL? = |
| 283 | switch source { | 284 | switch source { |
| ... | @@ -288,13 +289,17 @@ final class Notebook { | ... | @@ -288,13 +289,17 @@ final class Notebook { |
| 288 | case .server: nil | 289 | case .server: nil |
| 289 | } | 290 | } |
| 290 | guard let folder, recheck == nil, | 291 | guard let folder, recheck == nil, |
| 291 | let files = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: nil) | 292 | let files = FileManager.default.enumerator( |
| 293 | at: folder, includingPropertiesForKeys: [.ubiquitousItemDownloadingStatusKey]) | ||
| 292 | else { return } | 294 | else { return } |
| 293 | var waiting = false | 295 | var waiting = false |
| 294 | for case let stub as URL in files where stub.lastPathComponent.hasPrefix(".") && stub.pathExtension == "icloud" { | 296 | for case let file as URL in files { |
| 295 | let name = String(stub.deletingPathExtension().lastPathComponent.dropFirst()) | 297 | let stub = file.lastPathComponent.hasPrefix(".") && file.pathExtension == "icloud" |
| 296 | let file = stub.deletingLastPathComponent().appendingPathComponent(name) | 298 | let status = try? file.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]) |
| 297 | try? FileManager.default.startDownloadingUbiquitousItem(at: file) | 299 | guard stub || status?.ubiquitousItemDownloadingStatus == .notDownloaded else { continue } |
| 300 | let real = file.deletingLastPathComponent().appendingPathComponent( | ||
| 301 | String(file.deletingPathExtension().lastPathComponent.dropFirst())) | ||
| 302 | try? FileManager.default.startDownloadingUbiquitousItem(at: stub ? real : file) | ||
| 298 | waiting = true | 303 | waiting = true |
| 299 | } | 304 | } |
| 300 | guard waiting else { return } | 305 | guard waiting else { return } |
| ... | @@ -485,6 +490,13 @@ enum Notebooks { | ... | @@ -485,6 +490,13 @@ enum Notebooks { |
| 485 | return added | 490 | return added |
| 486 | } | 491 | } |
| 487 | 492 | ||
| 493 | /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as | ||
| 494 | /// when another device added or removed one. | ||
| 495 | static var inCloudChanged: Bool { | ||
| 496 | guard let folder = ICloud.documents, scripted == nil else { return false } | ||
| 497 | return notebooks(in: folder).map { "iCloud/" + $0 } != inCloud.map(\.id) | ||
| 498 | } | ||
| 499 | |||
| 488 | /// The notebook folders and section files in `folder`, by name, as Files orders them. | 500 | /// The notebook folders and section files in `folder`, by name, as Files orders them. |
| 489 | private static func notebooks(in folder: URL) -> [String] { | 501 | private static func notebooks(in folder: URL) -> [String] { |
| 490 | let listed = try? FileManager.default.contentsOfDirectory( | 502 | let listed = try? FileManager.default.contentsOfDirectory( |
| ... | @@ -648,6 +660,27 @@ final class Section { | ... | @@ -648,6 +660,27 @@ final class Section { |
| 648 | } | 660 | } |
| 649 | } | 661 | } |
| 650 | 662 | ||
| 663 | /// The notebook's themes, and the one page `id`, its section and its notebook each name. | ||
| 664 | struct Themes: Decodable { | ||
| 665 | struct Listed: Decodable { | ||
| 666 | let id: String | ||
| 667 | let name: String | ||
| 668 | } | ||
| 669 | let themes: [Listed] | ||
| 670 | let page: String? | ||
| 671 | let section: String? | ||
| 672 | let notebook: String? | ||
| 673 | } | ||
| 674 | |||
| 675 | func themes(of id: String) -> Themes? { decode(Themes.self, sb_section_themes(handle, id)) } | ||
| 676 | |||
| 677 | /// Gives `theme`, or none of its own, to page `id` (scope 0), its section (1) or its | ||
| 678 | /// notebook (2). | ||
| 679 | func setTheme(_ theme: String?, scope: UInt8, of id: String, done: @escaping (Bool) -> Void) { | ||
| 680 | let pointer = Int(bitPattern: handle) | ||
| 681 | background({ sb_section_set_theme(OpaquePointer(bitPattern: pointer), id, scope, theme) }, done: done) | ||
| 682 | } | ||
| 683 | |||
| 651 | /// Stores every edit and publishes them within `seconds`; call off the main thread. | 684 | /// Stores every edit and publishes them within `seconds`; call off the main thread. |
| 652 | func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) } | 685 | func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) } |
| 653 | 686 |
apps/ios/Snowbound/PageViewController.swift+72| ... | @@ -169,10 +169,19 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -169,10 +169,19 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 169 | UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) { | 169 | UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) { |
| 170 | [weak self] _ in self?.showPaper() | 170 | [weak self] _ in self?.showPaper() |
| 171 | }, | 171 | }, |
| 172 | themeMenu(), | ||
| 172 | UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in | 173 | UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in |
| 173 | guard let self else { return } | 174 | guard let self else { return } |
| 174 | scene?.showTags(of: section.notebook, from: self) | 175 | scene?.showTags(of: section.notebook, from: self) |
| 175 | }, | 176 | }, |
| 177 | UIMenu(options: .displayInline, children: [ | ||
| 178 | UIAction(title: "Print…", image: UIImage(systemName: "printer")) { [weak self] _ in | ||
| 179 | self?.printPage(export: false) | ||
| 180 | }, | ||
| 181 | UIAction(title: "Export as PDF…", image: UIImage(systemName: "doc.richtext")) { [weak self] _ in | ||
| 182 | self?.printPage(export: true) | ||
| 183 | }, | ||
| 184 | ]), | ||
| 176 | UIMenu(options: .displayInline, children: [ | 185 | UIMenu(options: .displayInline, children: [ |
| 177 | UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in | 186 | UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in |
| 178 | scene?.newPage(subpage: true) | 187 | scene?.newPage(subpage: true) |
| ... | @@ -186,6 +195,69 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -186,6 +195,69 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 186 | ] | 195 | ] |
| 187 | } | 196 | } |
| 188 | 197 | ||
| 198 | /// The page's theme, its section's and its notebook's, each pickable; the page wears a | ||
| 199 | /// new one when it opens again. | ||
| 200 | private func themeMenu() -> UIMenu { | ||
| 201 | guard let themes = section.themes(of: page) else { | ||
| 202 | return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: []) | ||
| 203 | } | ||
| 204 | let scopes: [(String, String?)] = [ | ||
| 205 | ("This Page", themes.page), ("This Section", themes.section), ("This Notebook", themes.notebook), | ||
| 206 | ] | ||
| 207 | let page = page | ||
| 208 | let menus = scopes.enumerated().map { scope, entry in | ||
| 209 | let (title, current) = entry | ||
| 210 | let choices = [(nil as String?, "None")] + themes.themes.map { ($0.id, $0.name) } | ||
| 211 | return UIMenu(title: title, children: choices.map { id, name in | ||
| 212 | UIAction(title: name, state: id == current ? .on : .off) { [weak self] _ in | ||
| 213 | guard let self else { return } | ||
| 214 | section.setTheme(id, scope: UInt8(scope), of: page) { [weak self] _ in | ||
| 215 | self?.onOpen?(page) | ||
| 216 | } | ||
| 217 | } | ||
| 218 | }) | ||
| 219 | } | ||
| 220 | return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: menus) | ||
| 221 | } | ||
| 222 | |||
| 223 | /// Prints the page through the system's print sheet, or with `export` offers its PDF in | ||
| 224 | /// the share sheet, laid out as OneNote 2010 prints it on the region's paper. | ||
| 225 | private func printPage(export: Bool) { | ||
| 226 | let letter = ["US", "CA", "MX", "PR", "PH", "CL", "CO", "VE", "GT", "CR", "PA", "DO", "SV", "NI", "HN", "BZ"] | ||
| 227 | .contains(Locale.current.region?.identifier ?? "US") | ||
| 228 | let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89) | ||
| 229 | let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? "" | ||
| 230 | guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else { | ||
| 231 | let alert = UIAlertController( | ||
| 232 | title: export ? "Couldn't Export the PDF" : "Couldn't Print", | ||
| 233 | message: "The page could not be laid out on paper.", preferredStyle: .alert) | ||
| 234 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | ||
| 235 | present(alert, animated: true) | ||
| 236 | return | ||
| 237 | } | ||
| 238 | if export { | ||
| 239 | let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-") | ||
| 240 | .trimmingCharacters(in: .whitespaces) | ||
| 241 | let file = FileManager.default.temporaryDirectory | ||
| 242 | .appendingPathComponent((name.isEmpty ? "Untitled" : name) + ".pdf") | ||
| 243 | do { | ||
| 244 | try pdf.write(to: file) | ||
| 245 | } catch { | ||
| 246 | return | ||
| 247 | } | ||
| 248 | let share = UIActivityViewController(activityItems: [file], applicationActivities: nil) | ||
| 249 | share.popoverPresentationController?.barButtonItem = more | ||
| 250 | present(share, animated: true) | ||
| 251 | } else { | ||
| 252 | let info = UIPrintInfo.printInfo() | ||
| 253 | info.jobName = title | ||
| 254 | let printer = UIPrintInteractionController.shared | ||
| 255 | printer.printInfo = info | ||
| 256 | printer.printingItem = pdf | ||
| 257 | printer.present(from: more, animated: true) | ||
| 258 | } | ||
| 259 | } | ||
| 260 | |||
| 189 | private func showPaper() { | 261 | private func showPaper() { |
| 190 | let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas)) | 262 | let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas)) |
| 191 | navigation.sheetPresentationController?.detents = [.medium(), .large()] | 263 | navigation.sheetPresentationController?.detents = [.medium(), .large()] |
arc/canvas.md+67-11| ... | @@ -48,6 +48,17 @@ ops still reach storage as it is typed. Undoing a deletion | ... | @@ -48,6 +48,17 @@ ops still reach storage as it is typed. Undoing a deletion |
| 48 | brings back the original identities, so internal links to those paragraphs | 48 | brings back the original identities, so internal links to those paragraphs |
| 49 | survive an undo. | 49 | survive an undo. |
| 50 | 50 | ||
| 51 | Each page keeps its history while the window is open, as in OneNote 2010: the | ||
| 52 | desktop app parks a page's editor when it is left and takes it up again, through | ||
| 53 | the same reload in place, when the page opens. Between those edits the app keeps | ||
| 54 | what was done to pages and sections (new, deleted, moved and indented pages, page | ||
| 55 | and section names, sections moved), and Undo takes back whichever came last: the | ||
| 56 | open page's own step, or the last such action in its section or notebook, | ||
| 57 | showing the page it changes. Unlike OneNote, an edit doesn't end what actions Undo | ||
| 58 | can reach, so New Page, a typed title, Undo, Undo takes the title and then the | ||
| 59 | page. Each step back is computed against the section as it is then, one edit of | ||
| 60 | its own; a step whose page is gone, or holds work it didn't make, is passed over. | ||
| 61 | |||
| 51 | Input-method composition (marked text) stays inside the editor until it | 62 | Input-method composition (marked text) stays inside the editor until it |
| 52 | commits, and only then becomes ops. When another client changes the open page, | 63 | commits, and only then becomes ops. When another client changes the open page, |
| 53 | the editor compares the stored page with what it last read plus the ops it has | 64 | the editor compares the stored page with what it last read plus the ops it has |
| ... | @@ -69,7 +80,8 @@ same line heights, same places. That took measuring, not guessing. | ... | @@ -69,7 +80,8 @@ same line heights, same places. That took measuring, not guessing. |
| 69 | New Roman or Courier New, bundled metric-compatible substitutes (Carlito, | 80 | New Roman or Courier New, bundled metric-compatible substitutes (Carlito, |
| 70 | Arimo, Tinos, Cousine) stand in, under the stored font name. | 81 | Arimo, Tinos, Cousine) stand in, under the stored font name. |
| 71 | - **OneNote's constants are OneNote's.** New outlines take OneNote's default | 82 | - **OneNote's constants are OneNote's.** New outlines take OneNote's default |
| 72 | width. Dragging snaps to its grid, anchored at the page's margin origin. Tags | 83 | width. Dragging snaps to its grid, anchored at the page's margin origin, unless |
| 84 | Snap To Grid (the shape gallery's last item, kept between launches) is off. Tags | ||
| 73 | sit in a column to the left of the text with OneNote's spacing, and a tag's | 85 | sit in a column to the left of the text with OneNote's spacing, and a tag's |
| 74 | colour paints the whole paragraph as it does there. | 86 | colour paints the whole paragraph as it does there. |
| 75 | 87 | ||
| ... | @@ -106,6 +118,12 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's | ... | @@ -106,6 +118,12 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's |
| 106 | grid point, outside any outline. It selects, drags on the grid, deletes and | 118 | grid point, outside any outline. It selects, drags on the grid, deletes and |
| 107 | moves with Insert Space as a picture does, and opens and saves as above. | 119 | moves with Insert Space as a picture does, and opens and saves as above. |
| 108 | 120 | ||
| 121 | A tag on a picture or file is stored on the object itself, as OneNote 2010 stores | ||
| 122 | the tag Ctrl+1 gives a selected one, and drawn in a column left of it, centred on | ||
| 123 | it; a click checks its box (`corpus/object-tags`). A picture's link follows on | ||
| 124 | Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's | ||
| 125 | tooltip says (`corpus/picture-link`). | ||
| 126 | |||
| 109 | An outline holding only pictures or files takes a paragraph after them where a | 127 | An outline holding only pictures or files takes a paragraph after them where a |
| 110 | click beside them lands, as OneNote 2010 adds one when typing there. It is | 128 | click beside them lands, as OneNote 2010 adds one when typing there. It is |
| 111 | stored with the first edit that reaches it, and undoing back to it empty takes it | 129 | stored with the first edit that reaches it, and undoing back to it empty takes it |
| ... | @@ -114,16 +132,20 @@ out again (`corpus/object-outline`). | ... | @@ -114,16 +132,20 @@ out again (`corpus/object-outline`). |
| 114 | A file printout's pages are pictures whose stored data is the printout's XPS | 132 | A file printout's pages are pictures whose stored data is the printout's XPS |
| 115 | package. The page draws the PNG OneNote rendered of each | 133 | package. The page draws the PNG OneNote rendered of each |
| 116 | (WebPictureContainer14), framed in grey as OneNote frames them, and moves them as | 134 | (WebPictureContainer14), framed in grey as OneNote frames them, and moves them as |
| 117 | pictures. Snowbound does not write XPS, so a printout page deleted and brought | 135 | pictures. A printout page deleted and brought back by undo returns as the page it |
| 118 | back by undo returns as that picture (`corpus/printout`). | 136 | was: its XPS package, the PNG and the properties tying it to the printout, written |
| 137 | back as read (`corpus/printout`). | ||
| 119 | 138 | ||
| 120 | Pictures go in as OneNote 2010 pastes and inserts them. At a caret in text the | 139 | Pictures go in as OneNote 2010 pastes and inserts them. At a caret in text the |
| 121 | paragraph splits around the picture, as around a file. On blank page the picture | 140 | paragraph splits around the picture, as around a file. On blank page the picture |
| 122 | lies on the page at the caret, and the caret moves to the grid row below it. A | 141 | lies on the page at the caret, and the caret moves to the grid row below it. |
| 123 | picture takes the size its resolution gives it, or 96 dpi without one. Paste | 142 | From the title it joins the outline where the body starts, or lies two grid rows |
| 124 | takes files first (a picture file as its picture), then text, then a picture: | 143 | below the page's content when none starts there. A picture takes the size its |
| 125 | Finder offers a copied file's name and icon beside it, and other apps a picture | 144 | resolution gives it, or 96 dpi without one. Paste takes files first (a picture |
| 126 | of copied text. | 145 | file as its picture, as a dropped one goes in), then a web page holding |
| 146 | pictures, its text and pictures in order as one undo step, then text, then a | ||
| 147 | picture: Finder offers a copied file's name and icon beside it, and other apps a | ||
| 148 | picture of copied text. | ||
| 127 | 149 | ||
| 128 | ## Recordings | 150 | ## Recordings |
| 129 | 151 | ||
| ... | @@ -176,6 +198,14 @@ tablet pressure on Linux. | ... | @@ -176,6 +198,14 @@ tablet pressure on Linux. |
| 176 | 198 | ||
| 177 | ## Tables and selections across them | 199 | ## Tables and selections across them |
| 178 | 200 | ||
| 201 | Columns size as OneNote 2010 sizes them (`corpus/table-widths`). An unlocked | ||
| 202 | column fits its widest line plus 4.347 pt, never under a new column's 37.11 pt, | ||
| 203 | widening and narrowing with each edit, which stores the width in the same | ||
| 204 | revision as its text. A table stops at the outline's width and its cells wrap | ||
| 205 | from there. Dragging a column's right border resizes that column alone, the | ||
| 206 | columns after it moving with it, down to 37.11 pt; on release it is one edit | ||
| 207 | and one undo step, and the column is locked, so typing no longer fits it. | ||
| 208 | |||
| 179 | Note tags on a table sit in the tag column centred on it, and a click checks its | 209 | Note tags on a table sit in the tag column centred on it, and a click checks its |
| 180 | box (`corpus/table-tags`). A selection crossing a table's edge deletes as | 210 | box (`corpus/table-tags`). A selection crossing a table's edge deletes as |
| 181 | OneNote 2010 deletes one: nothing joins across the edge, cells inside are | 211 | OneNote 2010 deletes one: nothing joins across the edge, cells inside are |
| ... | @@ -185,6 +215,28 @@ table with them when all do. What replaces the selection goes in at its start | ... | @@ -185,6 +215,28 @@ table with them when all do. What replaces the selection goes in at its start |
| 185 | Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs | 215 | Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs |
| 186 | makes each paragraph's part an equation, and a deletion between two equations | 216 | makes each paragraph's part an equation, and a deletion between two equations |
| 187 | joins them where neither seam lies inside an object (`corpus/equation-join`). | 217 | joins them where neither seam lies inside an object (`corpus/equation-join`). |
| 218 | An edit replacing a selection with an end inside an equation's object (a | ||
| 219 | fraction, a script, a root) takes the whole object first, as OneNote's equation | ||
| 220 | editor selects, and across paragraphs an object so taken at its paragraph's end | ||
| 221 | takes that end too; a placeholder ("Type equation here.") is taken whole | ||
| 222 | (`corpus/equation-select`). | ||
| 223 | |||
| 224 | ## Styles and themes | ||
| 225 | |||
| 226 | The Styles gallery is OneNote 2010's: Heading 1 to 6, Page Title, Citation, Quote, Code | ||
| 227 | and Normal, stored under OneNote's names (`h1`, `PageTitle`, `cite`, `blockquote`, `code`, | ||
| 228 | `p`). Applying one gives the paragraph the page's style object of that definition and | ||
| 229 | clears its character formatting but links, fields and language, as OneNote's does; Enter | ||
| 230 | at a paragraph's end takes its style's NextStyle, so a heading is followed by Normal. | ||
| 231 | Ctrl+Alt+1 to 6 apply the headings, and Clear Formatting at a caret applies Normal. | ||
| 232 | |||
| 233 | A theme gives the eleven styles a look, and a page wears it as its style objects: OneNote | ||
| 234 | draws a page by its own style objects, so OneNote users see the theme under the same | ||
| 235 | names. Changing a theme moves each styled paragraph to a new style object of the same name | ||
| 236 | (style objects are read-only) in one revision per page, and a page is brought to its | ||
| 237 | theme when it opens, so a notebook-wide change costs each page only when someone looks at | ||
| 238 | it. Which theme a notebook, section or page wears lives in the notebook's `.snowbound` | ||
| 239 | folder, beside the tags' art (`corpus/styles`). | ||
| 188 | 240 | ||
| 189 | ## Content the editor doesn't understand | 241 | ## Content the editor doesn't understand |
| 190 | 242 | ||
| ... | @@ -240,13 +292,17 @@ template art print across the paper; the page colour does not. The footer names | ... | @@ -240,13 +292,17 @@ template art print across the paper; the page colour does not. The footer names |
| 240 | section and numbers the sheets. `draw::pdf` writes the same primitives the screen paints | 292 | section and numbers the sheets. `draw::pdf` writes the same primitives the screen paints |
| 241 | as PDF: text in subset fonts whose ToUnicode maps come from the laid-out text, so it | 293 | as PDF: text in subset fonts whose ToUnicode maps come from the laid-out text, so it |
| 242 | selects and searches, ligatures included; ink and shapes as paths; pictures and tag | 294 | selects and searches, ligatures included; ink and shapes as paths; pictures and tag |
| 243 | icons as images. The desktop's Print hands that PDF to AppKit's print panel, the XDG print | 295 | icons as images. On the desktop, Print and Export as PDF first show OneNote's Print Preview |
| 244 | portal or the shell's print verb for PDFs; Export as PDF saves it. | 296 | and Settings, less the preview: the range (page, page group, section, and for a PDF the |
| 297 | notebook), paper, orientation, fitting to the paper's width and the footer. Print then hands | ||
| 298 | the PDF to AppKit's print panel, the XDG print portal or the shell's print verb for PDFs. | ||
| 299 | On iOS the page menu prints the page through the print sheet or shares its PDF. | ||
| 245 | 300 | ||
| 246 | ## Search, dates, conflicts | 301 | ## Search, dates, conflicts |
| 247 | 302 | ||
| 248 | Smaller modules follow the same pattern of reproducing OneNote's behaviour | 303 | Smaller modules follow the same pattern of reproducing OneNote's behaviour |
| 249 | precisely. `search` matches the way OneNote 2010 searches: word prefixes, | 304 | precisely. `search` matches the way OneNote 2010 searches: word prefixes, |
| 250 | ignoring case and diacritics, title matches first. `date` edits the title's | 305 | ignoring case and diacritics, title matches first, and the text OneNote recognised |
| 306 | in pictures, which it stores in them, as OneNote's search finds it. `date` edits the title's | ||
| 251 | date and time fields the way OneNote stores a changed page date. `conflict` | 307 | date and time fields the way OneNote stores a changed page date. `conflict` |
| 252 | shows conflict pages with OneNote's highlight. | 308 | shows conflict pages with OneNote's highlight. |
arc/sync.md+11-3| ... | @@ -285,8 +285,13 @@ version at once; nothing waits for the device that wrote it. A version of anothe | ... | @@ -285,8 +285,13 @@ version at once; nothing waits for the device that wrote it. A version of anothe |
| 285 | one that cannot be read, is kept beside the file as `Name (Device).one` instead. | 285 | one that cannot be read, is kept beside the file as `Name (Device).one` instead. |
| 286 | 286 | ||
| 287 | On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the | 287 | On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the |
| 288 | iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted is | 288 | iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted |
| 289 | asked for and read later; the replica serves it meanwhile. Each publication is an upload and a | 289 | (a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file under its own name) lists |
| 290 | as last read, or as downloading; the host asks for it, and reads the notebook again as files | ||
| 291 | arrive. The sync thread never waits for one: the replica serves it meanwhile. Opening a section | ||
| 292 | with no replica yet waits for its file. macOS and iOS offer no public way to keep a file | ||
| 293 | downloaded, as the Finder's Keep Downloaded does; evicted files are asked for again whenever | ||
| 294 | seen. Each publication is an upload and a | ||
| 290 | chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds, | 295 | chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds, |
| 291 | `Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks, | 296 | `Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks, |
| 292 | the Mac's window loses focus or the app quits. A folder presenter reports other devices' | 297 | the Mac's window loses focus or the app quits. A folder presenter reports other devices' |
| ... | @@ -305,7 +310,10 @@ which are revision stores edited through the same transaction path. Moving or | ... | @@ -305,7 +310,10 @@ which are revision stores edited through the same transaction path. Moving or |
| 305 | renaming a file also rewrites two header fields OneNote checks on open (the | 310 | renaming a file also rewrites two header fields OneNote checks on open (the |
| 306 | parent TOC's identity and a CRC of the file's name). Without them OneNote | 311 | parent TOC's identity and a CRC of the file's name). Without them OneNote |
| 307 | treats the file as a stranger and re-identifies it. Deleting sends sections and | 312 | treats the file as a stranger and re-identifies it. Deleting sends sections and |
| 308 | pages to `OneNote_RecycleBin`, as OneNote does. | 313 | pages to `OneNote_RecycleBin`, as OneNote does, and opening a notebook empties |
| 314 | what the bin has held unchanged for 60 days, as OneNote prunes it: judged by the | ||
| 315 | content's last change, the pages in one revision, binned section files deleted | ||
| 316 | with their TOC entries left (`corpus/recycle-purge`). | ||
| 309 | 317 | ||
| 310 | What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the | 318 | What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the |
| 311 | Windows hidden attribute so that OneNote never makes a section group of it. Its files are | 319 | Windows hidden attribute so that OneNote never makes a section group of it. Its files are |
arc/ui.md+5-2| ... | @@ -13,7 +13,8 @@ with a faint highlight along their tops. The open tab lies on top, in its | ... | @@ -13,7 +13,8 @@ with a faint highlight along their tops. The open tab lies on top, in its |
| 13 | section's colour, and frames the page. That colour shades gently down the | 13 | section's colour, and frames the page. That colour shades gently down the |
| 14 | window and becomes the accent for the whole interface, easing to the next | 14 | window and becomes the accent for the whole interface, easing to the next |
| 15 | section's colour when you switch. Page tabs sit on the right, and the open | 15 | section's colour when you switch. Page tabs sit on the right, and the open |
| 16 | one joins the page with rounded inside corners. It is OneNote 2010's layout, | 16 | one joins the page with rounded inside corners; OneNote's Display options move |
| 17 | them to the left and the notebooks to the right. It is OneNote 2010's layout, | ||
| 17 | drawn with soft shadows, concentric corner radii and a dark appearance. Call | 18 | drawn with soft shadows, concentric corner radii and a dark appearance. Call |
| 18 | it half-skeuomorphic: the shapes that made OneNote's notebook metaphor | 19 | it half-skeuomorphic: the shapes that made OneNote's notebook metaphor |
| 19 | legible, without the 2010 chrome. | 20 | legible, without the 2010 chrome. |
| ... | @@ -81,7 +82,9 @@ frame N | ... | @@ -81,7 +82,9 @@ frame N |
| 81 | *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by | 82 | *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by |
| 82 | priority. Only then do boxes give up room by their *strictness*, the least | 83 | priority. Only then do boxes give up room by their *strictness*, the least |
| 83 | strict first. That one knob covers most of what flexbox is usually needed | 84 | strict first. That one knob covers most of what flexbox is usually needed |
| 84 | for. | 85 | for. A box filling across a parent sized by its children stretches to what |
| 86 | its siblings make it, and a popup that isn't strict gives way to the window, | ||
| 87 | so a dialog is as tall as its contents up to the window and scrolls within. | ||
| 85 | - **The toolbar is one row that never overflows.** Both forms of every group | 88 | - **The toolbar is one row that never overflows.** Both forms of every group |
| 86 | are built each frame and the solver picks, so a group folds in the frame the | 89 | are built each frame and the solver picks, so a group folds in the frame the |
| 87 | window narrows, with no widths remembered or worked out by the app. A folded | 90 | window narrows, with no widths remembered or worked out by the app. A folded |
corpus/default-font/README.md created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | # Default font | ||
| 2 | |||
| 3 | OneNote 2010 keeps Options > General > Default font (Font, Size, Font color) in | ||
| 4 | `HKCU\Software\Microsoft\Office\14.0\OneNote\Options\Editing` as `DefaultFontFace`, | ||
| 5 | `DefaultFontSize` and `DefaultFontColor` (a COLORREF DWORD; its | ||
| 6 | picker is Office's theme and standard colours, `options-font-color.png`). Observed in a lab clone on 2026-09-30, it never writes run | ||
| 7 | formatting for it: a new page's `PageTitle` quick style takes the face at 17 pt | ||
| 8 | whatever the size, and new text takes a `p` quick style in the face and size | ||
| 9 | (plain, in the font colour, no highlight or paragraph spacing). On a page whose `p` is in | ||
| 10 | another font, new text gets a second `p` beside it, the old text keeping its own. | ||
| 11 | `native/Lab.one` is that section: "Title text" (Georgia 14), "T8" (Arial 8), | ||
| 12 | "C14B14" (Calibri 14), then "Later" typed on "Title text" with Calibri 14, its | ||
| 13 | second `p`. `native/Color.one` is a page made after picking Blue: `PageTitle` and `p` | ||
| 14 | both take `#0070C0`, runs no colour of their own (`color.png`). | ||
| 15 | |||
| 16 | `candidate/` is `crates/canvas/tests/default_font.rs`'s section, exported with | ||
| 17 | `CANVAS_DEFAULT_FONT_EXPORT`: a page created titled in Georgia and Blue, and two outlines | ||
| 18 | the editor started with the Default font Georgia 14 in Blue, each its own appended | ||
| 19 | revision. `cold/page.xml` and `cold/page.png` are a fresh OneNote 2010's read: | ||
| 20 | `PageTitle` Georgia 17 and one `p` Georgia 14, both `#0070C0`, as OneNote writes them. | ||
| 21 | `tools/test_default_font.py` checks it without a VM. | ||
corpus/default-font/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/default-font/candidate/Open Notebook.onetoc2 differ | |||
corpus/default-font/candidate/fonts.one created| Binary files /dev/null and b/corpus/default-font/candidate/fonts.one differ | |||
corpus/default-font/cold/page.png created| Binary files /dev/null and b/corpus/default-font/cold/page.png differ | |||
corpus/default-font/cold/page.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="{CA3DA07C-E834-0B2F-3765-868A9639EF79}{1}{B0}" name="Georgia title" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:37:06.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" fontColor="#0070C0" font="Georgia" fontSize="17.0"/><one:QuickStyleDef index="1" name="p" fontColor="#0070C0" highlightColor="automatic" font="Georgia" fontSize="14.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:29:02.000Z" objectID="{CA3DA07C-E834-0B2F-3765-868A9639EF79}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Georgia;font-size:17.0pt;color:#0070C0"><one:T><![CDATA[Georgia title]]></one:T></one:OE></one:Title><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2022-06-18T04:26:41.000Z" objectID="{61E3A761-D683-0F0B-1845-580EC263A955}{4}{B0}"><one:Position x="72.0" y="120.0" z="0"/><one:Size width="121.9315719604492" height="15.90724182128906"/><one:OEChildren><one:OE creationTime="2022-06-18T04:26:41.000Z" lastModifiedTime="2022-06-18T04:26:41.000Z" objectID="{61E3A761-D683-0F0B-1845-580EC263A955}{2}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Georgia body]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2022-06-18T04:26:42.000Z" objectID="{61E3A761-D683-0F0B-1845-580EC263A955}{7}{B0}"><one:Position x="72.0" y="199.9999847412109" z="1"/><one:Size width="125.2266845703125" height="15.90724182128906"/><one:OEChildren><one:OE creationTime="2022-06-18T04:26:42.000Z" lastModifiedTime="2022-06-18T04:26:42.000Z" objectID="{61E3A761-D683-0F0B-1845-580EC263A955}{5}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Georgia again]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/default-font/native/Color.one created| Binary files /dev/null and b/corpus/default-font/native/Color.one differ | |||
corpus/default-font/native/Lab.one created| Binary files /dev/null and b/corpus/default-font/native/Lab.one differ | |||
corpus/default-font/native/color.png created| Binary files /dev/null and b/corpus/default-font/native/color.png differ | |||
corpus/default-font/native/options-font-color.png created| Binary files /dev/null and b/corpus/default-font/native/options-font-color.png differ | |||
corpus/object-tags/README.md created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | # Tags on pictures and files | ||
| 2 | |||
| 3 | `native/` is a notebook OneNote 2010 made in the lab: `tools/native/picture-objects.ps1` | ||
| 4 | put a picture and a file in an outline, and a picture, a file and a picture of text on the | ||
| 5 | page; each picture and file was then selected and tagged To Do with Ctrl+1 by hand. | ||
| 6 | OneNote stores each tag on the picture or file itself (NoteTagStates on jcidImageNode and | ||
| 7 | jcidEmbeddedFileNode), in an outline as on the page, though its COM read shows an outline | ||
| 8 | object's tag on its paragraph. It draws the check box in a column left of the object, | ||
| 9 | centred on it: 20.25 points out from an outline's picture, as from text, and 24.75 points | ||
| 10 | from one on the page. It recognised "GLACIER HARBOR / Invoice 4471" in the picture of text | ||
| 11 | and stored it as the picture's RichEditTextUnicode, which its search finds (the read's | ||
| 12 | `OCRText`). A Ctrl+K on a selected picture there opened no dialog; the address typed | ||
| 13 | became a link to a new page, which the section keeps. | ||
| 14 | |||
| 15 | `candidate/` is the output of `tags_on_pictures_and_files_draw_and_check` in | ||
| 16 | `crates/canvas/tests/pictures.rs`: the page picture's, the page file's and the outline | ||
| 17 | picture's check boxes clicked, one revision each. `cold/` is a fresh OneNote 2010 read of | ||
| 18 | it: those three checked, the outline file's not, and the recognised text kept. | ||
| 19 | `tools/test_object_tags.py` checks this without a VM. Regenerate with | ||
| 20 | `CANVAS_OBJECT_TAGS_EXPORT` set to a new absolute directory while running the test, then | ||
| 21 | cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 4 --collect-notebook --screenshots`. | ||
| 22 | |||
| 23 | `restored/` is the output of `a_restored_picture_keeps_its_recognised_text`: the picture of | ||
| 24 | text deleted and brought back by undo, written with its text (RichEditTextUnicode), its | ||
| 25 | language and the undocumented layout of its words (0x1C001D61) as read, and its cold read, | ||
| 26 | whose OCRData equals the native read's, words and positions alike | ||
| 27 | (`CANVAS_RECOGNIZED_EXPORT`). | ||
corpus/object-tags/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/object-tags/candidate/Open Notebook.onetoc2 differ | |||
corpus/object-tags/candidate/files.one created| Binary files /dev/null and b/corpus/object-tags/candidate/files.one differ | |||
corpus/object-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-A6B430A7 -ExpectedPages 4 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 4 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/object-tags/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-a6b430a7", "hostname": "ONE-M6-A6B430A7"} | ||
corpus/object-tags/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/object-tags/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/object-tags/cold/notebook/files.one created| Binary files /dev/null and b/corpus/object-tags/cold/notebook/files.one differ | |||
corpus/object-tags/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | Floating file, stored at page level. | ||
corpus/object-tags/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | notes for tagging | ||
corpus/object-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-A6B430A7", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/object-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="{33250676-A4CC-4357-8960-70D030F905FB}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T22:53:33.000Z" color="#B49EDE"><one:Section name="files" ID="{C2AB6DDC-A4F5-02B9-0766-02154E90DD63}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-09-30T22:53:33.000Z" color="#B49EDE"><one:Page ID="{EEC6BEC4-3919-0FF3-2AC5-676DFE8292E8}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-09-30T22:53:32.000Z" pageLevel="1"/><one:Page ID="{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"/><one:Page ID="{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1"/><one:Page ID="{9CBC06CF-5AC0-42B4-A259-F2C670FFE9F1}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:53:33.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/object-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": "Pictures and files - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/object-tags/cold/read/page-000.png created| Binary files /dev/null and b/corpus/object-tags/cold/read/page-000.png differ | |||
corpus/object-tags/cold/read/page-000.xml created+194| ... | @@ -0,0 +1,194 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><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-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:05:57.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="119.9999847412109" height="179.8554229736328"/><one:OEChildren><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Picture below]]></one:T></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-30T22:05:56.000Z" completionDate="2026-09-30T22:50:46.000Z"/><one:Image format="png" originalPageNumber="0"><one:Size width="120.0" height="90.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh | ||
| 3 | DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB | ||
| 4 | 4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt | ||
| 5 | rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI | ||
| 6 | p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc | ||
| 7 | YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY | ||
| 8 | 3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG | ||
| 9 | JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 | ||
| 10 | svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V | ||
| 11 | h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 | ||
| 12 | S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 | ||
| 13 | geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:05:57.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{48}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:57.000Z"/><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt"/></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{52}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Text after]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{57}{B0}"><one:Position x="400.0000305175781" y="86.0" z="1"/><one:Size width="119.9999847412109" height="90.0"/><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-30T22:19:42.000Z" completionDate="2026-09-30T22:50:46.000Z"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh | ||
| 14 | DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB | ||
| 15 | 4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt | ||
| 16 | rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI | ||
| 17 | p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc | ||
| 18 | YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY | ||
| 19 | 3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG | ||
| 20 | JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 | ||
| 21 | svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V | ||
| 22 | h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 | ||
| 23 | S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 | ||
| 24 | geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{59}{B0}"><one:Position x="560.0" y="86.0" z="2"/><one:Size width="54.0" height="63.0"/><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-30T22:19:49.000Z" completionDate="2026-09-30T22:50:46.000Z"/></one:InsertedFile><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{8CBBD351-0539-4F37-AF64-B4AC90454A1C}{15}{B0}"><one:Position x="468.0" y="122.400001525879" z="4"/><one:Size width="180.6122131347656" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{8CBBD351-0539-4F37-AF64-B4AC90454A1C}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a | ||
| 25 | href="onenote:files.one#https//example.invalid/picture&amp;section-id={B771AD51-7810-4F70-BA52-95546B525FDD}&amp;page-id={76596C1B-90C9-4493-ACAF-9A0CBA6CC7FD}&amp;base-path=//C:/one-tests/runs/capture/notebook">https://example.invalid/picture</a>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2026-09-30T22:06:25.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{62}{B0}"><one:Position x="400.0000305175781" y="260.0" z="3"/><one:Size width="319.9999694824219" height="80.0" isSetByUser="true"/><one:Data>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 | ||
| 193 | +38UfRlkE+m5vQAAAABJRU5ErkJggg==</one:Data><one:OCRData lang="en-US"><one:OCRText><![CDATA[GLACIER HARBOR | ||
| 194 | Invoice 4471]]></one:OCRText><one:OCRToken startPos="0" region="0" line="0" x="16.44094467163086" y="20.69291496276855" width="159.8740234375" height="27.77952766418457"/><one:OCRToken startPos="8" region="0" line="0" x="189.3543395996094" y="20.97637939453125" width="155.6220550537109" height="27.49606323242187"/><one:OCRToken startPos="15" region="0" line="1" x="17.29133796691895" y="70.29920959472656" width="119.6220474243164" height="26.36220550537109"/><one:OCRToken startPos="23" region="0" line="1" x="149.1023559570312" y="70.29920959472656" width="74.26771545410156" height="26.36220550537109"/></one:OCRData></one:Image></one:Page> | ||
corpus/object-tags/cold/read/page-001.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/object-tags/cold/read/page-001.png created| Binary files /dev/null and b/corpus/object-tags/cold/read/page-001.png differ | |||
corpus/object-tags/cold/read/page-001.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="{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-29T18:04:53.000Z" lastModifiedTime="2026-09-29T18:05:06.000Z" objectID="{C2A94E46-18C9-06D4-1EC8-8D5328B30769}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:04:53.000Z" objectID="{C2A94E46-18C9-06D4-1EC8-8D5328B30769}{40}{B0}"><one:Position x="288.0" y="140.4000091552734" z="0"/><one:Size width="54.0" height="63.0"/></one:InsertedFile><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:05:53.000Z" objectID="{08585F32-BA6A-042A-288B-8FC98DC9C068}{18}{B0}"><one:Position x="342.0" y="284.3999938964844" z="1"/><one:Size width="54.0" height="62.99998092651367"/></one:InsertedFile></one:Page> | ||
corpus/object-tags/cold/read/page-002.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": "https://example.invalid/picture - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/object-tags/cold/read/page-002.png created| Binary files /dev/null and b/corpus/object-tags/cold/read/page-002.png differ | |||
corpus/object-tags/cold/read/page-002.xml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9CBC06CF-5AC0-42B4-A259-F2C670FFE9F1}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:53:33.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{3DC178EB-CB85-431A-A8B4-5DEF9CC7B9F8}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a | ||
| 3 | href="https://example.invalid/picture">https://example.invalid/picture</a>]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/object-tags/cold/read/page-003.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/object-tags/cold/read/page-003.png created| Binary files /dev/null and b/corpus/object-tags/cold/read/page-003.png differ | |||
corpus/object-tags/cold/read/page-003.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="{EEC6BEC4-3919-0FF3-2AC5-676DFE8292E8}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-09-30T22:53:32.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-29T18:04:33.000Z" lastModifiedTime="2026-09-29T18:04:33.000Z" objectID="{7FED4D4D-8D0A-0BE5-3E98-5C34BFBB9E66}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/object-tags/cold/read/payloads.json created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", | ||
| 4 | "name": "notes.txt", | ||
| 5 | "object": "{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{48}{B0}", | ||
| 6 | "kind": "InsertedFile", | ||
| 7 | "page": "{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}", | ||
| 8 | "bytes": 18 | ||
| 9 | }, | ||
| 10 | { | ||
| 11 | "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", | ||
| 12 | "name": "notes.txt", | ||
| 13 | "object": "{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{59}{B0}", | ||
| 14 | "kind": "InsertedFile", | ||
| 15 | "page": "{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}", | ||
| 16 | "bytes": 18 | ||
| 17 | }, | ||
| 18 | { | ||
| 19 | "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", | ||
| 20 | "name": "float.txt", | ||
| 21 | "object": "{C2A94E46-18C9-06D4-1EC8-8D5328B30769}{40}{B0}", | ||
| 22 | "kind": "InsertedFile", | ||
| 23 | "page": "{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}", | ||
| 24 | "bytes": 38 | ||
| 25 | }, | ||
| 26 | { | ||
| 27 | "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", | ||
| 28 | "name": "float.txt", | ||
| 29 | "object": "{08585F32-BA6A-042A-288B-8FC98DC9C068}{18}{B0}", | ||
| 30 | "kind": "InsertedFile", | ||
| 31 | "page": "{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}", | ||
| 32 | "bytes": 38 | ||
| 33 | } | ||
| 34 | ] | ||
| \ No newline at end of file | |||
corpus/object-tags/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/pictab/candidate", | ||
| 3 | "expected_pages": 4, | ||
| 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/object-tags/cold/scripts/cold.ps1 created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/object-tags/cold/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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/object-tags/cold/source.json created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Open Notebook.onetoc2", | ||
| 4 | "bytes": 1604, | ||
| 5 | "sha256": "f3a3df7f649fd730cc071103b4ab50fa7bdf3c4df0c4296ae0c3fb640ebee1ad", | ||
| 6 | "mtime_ns": 1790808646958031593 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "files.one", | ||
| 10 | "bytes": 59048, | ||
| 11 | "sha256": "7222f2c26057540dd2a537b337e5667d8140e4fdb2de41d091b09b39fdce5edc", | ||
| 12 | "mtime_ns": 1790808646957690287 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/object-tags/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
corpus/object-tags/native/commands.jsonl created+7| ... | @@ -0,0 +1,7 @@ | ||
| 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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE", "exit": 0, "stdout": "", "stderr": "", "error": null} | ||
| 3 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE -ExpectedPages -1 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 pages.\r\n", "stderr": "", "error": null} | ||
| 4 | {"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} | ||
| 5 | {"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} | ||
| 6 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 4 pages.\r\n", "stderr": "", "error": null} | ||
| 7 | {"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/object-tags/native/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-f27645be", "hostname": "ONE-M6-F27645BE"} | ||
corpus/object-tags/native/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/object-tags/native/notebook/Open Notebook.onetoc2 differ | |||
corpus/object-tags/native/notebook/files.one created| Binary files /dev/null and b/corpus/object-tags/native/notebook/files.one differ | |||
corpus/object-tags/native/notebook/notes.txt created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | notes for tagging | ||
corpus/object-tags/native/notebook/ocr.png created| Binary files /dev/null and b/corpus/object-tags/native/notebook/ocr.png differ | |||
corpus/object-tags/native/notebook/photo.png created| Binary files /dev/null and b/corpus/object-tags/native/notebook/photo.png differ | |||
corpus/object-tags/native/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | Floating file, stored at page level. | ||
corpus/object-tags/native/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | notes for tagging | ||
corpus/object-tags/native/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-F27645BE", | ||
| 5 | "cold": false, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/object-tags/native/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="{735E8C7C-35DD-4DEE-88DA-DC3C8F253606}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T22:19:55.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="files" ID="{EC855B5A-F06A-07E4-217A-2109E4E6B09D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-09-30T22:19:55.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Page ID="{C0E88842-6D86-0AAE-0CD9-447154F4FF16}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-09-30T22:03:26.000Z" pageLevel="1"/><one:Page ID="{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"/><one:Page ID="{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1" isCurrentlyViewed="true"/><one:Page ID="{B2923049-0E5F-47E9-8445-D1DADA89840F}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/object-tags/native/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="{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-29T18:04:53.000Z" lastModifiedTime="2026-09-29T18:05:06.000Z" objectID="{EC8778C0-4C56-0389-38D4-AE4F82C56A97}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:04:53.000Z" objectID="{EC8778C0-4C56-0389-38D4-AE4F82C56A97}{40}{B0}"><one:Position x="288.0" y="140.4000091552734" z="0"/><one:Size width="54.0" height="63.0"/></one:InsertedFile><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:05:53.000Z" objectID="{267669B4-EEF5-0177-0E97-ACD527BFAD96}{18}{B0}"><one:Position x="342.0" y="284.3999938964844" z="1"/><one:Size width="54.0" height="62.99998092651367"/></one:InsertedFile></one:Page> | ||
corpus/object-tags/native/read/page-001.xml created+194| ... | @@ -0,0 +1,194 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:50.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><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-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:06:26.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="119.9999847412109" height="179.8554229736328"/><one:OEChildren><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Picture below]]></one:T></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:06:26.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{45}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:56.000Z"/><one:Image format="png" originalPageNumber="0"><one:Size width="120.0" height="90.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh | ||
| 3 | DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB | ||
| 4 | 4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt | ||
| 5 | rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI | ||
| 6 | p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc | ||
| 7 | YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY | ||
| 8 | 3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG | ||
| 9 | JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 | ||
| 10 | svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V | ||
| 11 | h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 | ||
| 12 | S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 | ||
| 13 | geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:05:57.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{48}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:57.000Z"/><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt"/></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{52}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Text after]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2026-09-30T22:19:42.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{57}{B0}"><one:Position x="400.0000305175781" y="86.0" z="1"/><one:Size width="119.9999847412109" height="90.0"/><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:19:42.000Z"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh | ||
| 14 | DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB | ||
| 15 | 4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt | ||
| 16 | rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI | ||
| 17 | p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc | ||
| 18 | YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY | ||
| 19 | 3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG | ||
| 20 | JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 | ||
| 21 | svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V | ||
| 22 | h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 | ||
| 23 | S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 | ||
| 24 | geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt" lastModifiedTime="2026-09-30T21:57:31.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{59}{B0}"><one:Position x="560.0" y="86.0" z="2"/><one:Size width="54.0" height="63.0"/><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:19:49.000Z"/></one:InsertedFile><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{A295E5D7-51A6-4A6A-8978-97B03A3327E2}{15}{B0}"><one:Position x="468.0" y="122.400001525879" z="4"/><one:Size width="180.6122131347656" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{A295E5D7-51A6-4A6A-8978-97B03A3327E2}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a | ||
| 25 | href="onenote:files.one#https//example.invalid/picture&amp;section-id={B771AD51-7810-4F70-BA52-95546B525FDD}&amp;page-id={76596C1B-90C9-4493-ACAF-9A0CBA6CC7FD}&amp;base-path=//C:/one-tests/runs/capture/notebook">https://example.invalid/picture</a>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2026-09-30T22:06:25.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{62}{B0}"><one:Position x="400.0000305175781" y="260.0" z="3"/><one:Size width="319.9999694824219" height="80.0" isSetByUser="true"/><one:Data>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 | ||
| 193 | +38UfRlkE+m5vQAAAABJRU5ErkJggg==</one:Data><one:OCRData lang="en-US"><one:OCRText><![CDATA[GLACIER HARBOR | ||
| 194 | Invoice 4471]]></one:OCRText><one:OCRToken startPos="0" region="0" line="0" x="16.44094467163086" y="20.69291496276855" width="159.8740234375" height="27.77952766418457"/><one:OCRToken startPos="8" region="0" line="0" x="189.3543395996094" y="20.97637939453125" width="155.6220550537109" height="27.49606323242187"/><one:OCRToken startPos="15" region="0" line="1" x="17.29133796691895" y="70.29920959472656" width="119.6220474243164" height="26.36220550537109"/><one:OCRToken startPos="23" region="0" line="1" x="149.1023559570312" y="70.29920959472656" width="74.26771545410156" height="26.36220550537109"/></one:OCRData></one:Image></one:Page> | ||
corpus/object-tags/native/read/page-002.xml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B2923049-0E5F-47E9-8445-D1DADA89840F}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{13EF4E6D-9F1A-4647-8EA8-7EF336B1D406}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a | ||
| 3 | href="https://example.invalid/picture">https://example.invalid/picture</a>]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/object-tags/native/read/page-003.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="{C0E88842-6D86-0AAE-0CD9-447154F4FF16}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-09-30T22:03:27.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-29T18:04:33.000Z" lastModifiedTime="2026-09-29T18:04:33.000Z" objectID="{51C37BCB-D995-0EB8-1884-7F2815CDF398}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/object-tags/native/read/payloads.json created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", | ||
| 4 | "name": "float.txt", | ||
| 5 | "object": "{EC8778C0-4C56-0389-38D4-AE4F82C56A97}{40}{B0}", | ||
| 6 | "kind": "InsertedFile", | ||
| 7 | "page": "{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}", | ||
| 8 | "bytes": 38 | ||
| 9 | }, | ||
| 10 | { | ||
| 11 | "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", | ||
| 12 | "name": "float.txt", | ||
| 13 | "object": "{267669B4-EEF5-0177-0E97-ACD527BFAD96}{18}{B0}", | ||
| 14 | "kind": "InsertedFile", | ||
| 15 | "page": "{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}", | ||
| 16 | "bytes": 38 | ||
| 17 | }, | ||
| 18 | { | ||
| 19 | "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", | ||
| 20 | "name": "notes.txt", | ||
| 21 | "object": "{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{48}{B0}", | ||
| 22 | "kind": "InsertedFile", | ||
| 23 | "page": "{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}", | ||
| 24 | "bytes": 18 | ||
| 25 | }, | ||
| 26 | { | ||
| 27 | "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", | ||
| 28 | "name": "notes.txt", | ||
| 29 | "object": "{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{59}{B0}", | ||
| 30 | "kind": "InsertedFile", | ||
| 31 | "page": "{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}", | ||
| 32 | "bytes": 18 | ||
| 33 | } | ||
| 34 | ] | ||
| \ No newline at end of file | |||
corpus/object-tags/native/run.json created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/pictab/nb", | ||
| 3 | "expected_pages": -1, | ||
| 4 | "author": "tools/native/picture-objects.ps1", | ||
| 5 | "author_timeout_seconds": 600, | ||
| 6 | "inspect": true, | ||
| 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 | "author.ps1": "9402c73adc2a48971075f1ead915ee095b5d4abf8d09cdc9fb9473697aebe4c0", | ||
| 16 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | ||
| 17 | "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" | ||
| 18 | } | ||
| 19 | } | ||
corpus/object-tags/native/scripts/author.ps1 created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | ||
| 2 | # A page of pictures and files, in an outline and on the page, for tagging, linking and OCR | ||
| 3 | # by hand in the clone: notebook/photo.png, notebook/ocr.png and notebook/notes.txt ride along | ||
| 4 | # with the section. | ||
| 5 | Set-StrictMode -Version Latest | ||
| 6 | $ErrorActionPreference = 'Stop' | ||
| 7 | & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost | ||
| 8 | $namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' | ||
| 9 | $notebook = Join-Path $Root 'notebook' | ||
| 10 | $data = { param($name) [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $notebook $name))) } | ||
| 11 | $file = Join-Path $notebook 'notes.txt' | ||
| 12 | $app = New-Object -ComObject OneNote.Application | ||
| 13 | $notebookId = '' | ||
| 14 | try { | ||
| 15 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | ||
| 16 | $sectionId = '' | ||
| 17 | $app.OpenHierarchy('files.one', $notebookId, [ref]$sectionId, 0) | ||
| 18 | $pageId = '' | ||
| 19 | $app.CreateNewPage($sectionId, [ref]$pageId, 0) | ||
| 20 | $body = '<one:Title><one:OE><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title>' | ||
| 21 | $body += '<one:Outline><one:Position x="36" y="86"/><one:Size width="300" height="200"/><one:OEChildren>' | ||
| 22 | $body += '<one:OE><one:T><![CDATA[Picture below]]></one:T></one:OE>' | ||
| 23 | $body += '<one:OE><one:Image><one:Size width="120" height="90"/><one:Data>' + (& $data 'photo.png') + '</one:Data></one:Image></one:OE>' | ||
| 24 | $body += '<one:OE><one:InsertedFile pathSource="' + $file + '" preferredName="notes.txt"/></one:OE>' | ||
| 25 | $body += '<one:OE><one:T><![CDATA[Text after]]></one:T></one:OE>' | ||
| 26 | $body += '</one:OEChildren></one:Outline>' | ||
| 27 | $body += '<one:Image><one:Position x="400" y="86"/><one:Size width="120" height="90"/><one:Data>' + (& $data 'photo.png') + '</one:Data></one:Image>' | ||
| 28 | $body += '<one:InsertedFile pathSource="' + $file + '" preferredName="notes.txt"><one:Position x="560" y="86"/></one:InsertedFile>' | ||
| 29 | $body += '<one:Image><one:Position x="400" y="260"/><one:Size width="320" height="80"/><one:Data>' + (& $data 'ocr.png') + '</one:Data></one:Image>' | ||
| 30 | $xml = '<one:Page xmlns:one="' + $namespace + '" ID="' + $pageId + '">' + $body + '</one:Page>' | ||
| 31 | $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $true) | ||
| 32 | $app.SyncHierarchy($notebookId) | ||
| 33 | } finally { | ||
| 34 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | ||
| 35 | [GC]::Collect() | ||
| 36 | [GC]::WaitForPendingFinalizers() | ||
| 37 | } | ||
corpus/object-tags/native/scripts/cold.ps1 created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/object-tags/native/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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/object-tags/native/source.json created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "files.one", | ||
| 4 | "bytes": 21160, | ||
| 5 | "sha256": "d7d7688e86bae61505b96daf8af0d5d7adc1af3777ac13f2551cc8a339c1a182", | ||
| 6 | "mtime_ns": 1790805042299289270 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "notes.txt", | ||
| 10 | "bytes": 18, | ||
| 11 | "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", | ||
| 12 | "mtime_ns": 1790805042459716143 | ||
| 13 | }, | ||
| 14 | { | ||
| 15 | "path": "ocr.png", | ||
| 16 | "bytes": 9598, | ||
| 17 | "sha256": "b81dc7dd998956abfc1394b45567140c178b7b4523d06726970f75f4a2b701db", | ||
| 18 | "mtime_ns": 1790805042459075351 | ||
| 19 | }, | ||
| 20 | { | ||
| 21 | "path": "photo.png", | ||
| 22 | "bytes": 659, | ||
| 23 | "sha256": "2a39fa803c359c78ce662012ae75b810a33b13647c0050c29176301dcad8eb3b", | ||
| 24 | "mtime_ns": 1790805042459606226 | ||
| 25 | } | ||
| 26 | ] | ||
corpus/object-tags/native/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
corpus/object-tags/restored/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/object-tags/restored/candidate/Open Notebook.onetoc2 differ | |||
corpus/object-tags/restored/candidate/files.one created| Binary files /dev/null and b/corpus/object-tags/restored/candidate/files.one differ | |||
corpus/object-tags/restored/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-98295DF8 -ExpectedPages 4 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 4 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/object-tags/restored/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-98295df8", "hostname": "ONE-M6-98295DF8"} | ||
corpus/object-tags/restored/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/object-tags/restored/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/object-tags/restored/cold/notebook/files.one created| Binary files /dev/null and b/corpus/object-tags/restored/cold/notebook/files.one differ | |||
corpus/object-tags/restored/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | Floating file, stored at page level. | ||
corpus/object-tags/restored/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | notes for tagging | ||
corpus/object-tags/restored/cold/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-98295DF8", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/object-tags/restored/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="{BE5E2DF1-CE38-4344-BC90-497EE9FA804A}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T02:36:18.000Z" color="#B49EDE"><one:Section name="files" ID="{626481A0-A5CB-0535-2566-75D4AF7B7B5A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-10-01T02:36:18.000Z" color="#B49EDE"><one:Page ID="{4E0952B8-3827-087F-08C5-10AC1F6934D1}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-10-01T02:36:17.000Z" pageLevel="1"/><one:Page ID="{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"/><one:Page ID="{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1"/><one:Page ID="{3C73EAB3-5BFE-4538-8059-850791144FC8}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-10-01T02:36:18.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/object-tags/restored/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": "https://example.invalid/picture - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/object-tags/restored/cold/read/page-000.png created| Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-000.png differ | |||
corpus/object-tags/restored/cold/read/page-000.xml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3C73EAB3-5BFE-4538-8059-850791144FC8}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-10-01T02:36:18.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{9D0E9497-CABB-4496-8AB4-2A2E7D2C1FC1}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a | ||
| 3 | href="https://example.invalid/picture">https://example.invalid/picture</a>]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/object-tags/restored/cold/read/page-001.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/object-tags/restored/cold/read/page-001.png created| Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-001.png differ | |||
corpus/object-tags/restored/cold/read/page-001.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="{4E0952B8-3827-087F-08C5-10AC1F6934D1}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-10-01T02:36:17.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-29T18:04:33.000Z" lastModifiedTime="2026-09-29T18:04:33.000Z" objectID="{DF22A131-8C34-0C69-1C98-2BF55E50385F}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/object-tags/restored/cold/read/page-002.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/object-tags/restored/cold/read/page-002.png created| Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-002.png differ | |||
corpus/object-tags/restored/cold/read/page-002.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="{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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-29T18:04:53.000Z" lastModifiedTime="2026-09-29T18:05:06.000Z" objectID="{6266A23A-19F7-0158-3CC8-FA92C958A150}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:04:53.000Z" objectID="{6266A23A-19F7-0158-3CC8-FA92C958A150}{40}{B0}"><one:Position x="288.0" y="140.4000091552734" z="0"/><one:Size width="54.0" height="63.0"/></one:InsertedFile><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:05:53.000Z" objectID="{A897B34E-BB54-03A6-0A8B-F8086C226651}{18}{B0}"><one:Position x="342.0" y="284.3999938964844" z="1"/><one:Size width="54.0" height="62.99998092651367"/></one:InsertedFile></one:Page> | ||
corpus/object-tags/restored/cold/read/page-003.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": "Pictures and files - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/object-tags/restored/cold/read/page-003.png created| Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-003.png differ | |||
corpus/object-tags/restored/cold/read/page-003.xml created+194| ... | @@ -0,0 +1,194 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><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-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:06:26.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="119.9999847412109" height="179.8554229736328"/><one:OEChildren><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Picture below]]></one:T></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:06:26.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{45}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:56.000Z"/><one:Image format="png" originalPageNumber="0"><one:Size width="120.0" height="90.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh | ||
| 3 | DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB | ||
| 4 | 4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt | ||
| 5 | rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI | ||
| 6 | p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc | ||
| 7 | YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY | ||
| 8 | 3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG | ||
| 9 | JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 | ||
| 10 | svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V | ||
| 11 | h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 | ||
| 12 | S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 | ||
| 13 | geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:05:57.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{48}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:57.000Z"/><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt"/></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{52}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Text after]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2026-09-30T22:19:42.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{57}{B0}"><one:Position x="400.0000305175781" y="86.0" z="1"/><one:Size width="119.9999847412109" height="90.0"/><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:19:42.000Z"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh | ||
| 14 | DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB | ||
| 15 | 4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt | ||
| 16 | rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI | ||
| 17 | p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc | ||
| 18 | YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY | ||
| 19 | 3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG | ||
| 20 | JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 | ||
| 21 | svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V | ||
| 22 | h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 | ||
| 23 | S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 | ||
| 24 | geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt" lastModifiedTime="2026-09-30T21:57:31.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{59}{B0}"><one:Position x="560.0" y="86.0" z="2"/><one:Size width="54.0" height="63.0"/><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:19:49.000Z"/></one:InsertedFile><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{2C743F2D-0407-48BB-8D64-C36D71AEEC25}{15}{B0}"><one:Position x="468.0" y="122.400001525879" z="4"/><one:Size width="180.6122131347656" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{2C743F2D-0407-48BB-8D64-C36D71AEEC25}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a | ||
| 25 | href="onenote:files.one#https//example.invalid/picture&amp;section-id={B771AD51-7810-4F70-BA52-95546B525FDD}&amp;page-id={76596C1B-90C9-4493-ACAF-9A0CBA6CC7FD}&amp;base-path=//C:/one-tests/runs/capture/notebook">https://example.invalid/picture</a>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{62}{B0}"><one:Position x="400.0000305175781" y="260.0" z="3"/><one:Size width="319.9999694824219" height="80.0" isSetByUser="true"/><one:Data>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 | ||
| 193 | +38UfRlkE+m5vQAAAABJRU5ErkJggg==</one:Data><one:OCRData lang="en-US"><one:OCRText><![CDATA[GLACIER HARBOR | ||
| 194 | Invoice 4471]]></one:OCRText><one:OCRToken startPos="0" region="0" line="0" x="16.44094467163086" y="20.69291496276855" width="159.8740234375" height="27.77952766418457"/><one:OCRToken startPos="8" region="0" line="0" x="189.3543395996094" y="20.97637939453125" width="155.6220550537109" height="27.49606323242187"/><one:OCRToken startPos="15" region="0" line="1" x="17.29133796691895" y="70.29920959472656" width="119.6220474243164" height="26.36220550537109"/><one:OCRToken startPos="23" region="0" line="1" x="149.1023559570312" y="70.29920959472656" width="74.26771545410156" height="26.36220550537109"/></one:OCRData></one:Image></one:Page> | ||
corpus/object-tags/restored/cold/read/payloads.json created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", | ||
| 4 | "name": "float.txt", | ||
| 5 | "object": "{6266A23A-19F7-0158-3CC8-FA92C958A150}{40}{B0}", | ||
| 6 | "kind": "InsertedFile", | ||
| 7 | "page": "{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}", | ||
| 8 | "bytes": 38 | ||
| 9 | }, | ||
| 10 | { | ||
| 11 | "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", | ||
| 12 | "name": "float.txt", | ||
| 13 | "object": "{A897B34E-BB54-03A6-0A8B-F8086C226651}{18}{B0}", | ||
| 14 | "kind": "InsertedFile", | ||
| 15 | "page": "{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}", | ||
| 16 | "bytes": 38 | ||
| 17 | }, | ||
| 18 | { | ||
| 19 | "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", | ||
| 20 | "name": "notes.txt", | ||
| 21 | "object": "{5C260B06-D129-4877-B65E-78CBE3BC7260}{48}{B0}", | ||
| 22 | "kind": "InsertedFile", | ||
| 23 | "page": "{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}", | ||
| 24 | "bytes": 18 | ||
| 25 | }, | ||
| 26 | { | ||
| 27 | "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", | ||
| 28 | "name": "notes.txt", | ||
| 29 | "object": "{5C260B06-D129-4877-B65E-78CBE3BC7260}{59}{B0}", | ||
| 30 | "kind": "InsertedFile", | ||
| 31 | "page": "{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}", | ||
| 32 | "bytes": 18 | ||
| 33 | } | ||
| 34 | ] | ||
| \ No newline at end of file | |||
corpus/object-tags/restored/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/ocrlab/candidate", | ||
| 3 | "expected_pages": 4, | ||
| 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/object-tags/restored/cold/scripts/cold.ps1 created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/object-tags/restored/cold/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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/object-tags/restored/cold/source.json created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Open Notebook.onetoc2", | ||
| 4 | "bytes": 1604, | ||
| 5 | "sha256": "dc9d3f6501503965aa97e94b86766f4e9a87d279eeff4a253ff4612ca8993405", | ||
| 6 | "mtime_ns": 1790822016309856876 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "files.one", | ||
| 10 | "bytes": 67592, | ||
| 11 | "sha256": "fab6f6881081cf1b05cc375301bcfe1e7eda90a294bab3fa137108cf92b45026", | ||
| 12 | "mtime_ns": 1790822016309661293 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/object-tags/restored/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
corpus/picture-link/README.md created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | # Pictures with a link | ||
| 2 | |||
| 3 | `lab/` holds screenshots of OneNote 2010 in a disposable clone of | ||
| 4 | `corpus/m6/native-features-01`, on the page "Image png", whose picture stores a link | ||
| 5 | (WzHyperlinkUrl, the COM read's `hyperlink`). Hovering it shows the address and "Ctrl+click | ||
| 6 | to follow link" (`hover.png`); a click selects the picture (`click-selects.png`); Ctrl+click | ||
| 7 | opens the address, here an unreachable one (`ctrl-click.png`). Snowbound follows the same | ||
| 8 | link on Ctrl+click, Command+click on macOS. | ||
| 9 | |||
| 10 | `candidate/` is the output of `a_linked_picture_keeps_its_link_through_undo` in | ||
| 11 | `crates/canvas/tests/pictures.rs`: the picture deleted and brought back by undo, published | ||
| 12 | as one revision. `cold/` is a fresh OneNote 2010 read, whose picture still links there. | ||
| 13 | `tools/test_picture_link.py` checks this without a VM. Regenerate with | ||
| 14 | `CANVAS_PICTURE_LINK_EXPORT` set to a new absolute directory while running the test, then | ||
| 15 | cold-open it with `tools/native_runner.py OUTPUT COLD --collect-notebook`. | ||
corpus/picture-link/candidate/Features.one created| Binary files /dev/null and b/corpus/picture-link/candidate/Features.one differ | |||
corpus/picture-link/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/picture-link/candidate/Open Notebook.onetoc2 differ | |||
corpus/picture-link/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-70F8F401 -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 13 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/picture-link/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-70f8f401", "hostname": "ONE-M6-70F8F401"} | ||
corpus/picture-link/cold/notebook/Features.one created| Binary files /dev/null and b/corpus/picture-link/cold/notebook/Features.one differ | |||
corpus/picture-link/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/picture-link/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/picture-link/cold/read/4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2.attachment created| Binary files /dev/null and b/corpus/picture-link/cold/read/4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2.attachment differ | |||
corpus/picture-link/cold/read/56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9.attachment created| Binary files /dev/null and b/corpus/picture-link/cold/read/56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9.attachment differ | |||
corpus/picture-link/cold/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-70F8F401", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/picture-link/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="{61B523C4-0363-4001-9972-20EDE31F9FC2}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T23:00:10.000Z" color="#B49EDE"><one:Section name="Features" ID="{85110ED2-E5F5-05D7-1E5C-DC57A8A418C6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Features.one" lastModifiedTime="2026-09-30T23:00:10.000Z" color="#91BAAE"><one:Page ID="{86DA38AE-66AD-012D-0140-0BD7D50D4CD6}{1}{B0}" name="Image png" dateTime="2026-09-06T02:25:40.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{E4150670-59A6-0ECB-0BDD-D595FA83480D}{1}{B0}" name="Image jpg" dateTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{4D27D57A-6002-00EF-257A-3D7B07F997BC}{1}{B0}" name="Image bmp" dateTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{E25EA0DE-E27F-0A28-1A8D-D17E0ECC7E0F}{1}{B0}" name="Image tiff" dateTime="2026-09-06T02:25:43.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{2B90AB1F-25D8-0528-23B6-0068569D16DB}{1}{B0}" name="Image gif" dateTime="2026-09-06T02:25:44.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{2F5BAA67-B283-0E93-0BEF-3242BD9EA86E}{1}{B0}" name="Image role background=false, printout=false" dateTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{F86A5F48-E893-06F7-2CDE-4540E56F4C1D}{1}{B0}" name="Image role background=true, printout=false" dateTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{6F95DBC7-8E09-0A8C-29EE-9E4F78401929}{1}{B0}" name="Image role background=false, printout=true" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{3DBE8FF1-FCD5-05FE-3C1A-2C47F261DC16}{1}{B0}" name="Image role background=true, printout=true" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}" name="Files and recording" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{710863F5-A9D2-0E07-3FB8-007CC60AD628}{1}{B0}" name="Paragraph controls" dateTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1"/><one:Page ID="{55DF55E0-26A1-0C16-13B3-2B2199955F6D}{1}{B0}" name="Numbering controls" dateTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1"/><one:Page ID="{ADE0856F-5F03-05E7-03D6-EE9AF29EDD7B}{1}{B0}" name="Task controls" dateTime="2026-09-06T02:25:50.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/picture-link/cold/read/page-000.xml created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2B90AB1F-25D8-0528-23B6-0068569D16DB}{1}{B0}" name="Image gif" dateTime="2026-09-06T02:25:44.000Z" lastModifiedTime="2026-09-30T23:00:09.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-06T02:25:45.000Z" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{50C5A32E-43F2-00DB-3410-9455A6AA9FB8}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image gif]]></one:T></one:OE></one:Title><one:Image format="png" hyperlink="https://example.invalid/image/gif" alt="Pattern gif" originalPageNumber="0" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{50C5A32E-43F2-00DB-3410-9455A6AA9FB8}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAMAAADS6oI8AAADAFBMVEW4t5mrt5O4qpWrqo6euo2S | ||
| 3 | uoeZs4ieqoiSqoK4nJCrnIq4kIyrkIaen4SSn36ZmH+ekH+SkHmGuoGGs395t3pounJos3CGqnx5 | ||
| 4 | qnVtqm9iqmqGnHd5nHGGkHN5kG1on2lomGdokGS4hIirhIK4d4Srd32eh3ySh3aZgHeed3eSd3G4 | ||
| 5 | a4Cra3m4YHyrYHaea3OSa22eYG+SYGmGh3CGgG55hGloh2FogF+Gd2t5d2Rtd15id1mGa2d5a2CG | ||
| 6 | YGN5YF1ta1pia1VoYFRWumlWs2dJt2I4ulo4s1hWqmRJql09qlcyqlImulEaukshs0wKukMKs0Em | ||
| 7 | qkwaqkYPqkAEqjtWnF9JnFlWkFtJkFU4n1E4mE84kEwmn0gan0IhmEMmkEMakD0KnzoKmDgKkDVW | ||
| 8 | h1hWgFZJhFE4h0k4gEdWd1NJd0w9d0Yyd0FWa09Ja0hWYEtJYEU9a0Iyaz04YDwmh0AahzohgDsK | ||
| 9 | hzIKgDAmdzsadzUPdy8EdyomazcaazEmYDMaYC0PaysEayYKYCW4VHirVHK4R3SrR22eV2ySV2aZ | ||
| 10 | UGeeR2eSR2G4O3CrO2m4MGyrMGaeO2OSO12eMF+SMFmGV2CGUF55VFloV1FoUE+GR1t5R1RtR05i | ||
| 11 | R0mGO1d5O1CGMFN5ME1tO0piO0VoMES4JGirJGK4GGSrGF6eJ1ySJ1aZIFeeGFeSGFG4DmGrDlq4 | ||
| 12 | A12rA1eeDlSSDk6eA1CSA0qGJE95JEmGGEt5GEVoJ0FoID9oGDyGDkh5DkGGA0R5Az5tDjtiDjZo | ||
| 13 | AzVWVEdJVEE4Vzk4UDdWR0NJRzw9RzYyRzFWOz9JOzhWMDtJMDU9OzIyOy04MCwmVzAaVyohUCsK | ||
| 14 | VyIKUCAmRysaRyUPRx8ERxomOycaOyEmMCMaMB0POxsEOxYKMBVWJDdJJDFWGDNJGC04Jyk4ICc4 | ||
| 15 | GCRWDjBJDilWAyxJAyY9DiMyDh44Ax0mJyAaJxohIBsmGBsaGBUKJxIKIBAKGA0mDhgaDhImAxQa | ||
| 16 | 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 | ||
| 45 | AElFTkSuQmCC | ||
| 46 | </one:Data></one:Image></one:Page> | ||
corpus/picture-link/cold/read/page-001.xml created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2F5BAA67-B283-0E93-0BEF-3242BD9EA86E}{1}{B0}" name="Image role background=false, printout=false" dateTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><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-06T02:25:45.000Z" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{1D8CE7A0-6949-0F4D-2D02-51EB1E0078DC}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image role background=false, printout=false]]></one:T></one:OE></one:Title><one:Image format="png" alt="Role pattern" originalPageNumber="2" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{1D8CE7A0-6949-0F4D-2D02-51EB1E0078DC}{39}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="288.0" height="192.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec | ||
| 3 | CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI | ||
| 4 | E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp | ||
| 5 | oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT | ||
| 6 | +wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{1D8CE7A0-6949-0F4D-2D02-51EB1E0078DC}{41}{B0}"><one:Position x="72.0" y="108.0" z="1"/><one:Size width="146.7475433349609" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{1D8CE7A0-6949-0F4D-2D02-51EB1E0078DC}{42}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Foreground role control]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-link/cold/read/page-002.xml created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3DBE8FF1-FCD5-05FE-3C1A-2C47F261DC16}{1}{B0}" name="Image role background=true, printout=true" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><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-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{D27133B4-CC86-0FF3-1D02-3535E1C0B3D1}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image role background=true, printout=true]]></one:T></one:OE></one:Title><one:Image format="png" backgroundImage="true" isPrintOut="true" alt="Role pattern" originalPageNumber="2" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{D27133B4-CC86-0FF3-1D02-3535E1C0B3D1}{39}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="288.0" height="192.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec | ||
| 3 | CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI | ||
| 4 | E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp | ||
| 5 | oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT | ||
| 6 | +wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{D27133B4-CC86-0FF3-1D02-3535E1C0B3D1}{41}{B0}"><one:Position x="72.0" y="108.0" z="1"/><one:Size width="146.7475433349609" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{D27133B4-CC86-0FF3-1D02-3535E1C0B3D1}{42}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Foreground role control]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-link/cold/read/page-003.xml created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{4D27D57A-6002-00EF-257A-3D7B07F997BC}{1}{B0}" name="Image bmp" dateTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-30T23:00:09.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-06T02:25:43.000Z" lastModifiedTime="2026-09-06T02:25:43.000Z" objectID="{1C6391D4-C906-07B8-3EDB-2E6DE96118ED}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image bmp]]></one:T></one:OE></one:Title><one:Image format="png" hyperlink="https://example.invalid/image/bmp" alt="Pattern bmp" originalPageNumber="0" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{1C6391D4-C906-07B8-3EDB-2E6DE96118ED}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO | ||
| 3 | xAAADsQBlSsOGwAAARlJREFUeF7t2VFHQ2EcBvD3PWetVqvVam1ERESMiIiIiL7/J5p9gD1dvF51 | ||
| 4 | 8eNcn4uf53n2P1ZLKUOpnkMCtYx0kkAt00F8gkAtsxFQBJpPAEWgxRGgCLScAopAq2NAEWhzAigC | ||
| 5 | 3c0ARaD7U0AR6OEMUAR6nAOKQE/ngCLQ8wWgCLRdAIpAL5eAItDrFaAI9LYEFIHerwFFoI8bQBHo | ||
| 6 | cwUoAn3dAopA32tAEehnAygKjPu/xjyHBAY0WQDQL+0BBKhtYSVIgiSo6w+xiqmYiqnYX34J2SAb | ||
| 7 | ZINskA1qa4EESZAEdW1B15e7g9xBbf2VIAmSICPtDmprgQRJkAR1bUHXl7uD3EFt/ZUgCZIgI+0O | ||
| 8 | amuBBEnQP07QDo1OW207QPoWAAAAAElFTkSuQmCC | ||
| 9 | </one:Data></one:Image></one:Page> | ||
corpus/picture-link/cold/read/page-004.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="{55DF55E0-26A1-0C16-13B3-2B2199955F6D}{1}{B0}" name="Numbering controls" dateTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1" lang="en-US"><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-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Numbering controls]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:50.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="125.0376281738281" height="137.070068359375"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{40}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="0" numberFormat="##" restartNumberingAt="3" font="Calibri" text="3"/></one:List><one:T><![CDATA[Sequence 0, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{46}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="0" numberFormat="##" font="Calibri" text="4"/></one:List><one:T><![CDATA[Sequence 0, item 1]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{52}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="1" numberFormat="##" restartNumberingAt="3" font="Calibri" text="III"/></one:List><one:T><![CDATA[Sequence 1, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{58}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="1" numberFormat="##" font="Calibri" text="IV"/></one:List><one:T><![CDATA[Sequence 1, item 1]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{64}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="2" numberFormat="##" restartNumberingAt="3" font="Calibri" text="iii"/></one:List><one:T><![CDATA[Sequence 2, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{70}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="2" numberFormat="##" font="Calibri" text="iv"/></one:List><one:T><![CDATA[Sequence 2, item 1]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{76}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="3" numberFormat="##" restartNumberingAt="3" font="Calibri" text="C"/></one:List><one:T><![CDATA[Sequence 3, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{82}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="3" numberFormat="##" font="Calibri" text="D"/></one:List><one:T><![CDATA[Sequence 3, item 1]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{88}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="4" numberFormat="##" restartNumberingAt="3" font="Calibri" text="c"/></one:List><one:T><![CDATA[Sequence 4, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{94}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="4" numberFormat="##" font="Calibri" text="d"/></one:List><one:T><![CDATA[Sequence 4, item 1]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-link/cold/read/page-005.xml created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{6F95DBC7-8E09-0A8C-29EE-9E4F78401929}{1}{B0}" name="Image role background=false, printout=true" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><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-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{0AF56AE5-BB07-0230-0A58-C3D41529A053}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image role background=false, printout=true]]></one:T></one:OE></one:Title><one:Image format="png" isPrintOut="true" alt="Role pattern" originalPageNumber="2" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{0AF56AE5-BB07-0230-0A58-C3D41529A053}{39}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="288.0" height="192.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec | ||
| 3 | CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI | ||
| 4 | E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp | ||
| 5 | oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT | ||
| 6 | +wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{0AF56AE5-BB07-0230-0A58-C3D41529A053}{41}{B0}"><one:Position x="72.0" y="108.0" z="1"/><one:Size width="146.7475433349609" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{0AF56AE5-BB07-0230-0A58-C3D41529A053}{42}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Foreground role control]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-link/cold/read/page-006.xml created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{710863F5-A9D2-0E07-3FB8-007CC60AD628}{1}{B0}" name="Paragraph controls" dateTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="Fixture style" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="14.0" strikethrough="true" spaceBefore="0.25" spaceAfter="0.125"/><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-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Paragraph controls]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="480.0" height="87.66204071044922"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{40}{B0}" alignment="left" spaceBefore="9.0" spaceAfter="4.5" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 3 | style='text-decoration:line-through'>Inherited strike and spacing</span>]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{47}{B0}" alignment="center" spaceBefore="12.0" spaceAfter="6.0" quickStyleIndex="0"><one:T><![CDATA[Centered paragraph]]></one:T></one:OE><one:OE RTL="true" creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{52}{B0}" alignment="right" quickStyleIndex="0" style="font-family:Arial;font-size:11.0pt;text-align:right"><one:T><![CDATA[<span | ||
| 4 | style='direction:rtl;unicode-bidi:embed' lang=ar-SA>مرحبا بالعالم</span>]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" collapsed="1" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{58}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Collapsed parent]]></one:T><one:OEChildren indent="2"><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{63}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Hidden child remains stored]]></one:T><one:OEChildren><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{68}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{73}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[AB&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;C]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-link/cold/read/page-007.xml created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{86DA38AE-66AD-012D-0140-0BD7D50D4CD6}{1}{B0}" name="Image png" dateTime="2026-09-06T02:25:40.000Z" lastModifiedTime="2026-09-30T23:00:09.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-06T02:25:42.000Z" lastModifiedTime="2026-09-06T02:25:42.000Z" objectID="{F9849DAF-9E1D-05CB-221D-D71EBD73E5DB}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image png]]></one:T></one:OE></one:Title><one:Image format="png" hyperlink="https://example.invalid/image/png" alt="Pattern png" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{F9849DAF-9E1D-05CB-221D-D71EBD73E5DB}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec | ||
| 3 | CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI | ||
| 4 | E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp | ||
| 5 | oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT | ||
| 6 | +wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image></one:Page> | ||
corpus/picture-link/cold/read/page-008.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="{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}" name="Files and recording" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:MediaPlaylist><one:MediaReference mediaID="{9A418E57-7F64-40C5-87D0-8A8F7C1E5422}"/></one:MediaPlaylist><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-06T02:25:47.000Z" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Files and recording]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="136.6015625" height="139.427719116211"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:47.000Z" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{40}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\7d1ab415-649a-43bd-a863-bbef841467de.txt" pathSource="C:\one-tests\runs\capture\notebook\fixture-data\attachment.txt" preferredName="Fictitious attachment.txt"/></one:OE><one:OE creationTime="2026-09-06T02:25:47.000Z" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{44}{B0}" alignment="left"><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5cdfe8bc-15a0-4ef8-9710-d05c8cbe5118.wav" pathSource="C:\one-tests\runs\capture\notebook\fixture-data\silence.wav" preferredName="silence.wav"><one:MediaReference mediaID="{9A418E57-7F64-40C5-87D0-8A8F7C1E5422}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-06T02:25:47.000Z" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{48}{B0}" alignment="left" quickStyleIndex="0"><one:MediaIndex timeIndex="500"><one:MediaReference mediaID="{9A418E57-7F64-40C5-87D0-8A8F7C1E5422}"/></one:MediaIndex><one:T><![CDATA[Recording annotation]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-link/cold/read/page-009.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="{ADE0856F-5F03-05E7-03D6-EE9AF29EDD7B}{1}{B0}" name="Task controls" dateTime="2026-09-06T02:25:50.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1" lang="en-US"><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-06T02:25:50.000Z" lastModifiedTime="2026-09-06T02:25:50.000Z" objectID="{FF1642F1-31DA-0DD3-27A8-B129400E7A6F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Task controls]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:51.000Z" objectID="{FF1642F1-31DA-0DD3-27A8-B129400E7A6F}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="151.0606994628906" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:50.000Z" lastModifiedTime="2026-09-06T02:25:50.000Z" objectID="{FF1642F1-31DA-0DD3-27A8-B129400E7A6F}{40}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-03T00:00:00.000Z" guidTask="{45AF40E3-1D54-463C-90D3-C454F2F71A2E}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Disabled standalone task]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-link/cold/read/page-010.xml created+300| ... | @@ -0,0 +1,300 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E25EA0DE-E27F-0A28-1A8D-D17E0ECC7E0F}{1}{B0}" name="Image tiff" dateTime="2026-09-06T02:25:43.000Z" lastModifiedTime="2026-09-30T23:00:09.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-06T02:25:44.000Z" lastModifiedTime="2026-09-06T02:25:44.000Z" objectID="{A1DE56A1-CD3B-0637-20D3-DD4DD713D8CA}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image tiff]]></one:T></one:OE></one:Title><one:Image format="png" hyperlink="https://example.invalid/image/tiff" alt="Pattern tiff" originalPageNumber="0" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{A1DE56A1-CD3B-0637-20D3-DD4DD713D8CA}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAAXNSR0IArs4c6QAAAARnQU1BAACx | ||
| 3 | jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAEImSURBVHheDbiBV2GL+v+/f9V1q+uk46TjpDFp | ||
| 4 | TBqDMUhoh3Zoh3Zoh3Zoh3YYgzE0Jo1J46TjpOOk61Yf32pZ1bKqZVXLqpZV/W2/8y8863nez/v1 | ||
| 5 | AgAAaAL+vxagiQQ0twIt7cC/yACpA/h3J9BKBdq6gPZu4D90gNwD/NQLdDABSh/Q2Q/8zAaoA8Av | ||
| 6 | g0AXF6DxgG4B8KsQoIuA3yRAjxRgyIBeBfACBJhK4KUa6IMAlgbo1wGvYICtB14bgQEE4JiAQQvw | ||
| 7 | BgW4VuCtHeBhAN8BCFzAOxwQuoH3XkBEAGIfIAkAQ0FAGgKGI4AsCshjgGIFGEkA4CowugYoU4Bq | ||
| 8 | HVBvAGMZANoExrcATQ7QbgO6HWCiAMC7wOQeoC8Bhn3AeAhMlQHkCJg+AUwVwHwKWM6BmSqAXgCz | ||
| 9 | V4C1BtiuAfstMFcHsDtg/gFwNADnI+B6BhYAoPn/a2puamluJjW3tDb/q72ZRG7+d0dza2dzG7W5 | ||
| 10 | vav5P93NZHrzTz3NHb3NFGZzZ1/zz/3NVHbzLwPNXYPNNG5zN6/5V0EzXdj8m6i5R9LMkDb3yppf | ||
| 11 | KJqZYPNLZXOfupkFNfdrml/pmtlw82t984CxmYM0D5qa31iauWjzW2szz97Mx5oFjuZ3rmYh3vze | ||
| 12 | 3SzyNouJZomveSjQLA02D4eaZZFmebRZEWseWWkGE82jq83KtWZVqlm93jy20Qxlmsc3mzVbzdpc | ||
| 13 | s267eWKnGS40T+426/eaDaVm437z1GEzUm6ePmo2nTSbK82W0+aZ82a02jx70Wy9arbVmu3XzXO3 | ||
| 14 | zVi9ef6u2fHQ7Gw0ux6bF56bcQAgNTX9MxtSC4n0r1YSqZ30bzKptYPU1klqp5L+00Uid5N+opM6 | ||
| 15 | ekiUXlInk/RzH4naT/qFTeoaINEGSd1c0q88El1A+k1I6hGRGBJSr5T0QkZiKkgvQVKfksRSk/oh | ||
| 16 | 0isNia0jvYZJA3oSx0gaREhvTCSuhfQWJfGsJL6dJMBI7xwkoYv0HieJ3CSxlyQhSEM+kjRAGg6S | ||
| 17 | ZCGSPEJSREkjMRK4QhpNkJSrJNUaSZ0ija2ToA3SeIak2SRpt0i6HGlimwTvkCYLJP0uybBHMpZI | ||
| 18 | U/sk5JA0XSaZjkjmE5KlQpo5JaHnpNkqyXpBsl2R7DXS3DUJuyXN10mOO5LzgeRqkBYeSfgzaREA | ||
| 19 | 2pqb2lpa2v5FaiO1tv27va2V3NbW0dbe2fYfahu5q+2n7rYOehulp62zt+1nZhu1r+2X/rYudhtt | ||
| 20 | oK17sO1Xbhud1/aboK1H2MYQtfVK2l5I25iytpeKtj6wjaVs61e3vYLa2Jq217q2AbiNo28bNLa9 | ||
| 21 | Qdq4pra3ljYe2sa3tgnsbe+wNqGj7b2rTYS3id1tEm/bENEm9bUNB9pkwTZ5qE0RaRuJtoGxttGV | ||
| 22 | NmWiTbXapl5rG0u1Qett4xttmkybdrNNt9U2kWuDt9smd9r0hTbDbptxr22q1Ibst00ftpnKbeaj | ||
| 23 | NstJ20ylDT1tmz1vs1bbbBdt9qu2uVobdt02f9vmqLc579pcD20LjTb8sW3xuc0NAOSWJvK/Wsgk | ||
| 24 | EvnfreTWdnIbmdzeQf5PJ5lMJf/URe7oJlPo5M4e8s+9ZCqT/EsfuaufTGOTuwfIvw6S6Vzybzxy | ||
| 25 | j4DMEJJ7ReQXEjJTSn4pI/cpyCyQ3K8kv1KT2RD5tYY8oCNzYPKgnvzGSOYi5LcmMs9C5qNkgZX8 | ||
| 26 | zk4WYuT3DrLIRRbjZImbPOQlSwnysI8sC5DlQbIiRB6JkMEoeTRGVq6QVQmyepU8tkaGUuTxdbJm | ||
| 27 | g6zNkHWb5IktMpwjT26T9TtkQ4Fs3CVP7ZGREnl6n2w6JJvLZMsReeaEjFbIs6dk6znZViXbL8hz | ||
| 28 | V2SsRp6/Jjtuyc462XVHXngg4w3y4iPZ/Uz2AADlX00UUgvl3yRKayulrZ3STqb8p4NC7qT8RKV0 | ||
| 29 | dFEo3ZROOuXnHgq1l/ILk9LVR6H1U7rZlF8HKPRBym9cSg+PwhBQeoWUFyIKU0J5KaX0ySgsBaUf | ||
| 30 | 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 | ||
| 268 | 3Hc52X+pZ18aBi6Ng5dT3EuEdzktuDQJL82iS4vkckZ6icouZxWXVvDSpry0qy/noEtMczmvu3TA | ||
| 269 | l079pct4uYBc4qbLRculG730WC+99ssl7JJwXC67Ln34pd99GfBefiAug77Lj4HLUPAyHLqMRC4/ | ||
| 270 | RS+jscvPK5exxGV89XJl7fJL6jKxfvl143I1c5ncvFzbuvyWu0xtX37fuVwvXKZ3Lzf2Ln+ULjP7 | ||
| 271 | l78fXm6WL7NHl1snl39ULnOnl3+eX25XL/MXlztXl3/VLgvXl3/fXu7WL4t3l3sPl/9tXJYeL//3 | ||
| 272 | fLkPADVFU22kpQaSaqOtNWV7TUWuqTtqY501iFob76ppumtaek3XU5vorcHM2mRfTd9fM7BrxoHa | ||
| 273 | 1GAN4dameTWToGYW1iyi2oykhkprs7KaVVGzgTW7sjanrmFQbV5Tc+hqTrjm0tcWjDUcqS2aam5L | ||
| 274 | zYPWvNbakr1GYLVlR83nqvnxWsBd++CtBYnaR18tFKiFg7VIqPYpUotGa59jtdhKLZ6orazWvqzV | ||
| 275 | /lGBX9drqxu1ZKa2tln7tlVL5Wrft2vrO7V0obaxW/uxV8uUar/v1zYPa9lybeuo9sdJLVep/Xla | ||
| 276 | 2z6v5au1nYvaX1e1Qq3293Vt97ZWrNf27mr/faiVGrX/Pdb2n2sHAHAz0nQDttyMkm6UrTeq9hs1 | ||
| 277 | +Was4wbqvBmn3mi6brTdNzr6zUTPDdx7M8m80ffdGPpvjOybqYEbZPBmmntj4t2YBTcW4c2M6AaV | ||
| 278 | 3MxKb6yyG5vixg7ezClvMPXNPHTj0Nw4dTcu+GZBf4MbbxaRG7fpxmO58aI3S9Ybwn6zjN34HDd+ | ||
| 279 | 100Av/ngvgl6bz4SNyHfTThwEwnefArdRCM3n6M3sdhNfOVmJXHzZfUmsXbzNXWzun6T3LhZy9x8 | ||
| 280 | 27xJbd18z92sb9+kd242Cjc/dm8yeze/l24292+yhzdb5Zs/jm5yJzd/Vm62T2/y5zc71Zu/Lm4K | ||
| 281 | Vzd/1252r2+Ktzd79Zv/3t2UHm7+17jZf7w5eL45BIA62FQfbakrSXVVa13dXh8j16GO+nhnXUOt | ||
| 282 | a7vquu76BL0O99Qne+t6Zt3QVzf216fYdWSgPj1YN3HrZl7dIqjPCOuoqD4rqVuldZusblfU58A6 | ||
| 283 | pqzPq+sOqO7U1F26+gJcx/X1RWPdjdQ9prrXUl9C64S1vmyv+7C631EPuOof8HrQXf/orYeIethX | ||
| 284 | jwTqn4L1aKj+OVKPRevxWH1lpf4lUU+s1r+u1VdT9eR6fW2j/i1TT23Wv2/V13P19HZ9Y6f+o1DP | ||
| 285 | 7NZ/36tvlurZ/frWYf2Pcj13VP/zpL5dqedP6zvn9b+q9cJF/e+r+m6tXryu793W/1uvl+7q/3uo | ||
| 286 | 7zfqB4/1w+f6/wHA/WjTvbLlXkW6V7fej7XfQ+T78Y57Tee9lnqv67qf6L6H6feTPff63nsD897Y | ||
| 287 | dz/Vf4+w76cH7k2D92buvYV3PyO4R4X3s6J7q+TeJr23y+7nFPcYeD+vvHeo753QvUtzv6C7x+H7 | ||
| 288 | Rf2923jvQe69pvslyz2B3i9b7332ez92H3Dcf3DdB/H7j+77kPc+TNxHfPefAvfR4P3n0H0sch+P | ||
| 289 | 3q/E7r+s3CcS919X71fX7pOp+7X1+28b96nM/ffN+/Wt+3TufmP7/sfOfaZw//vu/ebefbZ0v7V/ | ||
| 290 | /8fhfa58/+fR/fbJfb5yv3N6/9f5faF6//fF/e7VfbF2v3d9/9/b+1L9/n939/sP9weN+8PH+/97 | ||
| 291 | vi8DQEPZ1FC1NNSkxlhrA2pvjJMbmo6GtrOhozYmuhpwd2OS3tD3NAy9DSOzMdXXQPob0+yGaaBh | ||
| 292 | HmxYuI0ZXgMVNGaFDauoYZM07NLGnKyBKRrzYMOhbDjVDRfUWNA0cF1jEW649Q2PseFFGkumBmFp | ||
| 293 | LKMNn7XhtzcCWOODoxF0NT7ijZC7EfY2IkTjk68RDTQ+BxuxUCMeaaxEG19ijcRK42uisbraSK41 | ||
| 294 | 1lKNb+uN1Ebje6axvvmPJW1s5Bo/thuZncbvhcbmbiO719gqNf7Yb+QOG3+WG9tHjfxJY6fS+Ou0 | ||
| 295 | UThv/F1t7F40ileNvVrjv9eN0m3jf/XG/l3j4KFx2Gj832Oj/Nz4fwDwpGp6Urc8jZGeoNan8fYn | ||
| 296 | DflJ2/Gk63yaoD7BXU+T3U96+pOh58nY+zTFfEL6nqb7n0zsJ/PAk2XwaYb7hPKeZgVPVuGTTfRk | ||
| 297 | lzzNSZ8w2dO84skBPjmVTy710wL0hGueFnVPbvjJo3/yGp+WkCfC9LRsefKhT37rU8D+9AF7Cjqe | ||
| 298 | PrqeQvhT2P0U8T59Ip6ivqfPgadY8CkeelqJPH2JPiViT19XnlYTT8nVp7W1p2+pp9T60/eNp/XM | ||
| 299 | U3rzaWPr6UfuKbP99PvO02bhKbv7tLX39EfpKbf/9Ofh03b5KX/0tHPy9FflqXD69Pf50271qXjx | ||
| 300 | tHf19N/aU+n66X+3T/v1p4O7p8OHp/9rPJUfn/7f89PR/w8QCy14LJdV2gAAAABJRU5ErkJggg==</one:Data></one:Image></one:Page> | ||
corpus/picture-link/cold/read/page-011.xml created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E4150670-59A6-0ECB-0BDD-D595FA83480D}{1}{B0}" name="Image jpg" dateTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-30T23:00:09.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-06T02:25:42.000Z" lastModifiedTime="2026-09-06T02:25:42.000Z" objectID="{3A28A05F-AE7B-062E-35AE-475132CD13FC}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image jpg]]></one:T></one:OE></one:Title><one:Image format="jpg" hyperlink="https://example.invalid/image/jpg" alt="Pattern jpg" originalPageNumber="0" lastModifiedTime="2026-09-06T02:25:50.000Z" objectID="{3A28A05F-AE7B-062E-35AE-475132CD13FC}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a | ||
| 3 | HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy | ||
| 4 | MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCABAAGADASIA | ||
| 5 | AhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA | ||
| 6 | AAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3 | ||
| 7 | ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm | ||
| 8 | p6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA | ||
| 9 | AwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx | ||
| 10 | BhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK | ||
| 11 | U1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3 | ||
| 12 | uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDwhYfa | ||
| 13 | plh9qsrD7VMsPtUqRFOqVVh9qmWH2qysPtUyw+1UpHbTqlZYfaplh9qsrD7VMsPtVqR206pVWGpl | ||
| 14 | h9qsrDUyw+1UpHbTqlZYalWH2q0sNSrD7VSkdtOqVlh9qmWH2qysPtUyw+1UpHdTqnHrD7VMsPtV | ||
| 15 | lYfaplh9q81SPyqnVKqw+1TLD7VZWH2qZYfaqUjtp1SssPtUyw+1WVh9qmWH2qlI7adUqrDUyw+1 | ||
| 16 | WVhqZYfarUjtp1SssNSrD7VaWGpVh9qpSO2nVKyw+1TLD7VZWH2qZYfaqUjup1Tj1h9qmWH2qysP | ||
| 17 | tUyw+1eapH5VTqlVYfaplh9qsrD7VMsPtVKR206pWWH2qZYfarKw+1TLD7VSkdtOqVVhqZYfarKw | ||
| 18 | 1MsPtVqR206pWWGpVh9qtLDUqw+1UpHbTqlZYfaplh9qsrD7VMsPtVKR206px6w+1TLD7VZWH2qZ | ||
| 19 | YfavNUj8rp1SqsPtUyw+1WVh9qmWH2qlI7adUrLD7VMsPtVlYfaplh9qpSO2nVKqw1MsPtVlYamW | ||
| 20 | H2q1I7adUrLDUqw+1WlhqVYfaqUjtp1SssPtUyw+1WVh9qmWH2qlI7adU//Z | ||
| 21 | </one:Data></one:Image></one:Page> | ||
corpus/picture-link/cold/read/page-012.xml created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{F86A5F48-E893-06F7-2CDE-4540E56F4C1D}{1}{B0}" name="Image role background=true, printout=false" dateTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><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-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{3ED7DAC4-B868-0BA3-1E44-97B162C75586}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image role background=true, printout=false]]></one:T></one:OE></one:Title><one:Image format="png" backgroundImage="true" alt="Role pattern" originalPageNumber="2" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{3ED7DAC4-B868-0BA3-1E44-97B162C75586}{39}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="288.0" height="192.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec | ||
| 3 | CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI | ||
| 4 | E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp | ||
| 5 | oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT | ||
| 6 | +wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{3ED7DAC4-B868-0BA3-1E44-97B162C75586}{41}{B0}"><one:Position x="72.0" y="108.0" z="1"/><one:Size width="146.7475433349609" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{3ED7DAC4-B868-0BA3-1E44-97B162C75586}{42}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Foreground role control]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-link/cold/read/payloads.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "sha256": "4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2", | ||
| 4 | "name": "Fictitious attachment.txt", | ||
| 5 | "object": "{8FA38498-A15A-08DC-3759-A172FAAA260D}{40}{B0}", | ||
| 6 | "kind": "InsertedFile", | ||
| 7 | "page": "{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}", | ||
| 8 | "bytes": 27 | ||
| 9 | }, | ||
| 10 | { | ||
| 11 | "sha256": "56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9", | ||
| 12 | "name": "silence.wav", | ||
| 13 | "object": "{8FA38498-A15A-08DC-3759-A172FAAA260D}{44}{B0}", | ||
| 14 | "kind": "MediaFile", | ||
| 15 | "page": "{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}", | ||
| 16 | "bytes": 16044 | ||
| 17 | } | ||
| 18 | ] | ||
| \ No newline at end of file | |||
corpus/picture-link/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/linklab/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": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" | ||
| 17 | } | ||
| 18 | } | ||
corpus/picture-link/cold/scripts/cold.ps1 created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/picture-link/cold/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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/picture-link/cold/source.json created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Features.one", | ||
| 4 | "bytes": 123080, | ||
| 5 | "sha256": "d1cc5e4deb98245d742488199c676ad1a44edc808a219d9bd9d1856d5b2ccac0", | ||
| 6 | "mtime_ns": 1790809064276439170 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "Open Notebook.onetoc2", | ||
| 10 | "bytes": 1604, | ||
| 11 | "sha256": "be341d46f14d6242c0e2933e88941f157d2071df895730d01be03156592a808c", | ||
| 12 | "mtime_ns": 1790809064276690168 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/picture-link/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
corpus/picture-link/lab/click-selects.png created| Binary files /dev/null and b/corpus/picture-link/lab/click-selects.png differ | |||
corpus/picture-link/lab/ctrl-click.png created| Binary files /dev/null and b/corpus/picture-link/lab/ctrl-click.png differ | |||
corpus/picture-link/lab/hover.png created| Binary files /dev/null and b/corpus/picture-link/lab/hover.png differ | |||
corpus/printout/README.md+7-3| ... | @@ -11,9 +11,13 @@ frames printout pages. | ... | @@ -11,9 +11,13 @@ frames printout pages. |
| 11 | `candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures` | 11 | `candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures` |
| 12 | in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted | 12 | in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted |
| 13 | and the deletion undone, and text typed before the citation, published as one revision. | 13 | and the deletion undone, and text typed before the citation, published as one revision. |
| 14 | Snowbound does not write XPS packages, so the undone page returns as the PNG OneNote | 14 | The undone page is written back as it was read: its XPS package as the PictureContainer, |
| 15 | showed of it. `cold/` is a fresh OneNote 2010 read: the moved page is still a printout, | 15 | under the file-data type OneNote gives XPS packages (0x8003A), the PNG beside it as the |
| 16 | the restored one a picture. `tools/test_printout.py` checks this without a VM. | 16 | WebPictureContainer14, IsPrintout, its page number, and the undocumented properties tying |
| 17 | it to its package, kept as stored; without that type or those properties OneNote reads | ||
| 18 | the page back but draws nothing there. `cold/` is a fresh OneNote 2010 read (with its PDF | ||
| 19 | export): both pages are printouts and both draw, the moved one lower down; the restored | ||
| 20 | page's package is a copy, which OneNote counts as a second (`xpsFileIndex="1"`). `tools/test_printout.py` checks this without a VM. | ||
| 17 | Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while | 21 | Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while |
| 18 | running the test, then cold-open it with | 22 | running the test, then cold-open it with |
| 19 | `tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`. | 23 | `tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`. |
corpus/printout/candidate/Open Notebook.onetoc2| Binary files a/corpus/printout/candidate/Open Notebook.onetoc2 and b/corpus/printout/candidate/Open Notebook.onetoc2 differ | |||
corpus/printout/candidate/Printouts.one| Binary files a/corpus/printout/candidate/Printouts.one and b/corpus/printout/candidate/Printouts.one differ | |||
corpus/printout/cold/commands.jsonl+1-1| ... | @@ -1,3 +1,3 @@ | ... | @@ -1,3 +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} | 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-B402FE00 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 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-02DA9A86 -ExpectedPages 2 -Pdf -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 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} | 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/printout/cold/machine.json+1-1| ... | @@ -1 +1 @@ | ... | @@ -1 +1 @@ |
| 1 | {"name": "m6-b402fe00", "hostname": "ONE-M6-B402FE00"} | 1 | {"name": "m6-02da9a86", "hostname": "ONE-M6-02DA9A86"} |
corpus/printout/cold/notebook/Open Notebook.onetoc2| Binary files a/corpus/printout/cold/notebook/Open Notebook.onetoc2 and b/corpus/printout/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/printout/cold/notebook/Printouts.one| Binary files a/corpus/printout/cold/notebook/Printouts.one and b/corpus/printout/cold/notebook/Printouts.one differ | |||
corpus/printout/cold/read/environment.json+1-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | { | 1 | { |
| 2 | "powershell": "5.1.14409.1005", | 2 | "powershell": "5.1.14409.1005", |
| 3 | "schema": "xs2010", | 3 | "schema": "xs2010", |
| 4 | "hostname": "ONE-M6-B402FE00", | 4 | "hostname": "ONE-M6-02DA9A86", |
| 5 | "cold": true, | 5 | "cold": true, |
| 6 | "onenote": "14.0.4763.1000" | 6 | "onenote": "14.0.4763.1000" |
| 7 | } | 7 | } |
corpus/printout/cold/read/hierarchy.xml+1-1| ... | @@ -1,2 +1,2 @@ | ... | @@ -1,2 +1,2 @@ |
| 1 | <?xml version="1.0"?> | 1 | <?xml version="1.0"?> |
| 2 | <one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{07CD8409-983C-4130-ACB9-C4153BF32885}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T05:02:36.000Z" color="#B49EDE"><one:Section name="Printouts" ID="{054C6BCE-5F57-09F2-0D51-A0B21E9C6778}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Printouts.one" lastModifiedTime="2026-09-30T05:02:36.000Z" color="#F6B078"><one:Page ID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1"/><one:Page ID="{0DFA3DDD-69CE-0060-0F6D-47BCB686B585}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.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="{7FA4C1E2-744A-4FC7-B663-86B24107964E}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T02:16:13.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="Printouts" ID="{85BBB604-70CF-0E0B-2809-9F72823F622C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Printouts.one" lastModifiedTime="2026-10-01T02:16:13.000Z" color="#F6B078" isCurrentlyViewed="true"><one:Page ID="{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1"/><one:Page ID="{8D0DE017-4656-0799-2A35-787C2A25B0D1}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook> |
corpus/printout/cold/read/page-000.navigation.json+1-1| ... | @@ -6,7 +6,7 @@ | ... | @@ -6,7 +6,7 @@ |
| 6 | "h": 600, | 6 | "h": 600, |
| 7 | "error": null, | 7 | "error": null, |
| 8 | "win": { | 8 | "win": { |
| 9 | "title": "Pasted shape - Microsoft OneNote", | 9 | "title": "Printout - Microsoft OneNote", |
| 10 | "class": "Framework::CFrame", | 10 | "class": "Framework::CFrame", |
| 11 | "dialog": false | 11 | "dialog": false |
| 12 | } | 12 | } |
corpus/printout/cold/read/page-000.pdf created| Binary files /dev/null and b/corpus/printout/cold/read/page-000.pdf differ | |||
corpus/printout/cold/read/page-000.png| Binary files a/corpus/printout/cold/read/page-000.png and b/corpus/printout/cold/read/page-000.png differ | |||
corpus/printout/cold/read/page-000.xml+525-73| ... | @@ -1,74 +1,526 @@ | ... | @@ -1,74 +1,526 @@ |
| 1 | <?xml version="1.0"?> | 1 | <?xml version="1.0"?> |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{0DFA3DDD-69CE-0060-0F6D-47BCB686B585}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1" lang="en-US"><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-30T03:34:56.000Z" lastModifiedTime="2026-09-30T03:35:06.000Z" objectID="{15FA1599-679E-03F5-0B27-750F6FE79355}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pasted shape]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{10}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="122.2499847412109" height="95.92770385742187"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:08.000Z" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{18}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="122.25" height="82.49999237060547"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKMAAABuCAIAAADEeDS9AAAAAXNSR0IArs4c6QAAD+tJREFUeF7t | 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:XPSFile xpsFileIndex="0" idDocument="{4061E200-F6FA-4AED-9E31-3DE29839A9AF}"><one:CallbackID callbackID="{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{D108}"/></one:XPSFile><one:XPSFile xpsFileIndex="1" idDocument="{4061E200-F6FA-4AED-9E31-3DE29839A9AF}"><one:CallbackID callbackID="{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{D117}"/></one:XPSFile><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-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:20.000Z" objectID="{6497E6DD-AEB7-0A57-2859-F70A49FCB175}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Printout]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{14}{B0}"><one:Position x="36.0" y="99.16597747802734" z="2"/><one:Size width="303.0612182617187" height="73.98631286621094"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{23}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\f2504f4c-e774-48dd-8e37-fe896574adbe.docx" pathSource="C:\work\printout\inputs\two-pages.docx" preferredName="two-pages.docx"/></one:OE><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{20}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Printed Inserted from: &lt;<a |
| 3 | XX1QVNcVT1ahKgSKgLhM3TVSCBT8AlvwAwgaTIJJBpgkpPmwzaBt1T+EzqgzHeNkEtuppDPqH+pk | 3 | href="file:///C:\work\printout\inputs\two-pages.docx">file://C:\work\printout\inputs\two-pages.docx</a>&gt;]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" xpsFileIndex="0" originalPageNumber="0" isPrintOut="true" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{32}{B0}"><one:Position x="36.0" y="225.352279663086" z="1"/><one:Size width="478.7999572753906" height="619.2000122070312"/><one:Data>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</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T03:35:09.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | 74 | gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg |
| 75 | FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A | ||
| 76 | PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA | ||
| 77 | AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz | ||
| 78 | AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC | ||
| 79 | BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA | ||
| 80 | IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE | ||
| 81 | CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI | ||
| 82 | EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q | ||
| 83 | IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg | ||
| 84 | QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs | ||
| 85 | LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA | ||
| 86 | gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA | ||
| 87 | IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA | ||
| 88 | AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB | ||
| 89 | WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA | ||
| 90 | 8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC | ||
| 91 | BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM | ||
| 92 | DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI | ||
| 93 | ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB | ||
| 94 | gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT | ||
| 95 | IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg | ||
| 96 | QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A | ||
| 97 | gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA | ||
| 98 | AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy | ||
| 99 | uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA | ||
| 100 | AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB | ||
| 101 | g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC | ||
| 102 | BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE | ||
| 103 | YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC | ||
| 104 | xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI | ||
| 105 | ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy | ||
| 106 | P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg | ||
| 107 | QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF | ||
| 108 | DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | ||
| 109 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | ||
| 110 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | ||
| 111 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | ||
| 112 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | ||
| 113 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | ||
| 114 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | ||
| 115 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | ||
| 116 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | ||
| 117 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | ||
| 118 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | ||
| 119 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | ||
| 120 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | ||
| 121 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | ||
| 122 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | ||
| 123 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | ||
| 124 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | ||
| 125 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | ||
| 126 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | ||
| 127 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | ||
| 128 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | ||
| 129 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | ||
| 130 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | ||
| 131 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | ||
| 132 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | ||
| 133 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | ||
| 134 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | ||
| 135 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | ||
| 136 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | ||
| 137 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | ||
| 138 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | ||
| 139 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | ||
| 140 | IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD | ||
| 141 | LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg | ||
| 142 | QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA | ||
| 143 | wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA | ||
| 144 | AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA | ||
| 145 | gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 | ||
| 146 | APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB | ||
| 147 | AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD | ||
| 148 | zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE | ||
| 149 | CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi | ||
| 150 | AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE | ||
| 151 | EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ | ||
| 152 | IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ | ||
| 153 | QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA | ||
| 154 | gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM | ||
| 155 | srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A | ||
| 156 | AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA | ||
| 157 | AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB | ||
| 158 | AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC | ||
| 159 | BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji | ||
| 160 | AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE | ||
| 161 | CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM | ||
| 162 | Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ | ||
| 163 | IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI | ||
| 164 | BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ | ||
| 165 | T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA | ||
| 166 | gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 | ||
| 167 | AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA | ||
| 168 | AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw | ||
| 169 | yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB | ||
| 170 | AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC | ||
| 171 | BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME | ||
| 172 | CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI | ||
| 173 | EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL | ||
| 174 | CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ | ||
| 175 | IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw | ||
| 176 | yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA | ||
| 177 | gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg | ||
| 178 | FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A | ||
| 179 | PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA | ||
| 180 | AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz | ||
| 181 | AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC | ||
| 182 | BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA | ||
| 183 | IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE | ||
| 184 | CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI | ||
| 185 | EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q | ||
| 186 | IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg | ||
| 187 | QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs | ||
| 188 | LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA | ||
| 189 | gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA | ||
| 190 | IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA | ||
| 191 | AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB | ||
| 192 | WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA | ||
| 193 | 8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC | ||
| 194 | BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM | ||
| 195 | DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI | ||
| 196 | ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB | ||
| 197 | gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT | ||
| 198 | IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg | ||
| 199 | QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A | ||
| 200 | gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA | ||
| 201 | AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy | ||
| 202 | uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA | ||
| 203 | AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB | ||
| 204 | g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC | ||
| 205 | BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE | ||
| 206 | YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC | ||
| 207 | xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI | ||
| 208 | ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy | ||
| 209 | P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg | ||
| 210 | QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF | ||
| 211 | DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | ||
| 212 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | ||
| 213 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | ||
| 214 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | ||
| 215 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | ||
| 216 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | ||
| 217 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | ||
| 218 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | ||
| 219 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | ||
| 220 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | ||
| 221 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | ||
| 222 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | ||
| 223 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | ||
| 224 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | ||
| 225 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | ||
| 226 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | ||
| 227 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | ||
| 228 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | ||
| 229 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | ||
| 230 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | ||
| 231 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | ||
| 232 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | ||
| 233 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | ||
| 234 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | ||
| 235 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | ||
| 236 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | ||
| 237 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | ||
| 238 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | ||
| 239 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | ||
| 240 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | ||
| 241 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | ||
| 242 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | ||
| 243 | IEAgFjDI4gLEEyBAgAABAgQWJ4sLPY6I6UcAAAAASUVORK5CYII=</one:Data></one:Image><one:Image format="png" xpsFileIndex="1" originalPageNumber="1" isPrintOut="true" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{39}{B0}"><one:Position x="36.0" y="824.7522583007812" z="0"/><one:Size width="478.7999572753906" height="619.2000732421874"/><one:Data>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 | ||
| 271 | 7sW/4L/oM5577rl//OMf/4FvdgV+4aif/OQnf/rTn770b37C/P3vf3/pn+8zCRAgQIAAgZcuYJC9 | ||
| 272 | iNVf/vKXd73rXT/+8Y//z7/+efe73/3Sce/6zLe+9a0n6tve9rYXCHAG1l3xPJcAAQIECBB4RsAg | ||
| 273 | e/GXxHmb6g1veMP1eX/729+uv5yh9upXv/p62+zrX//647s8Pvh4/+n6Yd/15/HB8+PI8/fzNtUz | ||
| 274 | b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP | ||
| 275 | b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8 | ||
| 276 | 4hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+ | ||
| 277 | febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO | ||
| 278 | uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/ | ||
| 279 | 3/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z | ||
| 280 | 487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh | ||
| 281 | eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO | ||
| 282 | J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq | ||
| 283 | Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9 | ||
| 284 | 25cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j | ||
| 285 | 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</one:Data></one:Image></one:Page> |
corpus/printout/cold/read/page-001.navigation.json+1-1| ... | @@ -6,7 +6,7 @@ | ... | @@ -6,7 +6,7 @@ |
| 6 | "h": 600, | 6 | "h": 600, |
| 7 | "error": null, | 7 | "error": null, |
| 8 | "win": { | 8 | "win": { |
| 9 | "title": "Printout - Microsoft OneNote", | 9 | "title": "Pasted shape - Microsoft OneNote", |
| 10 | "class": "Framework::CFrame", | 10 | "class": "Framework::CFrame", |
| 11 | "dialog": false | 11 | "dialog": false |
| 12 | } | 12 | } |
corpus/printout/cold/read/page-001.pdf created| Binary files /dev/null and b/corpus/printout/cold/read/page-001.pdf differ | |||
corpus/printout/cold/read/page-001.png| Binary files a/corpus/printout/cold/read/page-001.png and b/corpus/printout/cold/read/page-001.png differ | |||
corpus/printout/cold/read/page-001.xml+73-525| ... | @@ -1,526 +1,74 @@ | ... | @@ -1,526 +1,74 @@ |
| 1 | <?xml version="1.0"?> | 1 | <?xml version="1.0"?> |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:XPSFile xpsFileIndex="0" idDocument="{4061E200-F6FA-4AED-9E31-3DE29839A9AF}"><one:CallbackID callbackID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{D629}"/></one:XPSFile><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-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:20.000Z" objectID="{E4603B17-812F-0DAE-0D01-C8CAD55FB421}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Printout]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{14}{B0}"><one:Position x="36.0" y="99.16597747802734" z="2"/><one:Size width="303.0612182617187" height="73.98631286621094"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\f2504f4c-e774-48dd-8e37-fe896574adbe.docx" pathSource="C:\work\printout\inputs\two-pages.docx" preferredName="two-pages.docx"/></one:OE><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{20}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Printed Inserted from: &lt;<a | 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8D0DE017-4656-0799-2A35-787C2A25B0D1}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1" lang="en-US"><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-30T03:34:56.000Z" lastModifiedTime="2026-09-30T03:35:06.000Z" objectID="{950DC853-4806-040C-2E7F-4ACFF3449601}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pasted shape]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{CD69A2EE-E57B-0D07-3F57-90243E3DC2ED}{10}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="122.2499847412109" height="95.92770385742187"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:08.000Z" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{CD69A2EE-E57B-0D07-3F57-90243E3DC2ED}{18}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="122.25" height="82.49999237060547"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKMAAABuCAIAAADEeDS9AAAAAXNSR0IArs4c6QAAD+tJREFUeF7t |
| 3 | href="file:///C:\work\printout\inputs\two-pages.docx">file://C:\work\printout\inputs\two-pages.docx</a>&gt;]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" xpsFileIndex="0" originalPageNumber="0" isPrintOut="true" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{32}{B0}"><one:Position x="36.0" y="225.352279663086" z="1"/><one:Size width="478.7999572753906" height="619.2000122070312"/><one:Data>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</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T03:35:09.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" objectID="{CD69A2EE-E57B-0D07-3F57-90243E3DC2ED}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
| 75 | FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A | ||
| 76 | PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA | ||
| 77 | AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz | ||
| 78 | AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC | ||
| 79 | BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA | ||
| 80 | IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE | ||
| 81 | CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI | ||
| 82 | EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q | ||
| 83 | IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg | ||
| 84 | QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs | ||
| 85 | LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA | ||
| 86 | gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA | ||
| 87 | IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA | ||
| 88 | AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB | ||
| 89 | WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA | ||
| 90 | 8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC | ||
| 91 | BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM | ||
| 92 | DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI | ||
| 93 | ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB | ||
| 94 | gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT | ||
| 95 | IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg | ||
| 96 | QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A | ||
| 97 | gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA | ||
| 98 | AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy | ||
| 99 | uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA | ||
| 100 | AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB | ||
| 101 | g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC | ||
| 102 | BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE | ||
| 103 | YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC | ||
| 104 | xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI | ||
| 105 | ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy | ||
| 106 | P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg | ||
| 107 | QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF | ||
| 108 | DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | ||
| 109 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | ||
| 110 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | ||
| 111 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | ||
| 112 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | ||
| 113 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | ||
| 114 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | ||
| 115 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | ||
| 116 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | ||
| 117 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | ||
| 118 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | ||
| 119 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | ||
| 120 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | ||
| 121 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | ||
| 122 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | ||
| 123 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | ||
| 124 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | ||
| 125 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | ||
| 126 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | ||
| 127 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | ||
| 128 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | ||
| 129 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | ||
| 130 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | ||
| 131 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | ||
| 132 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | ||
| 133 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | ||
| 134 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | ||
| 135 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | ||
| 136 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | ||
| 137 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | ||
| 138 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | ||
| 139 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | ||
| 140 | IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD | ||
| 141 | LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg | ||
| 142 | QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA | ||
| 143 | wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA | ||
| 144 | AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA | ||
| 145 | gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 | ||
| 146 | APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB | ||
| 147 | AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD | ||
| 148 | zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE | ||
| 149 | CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi | ||
| 150 | AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE | ||
| 151 | EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ | ||
| 152 | IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ | ||
| 153 | QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA | ||
| 154 | gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM | ||
| 155 | srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A | ||
| 156 | AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA | ||
| 157 | AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB | ||
| 158 | AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC | ||
| 159 | BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji | ||
| 160 | AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE | ||
| 161 | CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM | ||
| 162 | Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ | ||
| 163 | IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI | ||
| 164 | BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ | ||
| 165 | T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA | ||
| 166 | gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 | ||
| 167 | AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA | ||
| 168 | AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw | ||
| 169 | yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB | ||
| 170 | AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC | ||
| 171 | BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME | ||
| 172 | CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI | ||
| 173 | EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL | ||
| 174 | CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ | ||
| 175 | IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw | ||
| 176 | yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA | ||
| 177 | gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg | ||
| 178 | FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A | ||
| 179 | PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA | ||
| 180 | AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz | ||
| 181 | AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC | ||
| 182 | BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA | ||
| 183 | IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE | ||
| 184 | CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI | ||
| 185 | EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q | ||
| 186 | IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg | ||
| 187 | QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs | ||
| 188 | LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA | ||
| 189 | gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA | ||
| 190 | IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA | ||
| 191 | AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB | ||
| 192 | WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA | ||
| 193 | 8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC | ||
| 194 | BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM | ||
| 195 | DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI | ||
| 196 | ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB | ||
| 197 | gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT | ||
| 198 | IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg | ||
| 199 | QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A | ||
| 200 | gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA | ||
| 201 | AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy | ||
| 202 | uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA | ||
| 203 | AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB | ||
| 204 | g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC | ||
| 205 | BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE | ||
| 206 | YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC | ||
| 207 | xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI | ||
| 208 | ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy | ||
| 209 | P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg | ||
| 210 | QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF | ||
| 211 | DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | ||
| 212 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | ||
| 213 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | ||
| 214 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | ||
| 215 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | ||
| 216 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | ||
| 217 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | ||
| 218 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | ||
| 219 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | ||
| 220 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | ||
| 221 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | ||
| 222 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | ||
| 223 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | ||
| 224 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | ||
| 225 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | ||
| 226 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | ||
| 227 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | ||
| 228 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | ||
| 229 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | ||
| 230 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | ||
| 231 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | ||
| 232 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | ||
| 233 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | ||
| 234 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | ||
| 235 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | ||
| 236 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | ||
| 237 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | ||
| 238 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | ||
| 239 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | ||
| 240 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | ||
| 241 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | ||
| 242 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | ||
| 243 | IEAgFjDI4gLEEyBAgAABAgQWJ4sLPY6I6UcAAAAASUVORK5CYII=</one:Data></one:Image><one:Image format="png" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{39}{B0}"><one:Position x="36.0" y="824.7522583007812" z="0"/><one:Size width="477.3599853515625" height="617.7600708007812"/><one:Data>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 | ||
| 271 | 7sW/4L/oM5577rl//OMf/4FvdgV+4aif/OQnf/rTn770b37C/P3vf3/pn+8zCRAgQIAAgZcuYJC9 | ||
| 272 | iNVf/vKXd73rXT/+8Y//z7/+efe73/3Sce/6zLe+9a0n6tve9rYXCHAG1l3xPJcAAQIECBB4RsAg | ||
| 273 | e/GXxHmb6g1veMP1eX/729+uv5yh9upXv/p62+zrX//647s8Pvh4/+n6Yd/15/HB8+PI8/fzNtUz | ||
| 274 | b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP | ||
| 275 | b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8 | ||
| 276 | 4hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+ | ||
| 277 | febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO | ||
| 278 | uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/ | ||
| 279 | 3/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z | ||
| 280 | 487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh | ||
| 281 | eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO | ||
| 282 | J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq | ||
| 283 | Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9 | ||
| 284 | 25cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j | ||
| 285 | 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</one:Data></one:Image></one:Page> |
corpus/printout/cold/read/payloads.json+2-2| ... | @@ -2,9 +2,9 @@ | ... | @@ -2,9 +2,9 @@ |
| 2 | { | 2 | { |
| 3 | "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7", | 3 | "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7", |
| 4 | "name": "two-pages.docx", | 4 | "name": "two-pages.docx", |
| 5 | "object": "{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}", | 5 | "object": "{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{23}{B0}", |
| 6 | "kind": "InsertedFile", | 6 | "kind": "InsertedFile", |
| 7 | "page": "{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}", | 7 | "page": "{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{B0}", |
| 8 | "bytes": 12589 | 8 | "bytes": 12589 |
| 9 | } | 9 | } |
| 10 | ] | 10 | ] |
| \ No newline at end of file | |||
corpus/printout/cold/run.json+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | { | 1 | { |
| 2 | "notebook": "/private/tmp/unsup-lab/printout-candidate", | 2 | "notebook": "/private/tmp/printlab/candidate", |
| 3 | "expected_pages": 2, | 3 | "expected_pages": 2, |
| 4 | "author": null, | 4 | "author": null, |
| 5 | "author_timeout_seconds": 600, | 5 | "author_timeout_seconds": 600, |
corpus/printout/cold/source.json+5-5| ... | @@ -2,13 +2,13 @@ | ... | @@ -2,13 +2,13 @@ |
| 2 | { | 2 | { |
| 3 | "path": "Open Notebook.onetoc2", | 3 | "path": "Open Notebook.onetoc2", |
| 4 | "bytes": 1612, | 4 | "bytes": 1612, |
| 5 | "sha256": "29bbfd869154aea1720f98cf7c7cc40387313b00c8dfdb61d159f968312367b4", | 5 | "sha256": "fe8a117e418bb9aea7565bd92f01e03f87d763ba60bb5df24ab85b428dbd4f08", |
| 6 | "mtime_ns": 1790744275764608328 | 6 | "mtime_ns": 1790820802979688144 |
| 7 | }, | 7 | }, |
| 8 | { | 8 | { |
| 9 | "path": "Printouts.one", | 9 | "path": "Printouts.one", |
| 10 | "bytes": 268208, | 10 | "bytes": 443488, |
| 11 | "sha256": "5b48da81b1b374da9af23b4dd664b01b100c34be71aef721907f4365afe5577e", | 11 | "sha256": "2be9306af1295605fd3deab3dfd16de2bfdeeecd6b696b48b4a6104b8d997c06", |
| 12 | "mtime_ns": 1790744275764329165 | 12 | "mtime_ns": 1790820802979090020 |
| 13 | } | 13 | } |
| 14 | ] | 14 | ] |
corpus/recycle-purge/README.md created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | # Recycle bin purge | ||
| 2 | |||
| 3 | OneNote 2010 deletes for good what its Notebook Recycle Bin has held "after 60 | ||
| 4 | days" (`DaysToKeepRecycledItems`), a few minutes after it starts. Observed in a lab | ||
| 5 | clone on 2026-09-30, with the clock moved to age pages and sections: | ||
| 6 | |||
| 7 | - it judges by when the content last changed, not when it was deleted: pages | ||
| 8 | created 70 days back and deleted a minute before were purged, a page deleted | ||
| 9 | that day but changed that day was kept; | ||
| 10 | - a page goes from `OneNote_DeletedPages.one` in one appended revision; | ||
| 11 | - a binned section goes as a file once none of its pages changed in 60 days, its | ||
| 12 | file's own times aside; the bin's TOC keeps listing it, unchanged. | ||
| 13 | |||
| 14 | `candidate/` is the notebook | ||
| 15 | `the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days` in | ||
| 16 | `crates/notebook/tests/structure.rs` exports with `NOTEBOOK_PURGE_EXPORT`: a page | ||
| 17 | and a section deleted into the bin, then purged as 61 days on. `cold/` is a fresh | ||
| 18 | OneNote 2010's read: Deleted Pages empty, the section gone, no repair, every file | ||
| 19 | left as written. `tools/test_recycle_purge.py` checks it without a VM. | ||
corpus/recycle-purge/candidate/New Section 1.one created| Binary files /dev/null and b/corpus/recycle-purge/candidate/New Section 1.one differ | |||
corpus/recycle-purge/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one created| Binary files /dev/null and b/corpus/recycle-purge/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one differ | |||
corpus/recycle-purge/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-purge/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 differ | |||
corpus/recycle-purge/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-purge/candidate/Open Notebook.onetoc2 differ | |||
corpus/recycle-purge/cold/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="purge" nickname="purge" ID="{98858EA9-DB12-48A2-B756-EB379B77AA25}{1}{B0}" path="C:\lab\purge" lastModifiedTime="2026-09-30T22:51:49.000Z" color="#FFD700"><one:Section name="New Section 1" ID="{6BE45CC0-74B3-0F1C-3814-D784BA1B84EB}{1}{B0}" path="C:\lab\purge\New Section 1.one" lastModifiedTime="2026-09-30T22:51:49.000Z" color="#8AA8E4"><one:Page ID="{B4E67780-D3AA-0F66-271A-852DFBA50A95}{1}{B0}" name="Kept" dateTime="2026-09-30T22:51:02.000Z" lastModifiedTime="2026-09-30T22:51:49.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="OneNote_RecycleBin" ID="{221540FE-0F55-4BD2-B5B1-B600558FB8F3}{1}{B0}" path="C:\lab\purge\OneNote_RecycleBin" lastModifiedTime="2026-09-30T22:51:20.000Z" isRecycleBin="true"><one:Section name="Deleted Pages" ID="{C10E127F-A313-04F9-2E2E-21B017977A20}{1}{B0}" path="C:\lab\purge\OneNote_RecycleBin\OneNote_DeletedPages.one" lastModifiedTime="2026-09-30T22:51:20.000Z" color="#E1E1E1" isInRecycleBin="true" isDeletedPages="true"/></one:SectionGroup></one:Notebook> | ||
corpus/recycle-purge/cold/recycle-bin.png created| Binary files /dev/null and b/corpus/recycle-purge/cold/recycle-bin.png differ | |||
corpus/section-color/README.md created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | # Section and notebook colour | ||
| 2 | |||
| 3 | `native` is what OneNote 2010 did on the VM (2026-09-30). A section tab's context menu | ||
| 4 | ends with Section Color, sixteen named colours and None (`section-color-menu.png`); | ||
| 5 | `colors.txt` is each as the hierarchy XML reports it, in the menu's order. Picking one | ||
| 6 | appends a revision to the section's own file setting its metadata's `0x14001cbe` | ||
| 7 | (COLORREF; `teal.one`), and None sets `0xFFFFFFFF` (`none.one`); the table of contents is | ||
| 8 | left byte for byte. Notebook Properties (`notebook-properties.png`, from the notebook's | ||
| 9 | Rename… or Properties…) offers the same sixteen without None (`notebook-colors.png`) and | ||
| 10 | appends a revision to the root `Open Notebook.onetoc2` setting its root's `0x14001cbe` | ||
| 11 | (`before.onetoc2`, `after.onetoc2`). Its Display name is written to neither file: OneNote | ||
| 12 | keeps it in its local `OneNoteOfflineCache.onecache`, so the hierarchy's `nickname` | ||
| 13 | changes and `name`, the folder, stays. | ||
| 14 | |||
| 15 | `candidate` is `crates/snowbound/src/menus.rs`'s notebook, exported with | ||
| 16 | `SNOWBOUND_SECTION_COLOR_EXPORT`: a section per colour and None, each made then coloured | ||
| 17 | with `Notebook::set_section_color`, and the notebook coloured Teal with | ||
| 18 | `Notebook::set_color`. `cold` is a fresh OneNote 2010's read: every section and the | ||
| 19 | notebook in the colour it was given. `tools/test_section_color.py` checks it without a VM. | ||
corpus/section-color/candidate/Apple.one created| Binary files /dev/null and b/corpus/section-color/candidate/Apple.one differ | |||
corpus/section-color/candidate/Blue Mist.one created| Binary files /dev/null and b/corpus/section-color/candidate/Blue Mist.one differ | |||
corpus/section-color/candidate/Blue.one created| Binary files /dev/null and b/corpus/section-color/candidate/Blue.one differ | |||
corpus/section-color/candidate/Cyan.one created| Binary files /dev/null and b/corpus/section-color/candidate/Cyan.one differ | |||
corpus/section-color/candidate/Green.one created| Binary files /dev/null and b/corpus/section-color/candidate/Green.one differ | |||
corpus/section-color/candidate/Lemon.one created| Binary files /dev/null and b/corpus/section-color/candidate/Lemon.one differ | |||
corpus/section-color/candidate/Magenta.one created| Binary files /dev/null and b/corpus/section-color/candidate/Magenta.one differ | |||
corpus/section-color/candidate/New Section 1.one created| Binary files /dev/null and b/corpus/section-color/candidate/New Section 1.one differ | |||
corpus/section-color/candidate/None.one created| Binary files /dev/null and b/corpus/section-color/candidate/None.one differ | |||
corpus/section-color/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/section-color/candidate/Open Notebook.onetoc2 differ | |||
corpus/section-color/candidate/Orange.one created| Binary files /dev/null and b/corpus/section-color/candidate/Orange.one differ | |||
corpus/section-color/candidate/Purple Mist.one created| Binary files /dev/null and b/corpus/section-color/candidate/Purple Mist.one differ | |||
corpus/section-color/candidate/Purple.one created| Binary files /dev/null and b/corpus/section-color/candidate/Purple.one differ | |||
corpus/section-color/candidate/Red Chalk.one created| Binary files /dev/null and b/corpus/section-color/candidate/Red Chalk.one differ | |||
corpus/section-color/candidate/Red.one created| Binary files /dev/null and b/corpus/section-color/candidate/Red.one differ | |||
corpus/section-color/candidate/Silver.one created| Binary files /dev/null and b/corpus/section-color/candidate/Silver.one differ | |||
corpus/section-color/candidate/Tan.one created| Binary files /dev/null and b/corpus/section-color/candidate/Tan.one differ | |||
corpus/section-color/candidate/Teal.one created| Binary files /dev/null and b/corpus/section-color/candidate/Teal.one differ | |||
corpus/section-color/candidate/Yellow.one created| Binary files /dev/null and b/corpus/section-color/candidate/Yellow.one differ | |||
corpus/section-color/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-35D6278F -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 18 sections and 18 pages.\r\n", "stderr": "", "error": null} | ||
| 3 | {"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/section-color/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-35d6278f", "hostname": "ONE-M6-35D6278F"} | ||
corpus/section-color/cold/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-35D6278F", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/section-color/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="{DE201FFA-1326-48B5-A3F3-ED7AA61FFD24}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T03:35:50.000Z" color="#4DBCCA"><one:Section name="New Section 1" ID="{F34D2C0C-CEB4-00FE-251C-827EA5EAC473}{1}{B0}" path="C:\one-tests\runs\capture\notebook\New Section 1.one" lastModifiedTime="2026-10-01T03:35:44.000Z" color="#8AA8E4"><one:Page ID="{90D5B5C6-8777-05E1-2EA4-C9948ED4A5BC}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"/></one:Section><one:Section name="Blue" ID="{AADE0D52-BE3E-026A-327F-A3CD5347178B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Blue.one" lastModifiedTime="2026-10-01T03:35:44.000Z" color="#8AA8E4"><one:Page ID="{3239575D-5857-0398-280F-710EAE4FDFB5}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"/></one:Section><one:Section name="Yellow" ID="{DD1F63C2-1935-04F7-3D18-2C6DFE4C8C47}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Yellow.one" lastModifiedTime="2026-10-01T03:35:44.000Z" color="#FFD869"><one:Page ID="{791B4AA8-533F-050A-3BF6-A583EF3BAD0B}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"/></one:Section><one:Section name="Green" ID="{BF5E1568-2CE4-07D7-3F3B-E2A2E7F6FE43}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Green.one" lastModifiedTime="2026-10-01T03:35:44.000Z" color="#B7C997"><one:Page ID="{24D52AE5-5F97-0097-3AF6-922A51E28C12}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"/></one:Section><one:Section name="Red" ID="{AEB0A8AC-CE2A-082E-0A88-F389C27A1BBB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Red.one" lastModifiedTime="2026-10-01T03:35:45.000Z" color="#EE9597"><one:Page ID="{81BDCDF3-EF9E-070A-2434-2A780B71D8F9}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:45.000Z" pageLevel="1"/></one:Section><one:Section name="Purple" ID="{E539F8FA-C896-0C83-2789-9C4B935CD20F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Purple.one" lastModifiedTime="2026-10-01T03:35:45.000Z" color="#B49EDE"><one:Page ID="{E90EECF6-6E48-0632-1940-7E8970A58DC6}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:45.000Z" pageLevel="1"/></one:Section><one:Section name="Cyan" ID="{AF1E4259-EBF2-000B-0088-223A0BC20B32}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Cyan.one" lastModifiedTime="2026-10-01T03:35:46.000Z" color="#91BAAE"><one:Page ID="{BC9979E2-5D95-00B6-2C74-B7A77A233187}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:46.000Z" pageLevel="1"/></one:Section><one:Section name="Orange" ID="{9A592B35-90D3-091F-206E-BE6BF8B5DA83}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Orange.one" lastModifiedTime="2026-10-01T03:35:46.000Z" color="#F6B078"><one:Page ID="{1C88B44F-EC4D-0184-1DB4-3AC18E902D25}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:46.000Z" pageLevel="1"/></one:Section><one:Section name="Magenta" ID="{A2CBA2FC-5BAD-01B7-2665-8449DB67E89F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Magenta.one" lastModifiedTime="2026-10-01T03:35:47.000Z" color="#D5A4BB"><one:Page ID="{9D9C8963-166F-0589-3BAB-A943E8F9DC28}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:47.000Z" pageLevel="1"/></one:Section><one:Section name="Blue Mist" ID="{3E316B71-E3AD-049D-0A91-9AE3725C9D2B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Blue Mist.one" lastModifiedTime="2026-10-01T03:35:47.000Z" color="#9BBBD2"><one:Page ID="{3E7714AB-FD3C-0D84-2BAF-44342145F507}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:47.000Z" pageLevel="1"/></one:Section><one:Section name="Purple Mist" ID="{69597FC7-D44F-0D46-1AA2-7D146F94F975}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Purple Mist.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#AB9CB7"><one:Page ID="{7C39C61C-A0F8-00D5-3893-8297F6742D60}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Tan" ID="{7D747A78-6A7F-0397-1D5B-3CC69A75BCED}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Tan.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#E8D199"><one:Page ID="{8E3AAB5C-B419-0B5F-16E0-1AF5A9438BD6}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Lemon" ID="{ADED87CE-BA73-010E-2722-26E7ACE70859}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Lemon.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#F5F96F"><one:Page ID="{DDB49CEE-9C38-04F0-1E0F-639616393CE1}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Apple" ID="{15D11240-5CF2-09C6-3A16-CD68FF1436F9}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Apple.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#ADE792"><one:Page ID="{DEA8ADE8-2F81-051A-3649-CC8A836EC084}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Teal" ID="{C431D5A9-3F5A-094E-0F1F-08EF1EE8F560}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Teal.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#4DBCCA"><one:Page ID="{C1B5C6E3-76DA-0D31-1416-106902477F66}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Red Chalk" ID="{FF23FB4E-B44C-07CB-0BE6-4C43D9011522}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Red Chalk.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#BA7575"><one:Page ID="{00EDDE16-DA2C-07F0-2C57-EB684E97986D}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Silver" ID="{2EAD8856-08CB-0803-29AC-B4AC54902F50}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Silver.one" lastModifiedTime="2026-10-01T03:35:49.000Z" color="#9595AA"><one:Page ID="{A65346B6-179C-06CD-15B8-2CFDFAEB150F}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:35:49.000Z" pageLevel="1"/></one:Section><one:Section name="None" ID="{8B4D3FA5-96DA-0D09-06EE-D1F568924219}{1}{B0}" path="C:\one-tests\runs\capture\notebook\None.one" lastModifiedTime="2026-10-01T03:35:50.000Z" color="none"><one:Page ID="{BB87BCFA-D952-06DA-0B0B-704A444F1847}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:35:50.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/section-color/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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-000.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-000.png differ | |||
corpus/section-color/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="{00EDDE16-DA2C-07F0-2C57-EB684E97986D}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{00EDDE16-DA2C-07F0-2C57-EB684E97986D}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-001.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-001.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-001.png differ | |||
corpus/section-color/cold/read/page-001.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="{1C88B44F-EC4D-0184-1DB4-3AC18E902D25}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:46.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{1C88B44F-EC4D-0184-1DB4-3AC18E902D25}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-002.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-002.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-002.png differ | |||
corpus/section-color/cold/read/page-002.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="{24D52AE5-5F97-0097-3AF6-922A51E28C12}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{24D52AE5-5F97-0097-3AF6-922A51E28C12}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-003.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-003.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-003.png differ | |||
corpus/section-color/cold/read/page-003.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="{3239575D-5857-0398-280F-710EAE4FDFB5}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{3239575D-5857-0398-280F-710EAE4FDFB5}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-004.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-004.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-004.png differ | |||
corpus/section-color/cold/read/page-004.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="{3E7714AB-FD3C-0D84-2BAF-44342145F507}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{3E7714AB-FD3C-0D84-2BAF-44342145F507}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-005.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-005.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-005.png differ | |||
corpus/section-color/cold/read/page-005.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="{791B4AA8-533F-050A-3BF6-A583EF3BAD0B}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{791B4AA8-533F-050A-3BF6-A583EF3BAD0B}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-006.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-006.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-006.png differ | |||
corpus/section-color/cold/read/page-006.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="{7C39C61C-A0F8-00D5-3893-8297F6742D60}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{7C39C61C-A0F8-00D5-3893-8297F6742D60}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-007.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-007.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-007.png differ | |||
corpus/section-color/cold/read/page-007.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="{81BDCDF3-EF9E-070A-2434-2A780B71D8F9}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:45.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{81BDCDF3-EF9E-070A-2434-2A780B71D8F9}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-008.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-008.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-008.png differ | |||
corpus/section-color/cold/read/page-008.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="{8E3AAB5C-B419-0B5F-16E0-1AF5A9438BD6}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{8E3AAB5C-B419-0B5F-16E0-1AF5A9438BD6}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-009.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-009.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-009.png differ | |||
corpus/section-color/cold/read/page-009.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="{90D5B5C6-8777-05E1-2EA4-C9948ED4A5BC}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{90D5B5C6-8777-05E1-2EA4-C9948ED4A5BC}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-010.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-010.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-010.png differ | |||
corpus/section-color/cold/read/page-010.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="{9D9C8963-166F-0589-3BAB-A943E8F9DC28}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{9D9C8963-166F-0589-3BAB-A943E8F9DC28}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-011.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-011.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-011.png differ | |||
corpus/section-color/cold/read/page-011.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="{A65346B6-179C-06CD-15B8-2CFDFAEB150F}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:35:49.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:29:02.000Z" objectID="{A65346B6-179C-06CD-15B8-2CFDFAEB150F}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-012.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-012.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-012.png differ | |||
corpus/section-color/cold/read/page-012.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="{BB87BCFA-D952-06DA-0B0B-704A444F1847}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:35:50.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:29:02.000Z" objectID="{BB87BCFA-D952-06DA-0B0B-704A444F1847}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-013.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-013.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-013.png differ | |||
corpus/section-color/cold/read/page-013.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="{BC9979E2-5D95-00B6-2C74-B7A77A233187}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:46.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{BC9979E2-5D95-00B6-2C74-B7A77A233187}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-014.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-014.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-014.png differ | |||
corpus/section-color/cold/read/page-014.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="{C1B5C6E3-76DA-0D31-1416-106902477F66}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{C1B5C6E3-76DA-0D31-1416-106902477F66}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-015.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-015.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-015.png differ | |||
corpus/section-color/cold/read/page-015.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="{DDB49CEE-9C38-04F0-1E0F-639616393CE1}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{DDB49CEE-9C38-04F0-1E0F-639616393CE1}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-016.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-016.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-016.png differ | |||
corpus/section-color/cold/read/page-016.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="{DEA8ADE8-2F81-051A-3649-CC8A836EC084}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{DEA8ADE8-2F81-051A-3649-CC8A836EC084}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/page-017.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": "Untitled page - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/section-color/cold/read/page-017.png created| Binary files /dev/null and b/corpus/section-color/cold/read/page-017.png differ | |||
corpus/section-color/cold/read/page-017.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="{E90EECF6-6E48-0632-1940-7E8970A58DC6}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:45.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{E90EECF6-6E48-0632-1940-7E8970A58DC6}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page> | ||
corpus/section-color/cold/read/payloads.json created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/section-color/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/snowbound-properties/section-color/candidate", | ||
| 3 | "expected_pages": -1, | ||
| 4 | "author": null, | ||
| 5 | "author_timeout_seconds": 600, | ||
| 6 | "inspect": false, | ||
| 7 | "collect_notebook": false, | ||
| 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/section-color/cold/scripts/cold.ps1 created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/section-color/cold/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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/section-color/cold/source.json created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Apple.one", | ||
| 4 | "bytes": 6352, | ||
| 5 | "sha256": "0bf6021d0904aa6e281574ce50db4f24d5bb94980140b444e21b9720e9d4c0d4", | ||
| 6 | "mtime_ns": 1790825341918454713 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "Blue Mist.one", | ||
| 10 | "bytes": 6352, | ||
| 11 | "sha256": "d9f3430fc9d4671a22a62a5575513df6cb710376d709e6bff47fab2598a7cc15", | ||
| 12 | "mtime_ns": 1790825341692411886 | ||
| 13 | }, | ||
| 14 | { | ||
| 15 | "path": "Blue.one", | ||
| 16 | "bytes": 6352, | ||
| 17 | "sha256": "10811cc79043e79e26a908c4974a6f8b8df923223a509d2673adbe0b9b060f1d", | ||
| 18 | "mtime_ns": 1790825341319295896 | ||
| 19 | }, | ||
| 20 | { | ||
| 21 | "path": "Cyan.one", | ||
| 22 | "bytes": 6352, | ||
| 23 | "sha256": "d51a7b08f6cda049ea8209174498d50283c7ec9dc0a63583b35029aeb0c4e578", | ||
| 24 | "mtime_ns": 1790825341540263286 | ||
| 25 | }, | ||
| 26 | { | ||
| 27 | "path": "Green.one", | ||
| 28 | "bytes": 6352, | ||
| 29 | "sha256": "470c4d19a7ca9d03c1243604bb8d4142feb5ca33c1e9244c3732f0275b02315d", | ||
| 30 | "mtime_ns": 1790825341404235629 | ||
| 31 | }, | ||
| 32 | { | ||
| 33 | "path": "Lemon.one", | ||
| 34 | "bytes": 6352, | ||
| 35 | "sha256": "a30ee07c1d1eaabd7d8ac2dd5ba93a12d62db8ca5602f11ef9cdaf2127461d45", | ||
| 36 | "mtime_ns": 1790825341865471198 | ||
| 37 | }, | ||
| 38 | { | ||
| 39 | "path": "Magenta.one", | ||
| 40 | "bytes": 6352, | ||
| 41 | "sha256": "d2fb77176b7b891f822e9b4e0a86071c811bcef4655e066c66aaf101661eead1", | ||
| 42 | "mtime_ns": 1790825341641456588 | ||
| 43 | }, | ||
| 44 | { | ||
| 45 | "path": "New Section 1.one", | ||
| 46 | "bytes": 6000, | ||
| 47 | "sha256": "8f9614276e8f8a60f681660b2015c0f2f59428eedef503087c5aea8874d7fc7b", | ||
| 48 | "mtime_ns": 1790825341261553569 | ||
| 49 | }, | ||
| 50 | { | ||
| 51 | "path": "None.one", | ||
| 52 | "bytes": 6352, | ||
| 53 | "sha256": "d61d1899f38e371b042d40641bc7bd1ba6eadf78b205bb20bcd1a9470e5eff53", | ||
| 54 | "mtime_ns": 1790825342154418500 | ||
| 55 | }, | ||
| 56 | { | ||
| 57 | "path": "Open Notebook.onetoc2", | ||
| 58 | "bytes": 14552, | ||
| 59 | "sha256": "61b231025ae93c0242bd59468e14f493ccb24c231c59fcb2fae28ad4c6efc8a6", | ||
| 60 | "mtime_ns": 1790825342179576107 | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | "path": "Orange.one", | ||
| 64 | "bytes": 6352, | ||
| 65 | "sha256": "1635f1fb9340b69e5abe82ef00bc99df332d83708187f44f573f5d887630c374", | ||
| 66 | "mtime_ns": 1790825341589404452 | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | "path": "Purple Mist.one", | ||
| 70 | "bytes": 6352, | ||
| 71 | "sha256": "aa06592b69ced1ddd935d625eba1c4e02fd084e9c032ba5976226cf7a59cbc21", | ||
| 72 | "mtime_ns": 1790825341748511081 | ||
| 73 | }, | ||
| 74 | { | ||
| 75 | "path": "Purple.one", | ||
| 76 | "bytes": 6352, | ||
| 77 | "sha256": "4ade0b3953c99a4ab8e29a7855370c9a3bf1b9e06c359fd2f523abf167dc8797", | ||
| 78 | "mtime_ns": 1790825341493283421 | ||
| 79 | }, | ||
| 80 | { | ||
| 81 | "path": "Red Chalk.one", | ||
| 82 | "bytes": 6352, | ||
| 83 | "sha256": "1e9c778c50a963c66ab375e1e8f67cff05e2a11d95a5a89abf0dfa09dcc6d05a", | ||
| 84 | "mtime_ns": 1790825342043389615 | ||
| 85 | }, | ||
| 86 | { | ||
| 87 | "path": "Red.one", | ||
| 88 | "bytes": 6352, | ||
| 89 | "sha256": "f17f0e4de9947c3b40567f8ba60a9ec3f127c1bb7dfdc55b778e7b08e1f3b60d", | ||
| 90 | "mtime_ns": 1790825341447219727 | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | "path": "Silver.one", | ||
| 94 | "bytes": 6352, | ||
| 95 | "sha256": "aa854f8b8c8333ae2d274d9a17333bb3a5c7c62bb35e69da1640385df3904101", | ||
| 96 | "mtime_ns": 1790825342101432402 | ||
| 97 | }, | ||
| 98 | { | ||
| 99 | "path": "Tan.one", | ||
| 100 | "bytes": 6352, | ||
| 101 | "sha256": "236d18dff5679e7bf10c108c44d18ab8df1d01cf860c2bd94b0d74fdb0c81015", | ||
| 102 | "mtime_ns": 1790825341806399157 | ||
| 103 | }, | ||
| 104 | { | ||
| 105 | "path": "Teal.one", | ||
| 106 | "bytes": 6352, | ||
| 107 | "sha256": "fbf08e85eee0b224bb76b82e3541fee9abe033bc04dd7a6abb09a8c0ae774e5f", | ||
| 108 | "mtime_ns": 1790825341979577971 | ||
| 109 | }, | ||
| 110 | { | ||
| 111 | "path": "Yellow.one", | ||
| 112 | "bytes": 6352, | ||
| 113 | "sha256": "a30a5231b8ec1a0d603c325694f538e432e6095d601dbcce002c2e663b42f3f7", | ||
| 114 | "mtime_ns": 1790825341361229906 | ||
| 115 | } | ||
| 116 | ] | ||
corpus/section-color/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
corpus/section-color/native/after.onetoc2 created| Binary files /dev/null and b/corpus/section-color/native/after.onetoc2 differ | |||
corpus/section-color/native/before.onetoc2 created| Binary files /dev/null and b/corpus/section-color/native/before.onetoc2 differ | |||
corpus/section-color/native/colors.txt created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | Blue #8AA8E4 | ||
| 2 | Yellow #FFD869 | ||
| 3 | Green #B7C997 | ||
| 4 | Red #EE9597 | ||
| 5 | Purple #B49EDE | ||
| 6 | Cyan #91BAAE | ||
| 7 | Orange #F6B078 | ||
| 8 | Magenta #D5A4BB | ||
| 9 | Blue Mist #9BBBD2 | ||
| 10 | Purple Mist #AB9CB7 | ||
| 11 | Tan #E8D199 | ||
| 12 | Lemon #F5F96F | ||
| 13 | Apple #ADE792 | ||
| 14 | Teal #4DBCCA | ||
| 15 | Red Chalk #BA7575 | ||
| 16 | Silver #9595AA | ||
| 17 | None none | ||
corpus/section-color/native/none.one created| Binary files /dev/null and b/corpus/section-color/native/none.one differ | |||
corpus/section-color/native/notebook-colors.png created| Binary files /dev/null and b/corpus/section-color/native/notebook-colors.png differ | |||
corpus/section-color/native/notebook-menu.png created| Binary files /dev/null and b/corpus/section-color/native/notebook-menu.png differ | |||
corpus/section-color/native/notebook-properties.png created| Binary files /dev/null and b/corpus/section-color/native/notebook-properties.png differ | |||
corpus/section-color/native/section-color-menu.png created| Binary files /dev/null and b/corpus/section-color/native/section-color-menu.png differ | |||
corpus/section-color/native/section-menu.png created| Binary files /dev/null and b/corpus/section-color/native/section-menu.png differ | |||
corpus/section-color/native/teal.one created| Binary files /dev/null and b/corpus/section-color/native/teal.one differ | |||
corpus/styles/README.md created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | # Styles and themes | ||
| 2 | |||
| 3 | `onenote/` is OneNote 2010 applying its own Styles gallery in the lab (2026-09-30): | ||
| 4 | `Styles.one` holds a page with each of the eleven styles applied (Ctrl+Alt+1 to 6 and the | ||
| 5 | gallery), Enter after each, a style over explicit formatting, Clear Formatting, and a second | ||
| 6 | page whose styles were made through COM in Georgia, onto which OneNote added its own `h1` | ||
| 7 | and into which a pasted Georgia heading brought its own; `applied.xml` is the first page's | ||
| 8 | COM read. Applying a style stores a paragraph style object per style per page and clears | ||
| 9 | the paragraph's explicit character formatting; headings carry NextStyle `p`. | ||
| 10 | |||
| 11 | `candidate/` is the notebook `themed_pages_hold_their_theme_under_onenote_s_names` in | ||
| 12 | `crates/snowbound/src/themes.rs` writes: a section per built-in theme (OneNote, Manuscript, | ||
| 13 | Editorial, Modern), each a page with a paragraph in every gallery style applied through the | ||
| 14 | editor, then restyled as opening the page does, and `.snowbound/themes.json` giving each | ||
| 15 | section its theme. `cold/` is `tools/native_styles.py` in a fresh OneNote 2010 clone: every | ||
| 16 | page draws in its theme (`read/page-00N.png`) and lists the eleven styles under OneNote's | ||
| 17 | names with the theme's values, and no `.snowbound` section group appears. | ||
| 18 | |||
| 19 | `onenote-edit/` is OneNote 2010 editing the Manuscript page of `candidate/` in the lab: | ||
| 20 | Ctrl+Alt+2 on the Normal paragraph and Enter after it, and Enter after the theme's Heading | ||
| 21 | 5. OneNote applies its own `h2` and `p` beside the theme's (`Manuscript.png`); reopened in | ||
| 22 | Snowbound they read under those names and restyling heals them | ||
| 23 | (`styles_onenote_adds_to_a_themed_page_are_healed`). | ||
| 24 | |||
| 25 | `tools/test_styles.py` checks this without a VM. Regenerate with `SNOWBOUND_STYLES_EXPORT` | ||
| 26 | set to a new absolute directory while running the test, then `tools/native_styles.py OUTPUT | ||
| 27 | COLD`. | ||
corpus/styles/candidate/.snowbound/themes.json created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | { | ||
| 2 | "themes": [], | ||
| 3 | "assignments": [ | ||
| 4 | { | ||
| 5 | "scope": { | ||
| 6 | "section": "125e8f01c6ee404b96cc675d00ceb659" | ||
| 7 | }, | ||
| 8 | "theme": "onenote", | ||
| 9 | "assigned": 134000000000000000 | ||
| 10 | }, | ||
| 11 | { | ||
| 12 | "scope": { | ||
| 13 | "section": "af4f8ea23c5f7e41838ac7cbea473590" | ||
| 14 | }, | ||
| 15 | "theme": "manuscript", | ||
| 16 | "assigned": 134000000000000000 | ||
| 17 | }, | ||
| 18 | { | ||
| 19 | "scope": { | ||
| 20 | "section": "18af2996b565454c971191d887028fc8" | ||
| 21 | }, | ||
| 22 | "theme": "editorial", | ||
| 23 | "assigned": 134000000000000000 | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | "scope": { | ||
| 27 | "section": "f0ca724b3a9d3d4b82bc6f28bd77966c" | ||
| 28 | }, | ||
| 29 | "theme": "modern", | ||
| 30 | "assigned": 134000000000000000 | ||
| 31 | } | ||
| 32 | ] | ||
| 33 | } | ||
| \ No newline at end of file | |||
corpus/styles/candidate/Editorial.one created| Binary files /dev/null and b/corpus/styles/candidate/Editorial.one differ | |||
corpus/styles/candidate/Manuscript.one created| Binary files /dev/null and b/corpus/styles/candidate/Manuscript.one differ | |||
corpus/styles/candidate/Modern.one created| Binary files /dev/null and b/corpus/styles/candidate/Modern.one differ | |||
corpus/styles/candidate/OneNote.one created| Binary files /dev/null and b/corpus/styles/candidate/OneNote.one differ | |||
corpus/styles/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/styles/candidate/Open Notebook.onetoc2 differ | |||
corpus/styles/cold/commands.jsonl created+4| ... | @@ -0,0 +1,4 @@ | ||
| 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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-222E5D78", "exit": 0, "stdout": "", "stderr": "", "error": null} | ||
| 3 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-222E5D78 -ExpectedPages 4 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 4 sections and 4 pages.\r\n", "stderr": "", "error": null} | ||
| 4 | {"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/styles/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-222e5d78", "hostname": "ONE-M6-222E5D78"} | ||
corpus/styles/cold/notebook/Editorial.one created| Binary files /dev/null and b/corpus/styles/cold/notebook/Editorial.one differ | |||
corpus/styles/cold/notebook/Manuscript.one created| Binary files /dev/null and b/corpus/styles/cold/notebook/Manuscript.one differ | |||
corpus/styles/cold/notebook/Modern.one created| Binary files /dev/null and b/corpus/styles/cold/notebook/Modern.one differ | |||
corpus/styles/cold/notebook/OneNote.one created| Binary files /dev/null and b/corpus/styles/cold/notebook/OneNote.one differ | |||
corpus/styles/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/styles/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/styles/cold/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-222E5D78", | ||
| 5 | "cold": false, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/styles/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="{271A6096-75F2-4406-AB00-6825469A9737}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#B49EDE"><one:Section name="OneNote" ID="{E3C86EF0-C031-09EE-1345-96A18B2CCE8A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote.one" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#8AA8E4"><one:Page ID="{20A092AA-72F9-0EF1-1B68-BAD626A1C2D1}{1}{B0}" name="Heading 1 in OneNote" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1"/></one:Section><one:Section name="Manuscript" ID="{93F9DCCA-6F22-0CBF-3CA8-63D0B1903566}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Manuscript.one" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#8AA8E4"><one:Page ID="{1B38B1D2-2CD4-06B9-39CE-B67CE50AF7B2}{1}{B0}" name="Heading 1 in Manuscript" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1"/></one:Section><one:Section name="Editorial" ID="{37BA4275-BBA9-09E8-1F3F-655C55899D0A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Editorial.one" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#8AA8E4"><one:Page ID="{7DD8EF42-6F86-057F-01F3-CC4506AB6443}{1}{B0}" name="Heading 1 in Editorial" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1"/></one:Section><one:Section name="Modern" ID="{349DBF5A-9B2C-06CE-32B2-EEF937D83CD2}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Modern.one" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#8AA8E4"><one:Page ID="{9A9B6AA5-46CF-05FF-3E17-6A76C011C454}{1}{B0}" name="Heading 1 in Modern" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/styles/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": "Heading 1 in Manuscript - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/styles/cold/read/page-000.png created| Binary files /dev/null and b/corpus/styles/cold/read/page-000.png differ | |||
corpus/styles/cold/read/page-000.xml created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1B38B1D2-2CD4-06B9-39CE-B67CE50AF7B2}{1}{B0}" name="Heading 1 in Manuscript" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="20.0" spaceBefore="0.333333343267441" spaceAfter="0.111111111938953"/><one:QuickStyleDef index="1" name="h2" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="16.0" bold="true" spaceBefore="0.277777791023254" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="2" name="h3" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="13.0" bold="true" spaceBefore="0.222222223877907" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="3" name="h4" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" bold="true" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h6" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="PageTitle" fontColor="#1F1A17" highlightColor="automatic" font="Georgia" fontSize="26.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="cite" fontColor="#7A6E66" highlightColor="automatic" font="Georgia" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="blockquote" fontColor="#6B5E55" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.0" spaceAfter="0.111111111938953"/><one:QuickStyleDef index="9" name="code" fontColor="#2B2B2B" highlightColor="automatic" font="Courier New" fontSize="10.5" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="p" fontColor="#2B2B2B" highlightColor="automatic" font="Georgia" fontSize="12.0" spaceBefore="0.0" spaceAfter="0.111111111938953"/><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="{93F9DCCA-6F22-0CBF-3CA8-63D0B1903566}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="321.6620483398437" height="215.5119476318359"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{93F9DCCA-6F22-0CBF-3CA8-63D0B1903566}{24}{B0}" alignment="left" spaceBefore="12.0" spaceAfter="4.0" quickStyleIndex="0"><one:T><![CDATA[Heading 1 in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{44}{B0}" alignment="left" spaceBefore="10.0" spaceAfter="2.0" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 3 | style='font-weight:bold'>Heading 2 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{48}{B0}" alignment="left" spaceBefore="8.0" spaceAfter="2.0" quickStyleIndex="2"><one:T><![CDATA[<span | ||
| 4 | style='font-weight:bold'>Heading 3 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{51}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="3"><one:T><![CDATA[<span | ||
| 5 | style='font-weight:bold;font-style:italic'>Heading 4 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{54}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="4"><one:T><![CDATA[Heading 5 in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{57}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="5"><one:T><![CDATA[<span | ||
| 6 | style='font-style:italic'>Heading 6 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{60}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[Page Title in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{63}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Citation in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{66}{B0}" alignment="left" spaceAfter="4.0" quickStyleIndex="8"><one:T><![CDATA[<span | ||
| 7 | style='font-style:italic'>Quote in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{69}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[Code in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{72}{B0}" alignment="left" spaceAfter="4.0" quickStyleIndex="10"><one:T><![CDATA[Normal in Manuscript]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/styles/cold/read/page-001.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": "Heading 1 in OneNote - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/styles/cold/read/page-001.png created| Binary files /dev/null and b/corpus/styles/cold/read/page-001.png differ | |||
corpus/styles/cold/read/page-001.xml created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{20A092AA-72F9-0EF1-1B68-BAD626A1C2D1}{1}{B0}" name="Heading 1 in OneNote" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#17365D" highlightColor="automatic" font="Calibri" fontSize="16.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="h2" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="13.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="2" name="h3" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="3" name="h4" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" bold="true" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h6" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="blockquote" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="11.0" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="9" name="code" fontColor="automatic" highlightColor="automatic" font="Courier New" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" 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:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{E3C86EF0-C031-09EE-1345-96A18B2CCE8A}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="190.4754333496094" height="160.1658935546875"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{E3C86EF0-C031-09EE-1345-96A18B2CCE8A}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<span | ||
| 3 | style='font-weight:bold'>Heading 1 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{2}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 4 | style='font-weight:bold'>Heading 2 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{6}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[<span | ||
| 5 | style='font-weight:bold'>Heading 3 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{9}{B0}" alignment="left" quickStyleIndex="3"><one:T><![CDATA[<span | ||
| 6 | style='font-weight:bold;font-style:italic'>Heading 4 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{12}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[Heading 5 in OneNote]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{15}{B0}" alignment="left" quickStyleIndex="5"><one:T><![CDATA[<span | ||
| 7 | style='font-style:italic'>Heading 6 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{18}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[Page Title in OneNote]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{21}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Citation in OneNote]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{24}{B0}" alignment="left" quickStyleIndex="8"><one:T><![CDATA[<span | ||
| 8 | style='font-style:italic'>Quote in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{27}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[Code in OneNote]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{30}{B0}" alignment="left" quickStyleIndex="10"><one:T><![CDATA[Normal in OneNote]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/styles/cold/read/page-002.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": "Heading 1 in Editorial - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/styles/cold/read/page-002.png created| Binary files /dev/null and b/corpus/styles/cold/read/page-002.png differ | |||
corpus/styles/cold/read/page-002.xml created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{7DD8EF42-6F86-057F-01F3-CC4506AB6443}{1}{B0}" name="Heading 1 in Editorial" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#9A3B1F" highlightColor="automatic" font="Georgia" fontSize="18.0" bold="true" spaceBefore="0.333333343267441" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="1" name="h2" fontColor="#202020" highlightColor="automatic" font="Georgia" fontSize="14.0" bold="true" spaceBefore="0.277777791023254" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="2" name="h3" fontColor="#202020" highlightColor="automatic" font="Georgia" fontSize="12.0" bold="true" spaceBefore="0.222222223877907" spaceAfter="0.0"/><one:QuickStyleDef index="3" name="h4" fontColor="#202020" highlightColor="automatic" font="Georgia" fontSize="11.0" bold="true" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#9A3B1F" highlightColor="automatic" font="Georgia" fontSize="11.0" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h6" fontColor="#9A3B1F" highlightColor="automatic" font="Georgia" fontSize="11.0" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="PageTitle" fontColor="#202020" highlightColor="automatic" font="Georgia" fontSize="24.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="cite" fontColor="#707070" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="blockquote" fontColor="#555555" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.0" spaceAfter="0.0833333358168602"/><one:QuickStyleDef index="9" name="code" fontColor="#262626" highlightColor="automatic" font="Courier New" fontSize="10.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="p" fontColor="#262626" highlightColor="automatic" font="Calibri" fontSize="11.5" spaceBefore="0.0" spaceAfter="0.0833333358168602"/><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="{37BA4275-BBA9-09E8-1F3F-655C55899D0A}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="271.0546264648437" height="203.7465362548828"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{37BA4275-BBA9-09E8-1F3F-655C55899D0A}{24}{B0}" alignment="left" spaceBefore="12.0" spaceAfter="2.0" quickStyleIndex="0"><one:T><![CDATA[<span | ||
| 3 | style='font-weight:bold'>Heading 1 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{86}{B0}" alignment="left" spaceBefore="10.0" spaceAfter="2.0" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 4 | style='font-weight:bold'>Heading 2 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{90}{B0}" alignment="left" spaceBefore="8.0" quickStyleIndex="2"><one:T><![CDATA[<span | ||
| 5 | style='font-weight:bold'>Heading 3 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{93}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="3"><one:T><![CDATA[<span | ||
| 6 | style='font-weight:bold;font-style:italic'>Heading 4 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{96}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="4"><one:T><![CDATA[Heading 5 in Editorial]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{99}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="5"><one:T><![CDATA[<span | ||
| 7 | style='font-style:italic'>Heading 6 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{102}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[Page Title in Editorial]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{105}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Citation in Editorial]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{108}{B0}" alignment="left" spaceAfter="3.0" quickStyleIndex="8"><one:T><![CDATA[<span | ||
| 8 | style='font-style:italic'>Quote in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{111}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[Code in Editorial]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{114}{B0}" alignment="left" spaceAfter="3.0" quickStyleIndex="10"><one:T><![CDATA[Normal in Editorial]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/styles/cold/read/page-003.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": "Heading 1 in Modern - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/styles/cold/read/page-003.png created| Binary files /dev/null and b/corpus/styles/cold/read/page-003.png differ | |||
corpus/styles/cold/read/page-003.xml created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9A9B6AA5-46CF-05FF-3E17-6A76C011C454}{1}{B0}" name="Heading 1 in Modern" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#0E6E6E" highlightColor="automatic" font="Arial" fontSize="16.0" bold="true" spaceBefore="0.277777791023254" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="1" name="h2" fontColor="#1B2631" highlightColor="automatic" font="Arial" fontSize="13.0" bold="true" spaceBefore="0.222222223877907" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="2" name="h3" fontColor="#1B2631" highlightColor="automatic" font="Arial" fontSize="11.0" bold="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="3" name="h4" fontColor="#1B2631" highlightColor="automatic" font="Arial" fontSize="10.5" bold="true" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#0E6E6E" highlightColor="automatic" font="Arial" fontSize="10.5" bold="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h6" fontColor="#0E6E6E" highlightColor="automatic" font="Arial" fontSize="10.5" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="PageTitle" fontColor="#1B2631" highlightColor="automatic" font="Arial" fontSize="22.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="cite" fontColor="#7B868C" highlightColor="automatic" font="Arial" fontSize="8.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="blockquote" fontColor="#5F6B73" highlightColor="automatic" font="Arial" fontSize="10.5" italic="true" spaceBefore="0.0" spaceAfter="0.0833333358168602"/><one:QuickStyleDef index="9" name="code" fontColor="#1B2631" highlightColor="automatic" font="Courier New" fontSize="10.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="p" fontColor="#2E2E2E" highlightColor="automatic" font="Arial" fontSize="10.5" spaceBefore="0.0" spaceAfter="0.0833333358168602"/><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="{349DBF5A-9B2C-06CE-32B2-EEF937D83CD2}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="254.8837738037109" height="183.18359375"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{349DBF5A-9B2C-06CE-32B2-EEF937D83CD2}{24}{B0}" alignment="left" spaceBefore="10.0" spaceAfter="2.0" quickStyleIndex="0"><one:T><![CDATA[<span | ||
| 3 | style='font-weight:bold'>Heading 1 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{128}{B0}" alignment="left" spaceBefore="8.0" spaceAfter="2.0" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 4 | style='font-weight:bold'>Heading 2 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{132}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="2"><one:T><![CDATA[<span | ||
| 5 | style='font-weight:bold'>Heading 3 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{135}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="3"><one:T><![CDATA[<span | ||
| 6 | style='font-weight:bold;font-style:italic'>Heading 4 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{138}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="4"><one:T><![CDATA[<span | ||
| 7 | style='font-weight:bold'>Heading 5 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{141}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="5"><one:T><![CDATA[<span | ||
| 8 | style='font-style:italic'>Heading 6 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{144}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[<span | ||
| 9 | style='font-weight:bold'>Page Title in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{147}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Citation in Modern]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{150}{B0}" alignment="left" spaceAfter="3.0" quickStyleIndex="8"><one:T><![CDATA[<span | ||
| 10 | style='font-style:italic'>Quote in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{153}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[Code in Modern]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{156}{B0}" alignment="left" spaceAfter="3.0" quickStyleIndex="10"><one:T><![CDATA[Normal in Modern]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/styles/cold/read/payloads.json created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/styles/cold/run.json created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/snowbound-styles/candidate", | ||
| 3 | "expected_pages": 4, | ||
| 4 | "author": "/Users/clo/dev/one/tools/native/styles.ps1", | ||
| 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 | "author.ps1": "fee6de8f8db4c294d9b39f5cf6428fa6031b52b9158470a3f0aeaf82ef64bfa8", | ||
| 16 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | ||
| 17 | "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" | ||
| 18 | } | ||
| 19 | } | ||
corpus/styles/cold/scripts/author.ps1 created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost | ||
| 5 | # Snowbound makes its folder hidden, which the transfer's archive does not keep. | ||
| 6 | $sidecar = Get-Item -LiteralPath (Join-Path $Root 'notebook\.snowbound') -Force | ||
| 7 | $sidecar.Attributes = $sidecar.Attributes -bor [IO.FileAttributes]::Hidden | ||
corpus/styles/cold/scripts/cold.ps1 created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/styles/cold/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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/styles/cold/source.json created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": ".snowbound/themes.json", | ||
| 4 | "bytes": 669, | ||
| 5 | "sha256": "0ede06c023cbdfa06fe307225baeb461f5a70e6ce368ae69dec9d405ef5822ac", | ||
| 6 | "mtime_ns": 1790821816933321351 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "Editorial.one", | ||
| 10 | "bytes": 12096, | ||
| 11 | "sha256": "0f65b622cdbeee22b146a6259074aeb4fd2b398d2bf982c7d712db31d190b6d1", | ||
| 12 | "mtime_ns": 1790821816932162894 | ||
| 13 | }, | ||
| 14 | { | ||
| 15 | "path": "Manuscript.one", | ||
| 16 | "bytes": 12120, | ||
| 17 | "sha256": "109d595430cf434fabcab11bfe233a34b5182a4b48f8cc5146885ee22936f636", | ||
| 18 | "mtime_ns": 1790821816931895020 | ||
| 19 | }, | ||
| 20 | { | ||
| 21 | "path": "Modern.one", | ||
| 22 | "bytes": 12000, | ||
| 23 | "sha256": "9ea5680d90a9b44bd036d4d50f5bcaae5b8f8ac505af4623e0ac3d0488b09ce2", | ||
| 24 | "mtime_ns": 1790821816932649894 | ||
| 25 | }, | ||
| 26 | { | ||
| 27 | "path": "OneNote.one", | ||
| 28 | "bytes": 12072, | ||
| 29 | "sha256": "5b019d8246db5a2728de5502d30b2a1bfe7a0a5d376c1a5d15ce98ac551ed210", | ||
| 30 | "mtime_ns": 1790821816932870935 | ||
| 31 | }, | ||
| 32 | { | ||
| 33 | "path": "Open Notebook.onetoc2", | ||
| 34 | "bytes": 2036, | ||
| 35 | "sha256": "9f613cf0b5db95b8809fe7d325cae9c37e8b9ae57fa3fff3b7d669649f1b416c", | ||
| 36 | "mtime_ns": 1790821816932392186 | ||
| 37 | } | ||
| 38 | ] | ||
corpus/styles/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
corpus/styles/onenote-edit/Manuscript.one created| Binary files /dev/null and b/corpus/styles/onenote-edit/Manuscript.one differ | |||
corpus/styles/onenote-edit/Manuscript.png created| Binary files /dev/null and b/corpus/styles/onenote-edit/Manuscript.png differ | |||
corpus/styles/onenote-edit/Manuscript.xml created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C9EC67ED-F2EC-0F39-02F7-55375077357D}{1}{B0}" name="Heading 1 in Manuscript" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:31:35.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="20.0" spaceBefore="0.333333343267441" spaceAfter="0.111111111938953"/><one:QuickStyleDef index="1" name="h2" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="16.0" bold="true" spaceBefore="0.277777791023254" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="2" name="h3" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="13.0" bold="true" spaceBefore="0.222222223877907" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="3" name="h4" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" bold="true" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="h6" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="PageTitle" fontColor="#1F1A17" highlightColor="automatic" font="Georgia" fontSize="26.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="cite" fontColor="#7A6E66" highlightColor="automatic" font="Georgia" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="9" name="blockquote" fontColor="#6B5E55" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.0" spaceAfter="0.111111111938953"/><one:QuickStyleDef index="10" name="code" fontColor="#2B2B2B" highlightColor="automatic" font="Courier New" fontSize="10.5" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="11" name="h2" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="13.0" bold="true" 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:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2026-10-01T02:31:35.000Z" objectID="{412D0AF5-B11A-053F-0791-809B04EDF7A9}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="321.6620483398437" height="244.6016998291016"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{412D0AF5-B11A-053F-0791-809B04EDF7A9}{24}{B0}" alignment="left" spaceBefore="12.0" spaceAfter="4.0" quickStyleIndex="0"><one:T><![CDATA[Heading 1 in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{44}{B0}" alignment="left" spaceBefore="10.0" spaceAfter="2.0" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 3 | style='font-weight:bold'>Heading 2 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{48}{B0}" alignment="left" spaceBefore="8.0" spaceAfter="2.0" quickStyleIndex="2"><one:T><![CDATA[<span | ||
| 4 | style='font-weight:bold'>Heading 3 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{51}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="3"><one:T><![CDATA[<span | ||
| 5 | style='font-weight:bold;font-style:italic'>Heading 4 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{54}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="4"><one:T><![CDATA[Heading 5 in Manuscript]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-10-01T02:31:35.000Z" lastModifiedTime="2026-10-01T02:31:35.000Z" objectID="{3A1FBC7F-49F8-4BBF-ABBF-F8660D35F607}{17}{B0}" alignment="left" quickStyleIndex="5"><one:T><![CDATA[Typed after themed h5]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{57}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="6"><one:T><![CDATA[<span | ||
| 6 | style='font-style:italic'>Heading 6 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{60}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Page Title in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{63}{B0}" alignment="left" quickStyleIndex="8"><one:T><![CDATA[Citation in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{66}{B0}" alignment="left" spaceAfter="4.0" quickStyleIndex="9"><one:T><![CDATA[<span | ||
| 7 | style='font-style:italic'>Quote in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{69}{B0}" alignment="left" quickStyleIndex="10"><one:T><![CDATA[Code in Manuscript]]></one:T></one:OE><one:OE lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:34.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{72}{B0}" alignment="left" quickStyleIndex="11"><one:T><![CDATA[<span | ||
| 8 | style='font-weight:bold'>Normal in Manuscript</span>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-10-01T02:31:35.000Z" lastModifiedTime="2026-10-01T02:31:35.000Z" objectID="{3A1FBC7F-49F8-4BBF-ABBF-F8660D35F607}{14}{B0}" alignment="left" quickStyleIndex="5"><one:T><![CDATA[Typed after OneNote h2]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/styles/onenote/Styles.one created| Binary files /dev/null and b/corpus/styles/onenote/Styles.one differ | |||
corpus/styles/onenote/applied.xml created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3DD74380-295A-46CD-8408-7E7B91469B66}{1}{B0}" name="Styles probe" dateTime="2026-09-30T22:25:42.000Z" lastModifiedTime="2026-09-30T22:34:13.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="h1" fontColor="#17365D" highlightColor="automatic" font="Calibri" fontSize="16.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="2" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="3" name="h2" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="13.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h3" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h4" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" bold="true" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="h5" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="h6" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="9" name="blockquote" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="11.0" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="code" fontColor="automatic" highlightColor="automatic" font="Courier New" 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-30T22:31:17.000Z" lastModifiedTime="2026-09-30T22:31:18.000Z" objectID="{655568C0-7633-4A10-A41C-3537BB8C5E79}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Styles probe]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:34:13.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{11}{B0}"><one:Position x="108.0" y="122.400001525879" z="0"/><one:Size width="190.436767578125" height="436.2985229492187"/><one:OEChildren><one:OE creationTime="2026-09-30T22:32:10.000Z" lastModifiedTime="2026-09-30T22:32:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{12}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 3 | style='font-weight:bold'>Heading 1 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:10.000Z" lastModifiedTime="2026-09-30T22:32:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{16}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 1]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:10.000Z" lastModifiedTime="2026-09-30T22:32:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{19}{B0}" alignment="left" quickStyleIndex="3"><one:T><![CDATA[<span | ||
| 4 | style='font-weight:bold'>Heading 2 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:11.000Z" lastModifiedTime="2026-09-30T22:32:11.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{22}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 2]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:11.000Z" lastModifiedTime="2026-09-30T22:32:11.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{24}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[<span | ||
| 5 | style='font-weight:bold'>Heading 3 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:11.000Z" lastModifiedTime="2026-09-30T22:32:11.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{27}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 3]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:11.000Z" lastModifiedTime="2026-09-30T22:32:11.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{29}{B0}" alignment="left" quickStyleIndex="5"><one:T><![CDATA[<span | ||
| 6 | style='font-weight:bold;font-style:italic'>Heading 4 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:12.000Z" lastModifiedTime="2026-09-30T22:32:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{32}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 4]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:12.000Z" lastModifiedTime="2026-09-30T22:32:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{34}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[Heading 5 text]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:12.000Z" lastModifiedTime="2026-09-30T22:32:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{37}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 5]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:12.000Z" lastModifiedTime="2026-09-30T22:32:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{39}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[<span | ||
| 7 | style='font-style:italic'>Heading 6 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:13.000Z" lastModifiedTime="2026-09-30T22:32:13.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{42}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 6]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:14.000Z" lastModifiedTime="2026-09-30T22:32:14.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{44}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Page Title text]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:14.000Z" lastModifiedTime="2026-09-30T22:32:15.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{46}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After Page Title]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:15.000Z" lastModifiedTime="2026-09-30T22:32:15.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{48}{B0}" alignment="left" quickStyleIndex="8"><one:T><![CDATA[Citation text]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:16.000Z" lastModifiedTime="2026-09-30T22:32:16.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{51}{B0}" alignment="left" quickStyleIndex="8"><one:T><![CDATA[After Citation]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:17.000Z" lastModifiedTime="2026-09-30T22:32:17.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{53}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[<span | ||
| 8 | style='font-style:italic'>Quote text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:17.000Z" lastModifiedTime="2026-09-30T22:32:17.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{56}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[<span | ||
| 9 | style='font-style:italic'>After Quote</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:18.000Z" lastModifiedTime="2026-09-30T22:32:18.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{58}{B0}" alignment="left" quickStyleIndex="10"><one:T><![CDATA[Code text]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:19.000Z" lastModifiedTime="2026-09-30T22:32:19.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{61}{B0}" alignment="left" quickStyleIndex="10"><one:T><![CDATA[After Code]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:08.000Z" lastModifiedTime="2026-09-30T22:33:08.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{66}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[20]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:08.000Z" lastModifiedTime="2026-09-30T22:33:08.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{69}{B0}" alignment="left" quickStyleIndex="3"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:09.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{71}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 10 | style='font-weight:bold'>H1 then italic </span><span style='font-weight:bold; | ||
| 11 | font-style:italic'>word</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:09.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{74}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[H1 then clear]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:10.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{76}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[<span | ||
| 12 | style='font-weight:bold'>Split heading </span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:10.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{78}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[<span | ||
| 13 | style='font-weight:bold'>Here</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:10.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{80}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:10.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{82}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[<span | ||
| 14 | style='font-weight:bold'>Start enter heading</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:34:12.000Z" lastModifiedTime="2026-09-30T22:34:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{84}{B0}" alignment="left" quickStyleIndex="3"><one:T><![CDATA[<span | ||
| 15 | style='font-weight:bold'>Explicit big bold red then h2</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:34:13.000Z" lastModifiedTime="2026-09-30T22:34:13.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{89}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[<span | ||
| 16 | style='font-weight:bold'>Quote then h1</span>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/table-widths/README.md created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | # Table column widths through the editor | ||
| 2 | |||
| 3 | `candidate/` is the output of `typed_and_dragged_columns_store_onenote_s_widths` in | ||
| 4 | `crates/canvas/tests/table_widths.rs`, editing `corpus/table-tags/native`. On "Check a | ||
| 5 | table" a three-by-three table is typed after the text a keystroke per revision, each | ||
| 6 | storing its column's fitted width with its text; on "Edit a tagged table" a native cell | ||
| 7 | is typed into, widening its column; on "Delete and undo" a new table's first column is | ||
| 8 | dragged to 96 pt, locking it, and a line long enough to reach the outline's width is | ||
| 9 | typed in the second. | ||
| 10 | |||
| 11 | `cold/` is a fresh OneNote 2010 read: the typed table's columns are the widths OneNote | ||
| 12 | gives the same table typed in it (60.76, 37.11 and 139.09 pt), the widened native column | ||
| 13 | keeps its width, the dragged column stays 96 pt and locked, and the long line wraps in | ||
| 14 | its column at the outline's edge. `tools/test_table_widths.py` checks this without a VM. | ||
| 15 | Regenerate with `SNOWBOUND_TABLE_WIDTHS_EXPORT` set to a new absolute directory while | ||
| 16 | running the test, then cold-open it with | ||
| 17 | `tools/native_runner.py OUTPUT COLD --expected-pages 3 --collect-notebook --screenshots`. | ||
corpus/table-widths/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/table-widths/candidate/Open Notebook.onetoc2 differ | |||
corpus/table-widths/candidate/Tables.one created| Binary files /dev/null and b/corpus/table-widths/candidate/Tables.one differ | |||
corpus/table-widths/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-F8389DC9 -ExpectedPages 3 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 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/table-widths/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-f8389dc9", "hostname": "ONE-M6-F8389DC9"} | ||
corpus/table-widths/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/table-widths/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/table-widths/cold/notebook/Tables.one created| Binary files /dev/null and b/corpus/table-widths/cold/notebook/Tables.one differ | |||
corpus/table-widths/cold/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-F8389DC9", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/table-widths/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="{CB538ADE-527B-4666-A701-0D6D2EB4F273}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T02:08:01.000Z" color="#B49EDE"><one:Section name="Tables" ID="{50D98C6A-6315-0B5D-13E9-371E75D6E8D8}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Tables.one" lastModifiedTime="2026-10-01T02:08:01.000Z" color="#91BAAE"><one:Page ID="{F77AECA3-F77A-0634-2313-F7734E45953D}{1}{B0}" name="Check a table" dateTime="2026-09-30T03:42:55.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1"/><one:Page ID="{5749080B-A1A1-0F66-06D4-3D7F2FCA5C62}{1}{B0}" name="Edit a tagged table" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1"/><one:Page ID="{2C18A65F-888B-0AF2-38F8-4DBE934D9BB9}{1}{B0}" name="Delete and undo" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/table-widths/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": "Delete and undo - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/table-widths/cold/read/page-000.png created| Binary files /dev/null and b/corpus/table-widths/cold/read/page-000.png differ | |||
corpus/table-widths/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="{2C18A65F-888B-0AF2-38F8-4DBE934D9BB9}{1}{B0}" name="Delete and undo" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:TagDef index="1" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete and undo]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="455.1275939941406" height="79.2708740234375"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:42:57.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{46}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="100.8300018310547"/></one:Columns><one:Row objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{47}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Outer]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{59}{B0}" alignment="left"><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{60}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{61}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{62}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Inner A]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{70}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{73}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Inner B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2025-08-18T14:14:46.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{80}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2025-08-18T14:14:46.000Z" lastModifiedTime="2025-08-18T14:14:48.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{37}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2025-08-18T14:14:48.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{38}{B0}"><one:Columns><one:Column index="0" width="96.0" isLocked="true"/><one:Column index="1" width="350.9975891113281"/></one:Columns><one:Row objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{39}{B0}" lastModifiedTime="2025-08-18T14:14:48.000Z"><one:Cell lastModifiedTime="2025-08-18T14:14:46.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{42}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:46.000Z" lastModifiedTime="2025-08-18T14:14:46.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Dragged]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:14:48.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{45}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:46.000Z" lastModifiedTime="2025-08-18T14:14:48.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{43}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fitted and then a line long enough to reach the outline's edge and wrap there]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/table-widths/cold/read/page-001.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": "Edit a tagged table - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/table-widths/cold/read/page-001.png created| Binary files /dev/null and b/corpus/table-widths/cold/read/page-001.png differ | |||
corpus/table-widths/cold/read/page-001.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="{5749080B-A1A1-0F66-06D4-3D7F2FCA5C62}{1}{B0}" name="Edit a tagged table" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Edit a tagged table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="225.4903259277344" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:42:57.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{46}{B0}"><one:Columns><one:Column index="0" width="180.2503204345703"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{47}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2025-08-18T14:14:45.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2025-08-18T14:14:45.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Typed into and typed on in Snowbound]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{64}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[C]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[D]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{82}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/table-widths/cold/read/page-002.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": "Check a table - Microsoft OneNote", | ||
| 10 | "class": "Framework::CFrame", | ||
| 11 | "dialog": false | ||
| 12 | } | ||
| 13 | } | ||
| \ No newline at end of file | |||
corpus/table-widths/cold/read/page-002.png created| Binary files /dev/null and b/corpus/table-widths/cold/read/page-002.png differ | |||
corpus/table-widths/cold/read/page-002.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="{F77AECA3-F77A-0634-2313-F7734E45953D}{1}{B0}" name="Check a table" dateTime="2026-09-30T03:42:55.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{17}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Check a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{23}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="250.0658264160156" height="107.5663146972656"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{29}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{30}{B0}"><one:Columns><one:Column index="0" width="85.20857238769531"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{31}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{32}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{35}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Checked by a click]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{40}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{43}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{49}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:14:44.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{3}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2025-08-18T14:14:44.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{4}{B0}"><one:Columns><one:Column index="0" width="60.75944900512695"/><one:Column index="1" width="37.11000061035156"/><one:Column index="2" width="139.0863647460937"/></one:Columns><one:Row objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{5}{B0}" lastModifiedTime="2025-08-18T14:14:31.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:26.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{8}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:26.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{6}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fruit]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:33.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{11}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:27.000Z" lastModifiedTime="2025-08-18T14:13:33.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{9}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Colour]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:39.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{14}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:34.000Z" lastModifiedTime="2025-08-18T14:13:39.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{12}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Notes]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{24}{B0}" lastModifiedTime="2025-08-18T14:14:31.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:45.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{17}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:40.000Z" lastModifiedTime="2025-08-18T14:13:45.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Apple]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:49.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{20}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:40.000Z" lastModifiedTime="2025-08-18T14:13:49.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{18}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Red]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:14:20.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{23}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:40.000Z" lastModifiedTime="2025-08-18T14:14:20.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{21}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Crisp and sweet, good for pies]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{34}{B0}" lastModifiedTime="2025-08-18T14:14:44.000Z"><one:Cell lastModifiedTime="2025-08-18T14:14:31.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{27}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:21.000Z" lastModifiedTime="2025-08-18T14:14:31.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Watermelon]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:14:37.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{30}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:21.000Z" lastModifiedTime="2025-08-18T14:14:37.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{28}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Green]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:14:44.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{33}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:21.000Z" lastModifiedTime="2025-08-18T14:14:44.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{31}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Summer]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/table-widths/cold/read/payloads.json created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/table-widths/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/snowbound-tables/candidate", | ||
| 3 | "expected_pages": 3, | ||
| 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/table-widths/cold/scripts/cold.ps1 created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/table-widths/cold/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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/table-widths/cold/source.json created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Open Notebook.onetoc2", | ||
| 4 | "bytes": 1604, | ||
| 5 | "sha256": "78250a5c9a50d89ba0b9a0cb725c8afe0c9cbc538a8f39e8a3cde75d2aebed65", | ||
| 6 | "mtime_ns": 1790820308278651723 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "Tables.one", | ||
| 10 | "bytes": 35192, | ||
| 11 | "sha256": "b45ea79ddcb9c9f30992e9beee4919993578c295cc81605ca3d97fbbfc4d2eac", | ||
| 12 | "mtime_ns": 1790820308259896484 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/table-widths/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
crates/canvas/examples/print_pdf.rs+10-2| ... | @@ -33,8 +33,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -33,8 +33,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 33 | .file_stem() | 33 | .file_stem() |
| 34 | .and_then(|stem| stem.to_str()) | 34 | .and_then(|stem| stem.to_str()) |
| 35 | .unwrap_or_default(); | 35 | .unwrap_or_default(); |
| 36 | let paper = if a4 { print::A4 } else { print::LETTER }; | 36 | let setup = print::Setup { |
| 37 | let pdf = print::pdf(pages, &mut TextEngine::default(), paper, name)?; | 37 | paper: if a4 { print::A4 } else { print::LETTER }, |
| 38 | fit_width: true, | ||
| 39 | footer: print::Footer::SectionAndPage, | ||
| 40 | }; | ||
| 41 | let pdf = print::pdf( | ||
| 42 | vec![(name.to_owned(), pages)], | ||
| 43 | &mut TextEngine::default(), | ||
| 44 | &setup, | ||
| 45 | )?; | ||
| 38 | fs::write(destination, pdf)?; | 46 | fs::write(destination, pdf)?; |
| 39 | Ok(()) | 47 | Ok(()) |
| 40 | } | 48 | } |
crates/canvas/examples/scratch_eqref.rs deleted-667| ... | @@ -1,667 +0,0 @@ | ||
| 1 | //! Every place the canvas shows a page's content as a placeholder, draws it read-only, or | ||
| 2 | //! refuses an ordinary edit as content it cannot edit, across the active pages of every | ||
| 3 | //! section under the given directories, grouped by kind as Markdown. Identical sections | ||
| 4 | //! count once. | ||
| 5 | //! | ||
| 6 | //! `cargo run --release -p canvas --features gpu --example unsupported_inventory -- corpus` | ||
| 7 | |||
| 8 | use canvas::{ | ||
| 9 | document::TextPosition, | ||
| 10 | editor::{CanvasEditor, EditorError, TextOutline}, | ||
| 11 | layout::{LayoutError, TextEngine}, | ||
| 12 | outline::Arrange, | ||
| 13 | }; | ||
| 14 | use onestore::{ | ||
| 15 | Arena, ExGuid, Section, | ||
| 16 | page::{ | ||
| 17 | Image, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, | ||
| 18 | text::{Affinity, EditError}, | ||
| 19 | }, | ||
| 20 | }; | ||
| 21 | use std::{ | ||
| 22 | collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher}, | ||
| 23 | hash::{Hash, Hasher}, | ||
| 24 | path::{Path, PathBuf}, | ||
| 25 | }; | ||
| 26 | |||
| 27 | #[derive(Default)] | ||
| 28 | struct Inventory { | ||
| 29 | /// Kind to the places it occurs, as `section — page`. | ||
| 30 | kinds: BTreeMap<(Stage, String), Vec<String>>, | ||
| 31 | sections: usize, | ||
| 32 | pages: usize, | ||
| 33 | } | ||
| 34 | |||
| 35 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] | ||
| 36 | enum Stage { | ||
| 37 | /// Drawn as an "Unsupported content" (or "Image unavailable") box. | ||
| 38 | Placeholder, | ||
| 39 | /// Drawn as stored, but the editor cannot hold it. | ||
| 40 | ReadOnly, | ||
| 41 | /// An ordinary edit the editor refuses as content it cannot edit. | ||
| 42 | Refused, | ||
| 43 | /// The page or section does not open at all. | ||
| 44 | Failure, | ||
| 45 | } | ||
| 46 | |||
| 47 | impl Inventory { | ||
| 48 | fn add(&mut self, stage: Stage, kind: impl Into<String>, place: &str) { | ||
| 49 | self.kinds | ||
| 50 | .entry((stage, kind.into())) | ||
| 51 | .or_default() | ||
| 52 | .push(place.to_owned()); | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | /// MS-ONE's names for the object kinds it documents, and the observed undocumented ones. | ||
| 57 | fn jcid_name(jcid: u32) -> String { | ||
| 58 | let name = match jcid { | ||
| 59 | 0x60007 => "section", | ||
| 60 | 0x60008 => "page series", | ||
| 61 | 0x6000b => "page", | ||
| 62 | 0x6000c => "outline", | ||
| 63 | 0x6000d => "outline element", | ||
| 64 | 0x6000e => "rich text", | ||
| 65 | 0x60011 => "picture", | ||
| 66 | 0x60012 => "number list", | ||
| 67 | 0x60014 => "ink container", | ||
| 68 | 0x60019 => "outline group", | ||
| 69 | 0x60022 => "table", | ||
| 70 | 0x60023 => "table row", | ||
| 71 | 0x60024 => "table cell", | ||
| 72 | 0x6002c => "title", | ||
| 73 | 0x60035 => "embedded file", | ||
| 74 | 0x60037 => "page manifest", | ||
| 75 | 0x6003c => "version history", | ||
| 76 | 0x6003d => "version proxy", | ||
| 77 | _ => "undocumented", | ||
| 78 | }; | ||
| 79 | format!("jcid 0x{jcid:05x} ({name})") | ||
| 80 | } | ||
| 81 | |||
| 82 | fn unsupported_error(error: &EditorError) -> bool { | ||
| 83 | matches!( | ||
| 84 | error, | ||
| 85 | EditorError::Layout(LayoutError::UnsupportedContent) | ||
| 86 | | EditorError::Edit(EditError::UnsupportedContent) | ||
| 87 | ) | ||
| 88 | } | ||
| 89 | |||
| 90 | /// What a text paragraph holds that bears on editing it. | ||
| 91 | fn traits(text: &Paragraph) -> Vec<&'static str> { | ||
| 92 | let spans = text.spans(); | ||
| 93 | let mut traits = Vec::new(); | ||
| 94 | if text.text().contains('\u{fffc}') | ||
| 95 | || spans.iter().any(|s| s.format.embedded_object == Some(true)) | ||
| 96 | { | ||
| 97 | traits.push("embedded object in text"); | ||
| 98 | } | ||
| 99 | if spans.iter().any(|s| s.format.math == Some(true)) { | ||
| 100 | traits.push("equation"); | ||
| 101 | } | ||
| 102 | if spans.iter().any(|s| s.format.hyperlink == Some(true)) { | ||
| 103 | traits.push("link"); | ||
| 104 | } | ||
| 105 | if spans.iter().any(|s| s.format.hidden == Some(true)) { | ||
| 106 | traits.push("hidden text"); | ||
| 107 | } | ||
| 108 | if text.text().is_empty() { | ||
| 109 | traits.push("empty"); | ||
| 110 | } | ||
| 111 | traits | ||
| 112 | } | ||
| 113 | |||
| 114 | /// Why `node`, alone in an outline, does not lay out as content the canvas understands. | ||
| 115 | fn culprit( | ||
| 116 | engine: &mut TextEngine, | ||
| 117 | outline: &Outline, | ||
| 118 | node: &PageParagraph, | ||
| 119 | page: &Page, | ||
| 120 | ) -> Option<String> { | ||
| 121 | let alone = Outline { | ||
| 122 | paragraphs: vec![PageParagraph { | ||
| 123 | parent: None, | ||
| 124 | level: 1, | ||
| 125 | ..node.clone() | ||
| 126 | }], | ||
| 127 | unsupported: Vec::new(), | ||
| 128 | ..outline.clone() | ||
| 129 | }; | ||
| 130 | match alone.layout(engine, &page.definitions) { | ||
| 131 | Err(LayoutError::UnsupportedContent) => {} | ||
| 132 | _ => return None, | ||
| 133 | } | ||
| 134 | Some(match &node.content { | ||
| 135 | ParagraphContent::Unsupported(u) => format!("holds {}", jcid_name(u.jcid)), | ||
| 136 | ParagraphContent::Table(table) if !table.tags.is_empty() => "table with note tags".into(), | ||
| 137 | ParagraphContent::Table(table) => { | ||
| 138 | let cells = || table.rows.iter().flat_map(|row| &row.cells); | ||
| 139 | if let Some(cell) = cells().find(|cell| !cell.unsupported.is_empty()) { | ||
| 140 | format!( | ||
| 141 | "table cell holding {}", | ||
| 142 | cell.unsupported | ||
| 143 | .iter() | ||
| 144 | .map(|u| jcid_name(u.jcid)) | ||
| 145 | .collect::<Vec<_>>() | ||
| 146 | .join(", ") | ||
| 147 | ) | ||
| 148 | } else { | ||
| 149 | let inner = cells() | ||
| 150 | .flat_map(|cell| &cell.paragraphs) | ||
| 151 | .find_map(|inner| culprit(engine, outline, inner, page)); | ||
| 152 | format!( | ||
| 153 | "table cell: {}", | ||
| 154 | inner.unwrap_or_else(|| "nested content".into()) | ||
| 155 | ) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | ParagraphContent::Image(_) => "picture without a size".into(), | ||
| 159 | ParagraphContent::Ink(_) => "handwriting without strokes".into(), | ||
| 160 | ParagraphContent::Attachment(_) => "file".into(), | ||
| 161 | ParagraphContent::Text(text) => { | ||
| 162 | let lists: Vec<String> = node | ||
| 163 | .lists | ||
| 164 | .iter() | ||
| 165 | .filter_map(|id| page.definitions.get(id)) | ||
| 166 | .map(|definition| format!("{:?}", definition.kind)) | ||
| 167 | .collect(); | ||
| 168 | if !lists.is_empty() { | ||
| 169 | format!("list marker {}", lists.join(", ")) | ||
| 170 | } else if text | ||
| 171 | .text | ||
| 172 | .spans() | ||
| 173 | .iter() | ||
| 174 | .any(|s| s.format.math == Some(true)) | ||
| 175 | { | ||
| 176 | "equation the math layout does not parse".into() | ||
| 177 | } else { | ||
| 178 | format!("text ({})", traits(&text.text).join(", ")) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | }) | ||
| 182 | } | ||
| 183 | |||
| 184 | /// Why an outline the page shows is not editable. | ||
| 185 | fn diagnose(engine: &mut TextEngine, outline: &Outline, page: &Page) -> Vec<String> { | ||
| 186 | let mut reasons: Vec<String> = outline | ||
| 187 | .unsupported | ||
| 188 | .iter() | ||
| 189 | .map(|u| format!("outline child {}", jcid_name(u.jcid))) | ||
| 190 | .collect(); | ||
| 191 | reasons.extend( | ||
| 192 | outline | ||
| 193 | .paragraphs | ||
| 194 | .iter() | ||
| 195 | .filter_map(|node| culprit(engine, outline, node, page)), | ||
| 196 | ); | ||
| 197 | if reasons.is_empty() { | ||
| 198 | let text = outline.paragraphs.iter().any(|node| { | ||
| 199 | node.text().is_some() | ||
| 200 | || matches!(&node.content, ParagraphContent::Table(t) | ||
| 201 | if t.rows.iter().flat_map(|r| &r.cells).any(|c| !c.paragraphs.is_empty())) | ||
| 202 | }); | ||
| 203 | reasons.push(if text { | ||
| 204 | match TextOutline::from_outline(engine, outline, &page.definitions) { | ||
| 205 | Err(error) => format!("editor: {error}"), | ||
| 206 | Ok(_) => "outline on its own is editable".into(), | ||
| 207 | } | ||
| 208 | } else { | ||
| 209 | let kinds: BTreeSet<&str> = outline | ||
| 210 | .paragraphs | ||
| 211 | .iter() | ||
| 212 | .map(|node| match node.content { | ||
| 213 | ParagraphContent::Image(_) => "pictures", | ||
| 214 | ParagraphContent::Attachment(_) => "files", | ||
| 215 | ParagraphContent::Ink(_) => "handwriting", | ||
| 216 | _ => "other", | ||
| 217 | }) | ||
| 218 | .collect(); | ||
| 219 | format!( | ||
| 220 | "no paragraph of text (only {})", | ||
| 221 | kinds.into_iter().collect::<Vec<_>>().join(", ") | ||
| 222 | ) | ||
| 223 | }); | ||
| 224 | } | ||
| 225 | reasons | ||
| 226 | } | ||
| 227 | |||
| 228 | /// A picture that draws as "Image unavailable": no data, or (with `--features gpu`) data | ||
| 229 | /// the renderer does not decode, named by its leading bytes. | ||
| 230 | fn picture(image: &Image, at: &str, inventory: &mut Inventory, place: &str) { | ||
| 231 | let Some(bytes) = &image.bytes else { | ||
| 232 | return inventory.add( | ||
| 233 | Stage::Placeholder, | ||
| 234 | format!("{at} picture without data"), | ||
| 235 | place, | ||
| 236 | ); | ||
| 237 | }; | ||
| 238 | #[cfg(feature = "gpu")] | ||
| 239 | if [Some(bytes), image.display.as_ref()] | ||
| 240 | .into_iter() | ||
| 241 | .flatten() | ||
| 242 | .all(|bytes| draw::RasterImage::measure(bytes).is_err()) | ||
| 243 | { | ||
| 244 | let format = match &bytes[..bytes.len().min(4)] { | ||
| 245 | [0x50, 0x4b, 3, 4] => "a zip package (XPS printout)", | ||
| 246 | [1, 0, 0, 0] => "EMF", | ||
| 247 | [0xd7, 0xcd, 0xc6, 0x9a] | [1, 0, 9, 0] => "WMF", | ||
| 248 | [b'B', b'M', ..] => "BMP", | ||
| 249 | _ => "an unknown format", | ||
| 250 | }; | ||
| 251 | inventory.add( | ||
| 252 | Stage::Placeholder, | ||
| 253 | format!("{at} picture stored as {format}"), | ||
| 254 | place, | ||
| 255 | ); | ||
| 256 | } | ||
| 257 | #[cfg(not(feature = "gpu"))] | ||
| 258 | let _ = bytes; | ||
| 259 | } | ||
| 260 | |||
| 261 | /// Pictures that draw as "Image unavailable" and objects the model keeps aside. | ||
| 262 | fn scan_paragraphs(nodes: &[PageParagraph], at: &str, inventory: &mut Inventory, place: &str) { | ||
| 263 | for node in nodes { | ||
| 264 | match &node.content { | ||
| 265 | ParagraphContent::Unsupported(u) => inventory.add( | ||
| 266 | Stage::Placeholder, | ||
| 267 | format!("{at} paragraph holding {}", jcid_name(u.jcid)), | ||
| 268 | place, | ||
| 269 | ), | ||
| 270 | ParagraphContent::Image(image) => picture(image, at, inventory, place), | ||
| 271 | ParagraphContent::Table(table) => { | ||
| 272 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | ||
| 273 | for u in &cell.unsupported { | ||
| 274 | inventory.add( | ||
| 275 | Stage::Placeholder, | ||
| 276 | format!("table cell child {}", jcid_name(u.jcid)), | ||
| 277 | place, | ||
| 278 | ); | ||
| 279 | } | ||
| 280 | scan_paragraphs(&cell.paragraphs, "table cell", inventory, place); | ||
| 281 | } | ||
| 282 | } | ||
| 283 | _ => {} | ||
| 284 | } | ||
| 285 | } | ||
| 286 | } | ||
| 287 | |||
| 288 | /// Ordinary edits at every text paragraph of every editable outline: typing at its start, | ||
| 289 | /// middle and end, Enter, Backspace at its start, Delete at its end, and deleting from its | ||
| 290 | /// middle into the next. | ||
| 291 | fn probe( | ||
| 292 | editor: &mut CanvasEditor, | ||
| 293 | engine: &mut TextEngine, | ||
| 294 | inventory: &mut Inventory, | ||
| 295 | place: &str, | ||
| 296 | ) { | ||
| 297 | let ids: Vec<ExGuid> = editor.outlines().iter().map(|o| o.id).collect(); | ||
| 298 | for id in ids { | ||
| 299 | if editor.focus_outline(id).is_err() { | ||
| 300 | continue; | ||
| 301 | } | ||
| 302 | let document = editor.active_outline().document(); | ||
| 303 | let containers = containers(document.nodes()); | ||
| 304 | let leaves: Vec<(Option<ExGuid>, Paragraph)> = document | ||
| 305 | .text_nodes() | ||
| 306 | .map(|node| { | ||
| 307 | ( | ||
| 308 | containers.get(&node.id).copied().flatten(), | ||
| 309 | node.text().unwrap().text.clone(), | ||
| 310 | ) | ||
| 311 | }) | ||
| 312 | .collect(); | ||
| 313 | for (index, (container, text)) in leaves.iter().enumerate() { | ||
| 314 | let length = text.text().encode_utf16().count() as u32; | ||
| 315 | // The middle of what shows, where a click could put the caret. | ||
| 316 | let Ok(projection) = text.project() else { | ||
| 317 | continue; | ||
| 318 | }; | ||
| 319 | let shown = projection.text(); | ||
| 320 | let middle = shown | ||
| 321 | .text() | ||
| 322 | .char_indices() | ||
| 323 | .map(|(at, _)| at) | ||
| 324 | .nth(shown.text().chars().count() / 2) | ||
| 325 | .and_then(|at| shown.utf16_offset(at).ok()) | ||
| 326 | .and_then(|at| projection.source_offset(at, Affinity::Downstream).ok()) | ||
| 327 | .unwrap_or(length); | ||
| 328 | let at = |offset| TextPosition { | ||
| 329 | paragraph: index, | ||
| 330 | offset, | ||
| 331 | }; | ||
| 332 | let next = leaves.get(index + 1); | ||
| 333 | let mut probes: Vec<(&str, [TextPosition; 2], Probe)> = vec![ | ||
| 334 | ("typing at a paragraph's start", [at(0); 2], Probe::Type), | ||
| 335 | ("typing inside a paragraph", [at(middle); 2], Probe::Type), | ||
| 336 | ("typing at a paragraph's end", [at(length); 2], Probe::Type), | ||
| 337 | ("Enter inside a paragraph", [at(middle); 2], Probe::Enter), | ||
| 338 | ( | ||
| 339 | "Delete at a paragraph's end", | ||
| 340 | [at(length); 2], | ||
| 341 | Probe::Delete, | ||
| 342 | ), | ||
| 343 | ]; | ||
| 344 | if index > 0 { | ||
| 345 | probes.push(( | ||
| 346 | "Backspace at a paragraph's start", | ||
| 347 | [at(0); 2], | ||
| 348 | Probe::Backspace, | ||
| 349 | )); | ||
| 350 | } | ||
| 351 | if let Some((_, next_text)) = next { | ||
| 352 | let end = next_text.text().encode_utf16().count() as u32 / 2; | ||
| 353 | probes.push(( | ||
| 354 | "deleting a selection into the next paragraph", | ||
| 355 | [ | ||
| 356 | at(middle), | ||
| 357 | TextPosition { | ||
| 358 | paragraph: index + 1, | ||
| 359 | offset: end, | ||
| 360 | }, | ||
| 361 | ], | ||
| 362 | Probe::Backspace, | ||
| 363 | )); | ||
| 364 | } | ||
| 365 | for (name, positions, kind) in probes { | ||
| 366 | if editor.select(positions.into()).is_err() { | ||
| 367 | continue; | ||
| 368 | } | ||
| 369 | let result = match kind { | ||
| 370 | Probe::Type => editor.insert(engine, "x").map(|()| true), | ||
| 371 | Probe::Enter => editor.enter(engine, false).map(|()| true), | ||
| 372 | Probe::Delete => editor.delete(engine, false), | ||
| 373 | Probe::Backspace => editor.delete(engine, true), | ||
| 374 | }; | ||
| 375 | match result { | ||
| 376 | Ok(true) => { | ||
| 377 | editor.undo(engine).ok(); | ||
| 378 | } | ||
| 379 | Ok(false) => {} | ||
| 380 | Err(error) if unsupported_error(&error) => { | ||
| 381 | let show = |t: &Paragraph, o: u32| { | ||
| 382 | let b = t.byte_offset(o).unwrap_or(0); | ||
| 383 | let v = |s: &str| s.replace('\u{fdd0}', "⟨").replace('\u{fdef}', "⟩").replace('\u{fdee}', "|").replace('\u{fffc}', "¤"); | ||
| 384 | format!("{}‸{}", v(&t.text()[..b]), v(&t.text()[b..])) | ||
| 385 | }; | ||
| 386 | let a = show(text, positions[0].offset); | ||
| 387 | let b = if positions[1].paragraph != index { show(&leaves[positions[1].paragraph].1, positions[1].offset) } else { String::new() }; | ||
| 388 | eprintln!("REFUSED {name} @ {place}\n A: {a}\n B: {b}"); | ||
| 389 | let crossing = positions[0].paragraph != positions[1].paragraph | ||
| 390 | || name.starts_with("Backspace") | ||
| 391 | || name.starts_with("Delete"); | ||
| 392 | let neighbour = match name { | ||
| 393 | n if n.starts_with("Backspace") => leaves.get(index - 1), | ||
| 394 | _ if crossing => next, | ||
| 395 | _ => None, | ||
| 396 | }; | ||
| 397 | let mut why: Vec<&str> = traits(text); | ||
| 398 | if let Some((other, other_text)) = neighbour { | ||
| 399 | if other != container { | ||
| 400 | why.push("across a table cell boundary"); | ||
| 401 | } | ||
| 402 | why.extend(traits(other_text).into_iter().map(|t| match t { | ||
| 403 | "embedded object in text" => "next to an embedded object", | ||
| 404 | "equation" => "next to an equation", | ||
| 405 | "link" => "next to a link", | ||
| 406 | t => t, | ||
| 407 | })); | ||
| 408 | } | ||
| 409 | if container.is_some() { | ||
| 410 | why.push("in a table cell"); | ||
| 411 | } | ||
| 412 | why.sort(); | ||
| 413 | why.dedup(); | ||
| 414 | inventory.add( | ||
| 415 | Stage::Refused, | ||
| 416 | format!("{name} [{}]", why.join(", ")), | ||
| 417 | place, | ||
| 418 | ); | ||
| 419 | } | ||
| 420 | Err(_) => {} | ||
| 421 | } | ||
| 422 | } | ||
| 423 | } | ||
| 424 | } | ||
| 425 | } | ||
| 426 | |||
| 427 | #[derive(Clone, Copy)] | ||
| 428 | enum Probe { | ||
| 429 | Type, | ||
| 430 | Enter, | ||
| 431 | Delete, | ||
| 432 | Backspace, | ||
| 433 | } | ||
| 434 | |||
| 435 | /// Each paragraph's table cell, `None` at the outline's top. | ||
| 436 | fn containers(nodes: &[PageParagraph]) -> BTreeMap<ExGuid, Option<ExGuid>> { | ||
| 437 | let mut map = BTreeMap::new(); | ||
| 438 | let mut pending = vec![(None, nodes)]; | ||
| 439 | while let Some((container, nodes)) = pending.pop() { | ||
| 440 | for node in nodes { | ||
| 441 | map.insert(node.id, container); | ||
| 442 | if let ParagraphContent::Table(table) = &node.content { | ||
| 443 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | ||
| 444 | pending.push((Some(cell.id), cell.paragraphs.as_slice())); | ||
| 445 | } | ||
| 446 | } | ||
| 447 | } | ||
| 448 | } | ||
| 449 | map | ||
| 450 | } | ||
| 451 | |||
| 452 | fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Inventory) { | ||
| 453 | for object in &page.objects { | ||
| 454 | match object { | ||
| 455 | PageObject::Unsupported(u) => inventory.add( | ||
| 456 | Stage::Placeholder, | ||
| 457 | format!("page object {}", jcid_name(u.jcid)), | ||
| 458 | place, | ||
| 459 | ), | ||
| 460 | PageObject::Image(image) => picture(image, "page", inventory, place), | ||
| 461 | PageObject::Outline(outline) => { | ||
| 462 | for u in &outline.unsupported { | ||
| 463 | inventory.add( | ||
| 464 | Stage::Placeholder, | ||
| 465 | format!("outline child {}", jcid_name(u.jcid)), | ||
| 466 | place, | ||
| 467 | ); | ||
| 468 | } | ||
| 469 | scan_paragraphs(&outline.paragraphs, "outline", inventory, place); | ||
| 470 | } | ||
| 471 | PageObject::Title(title) => { | ||
| 472 | for outline in &title.outlines { | ||
| 473 | scan_paragraphs(&outline.paragraphs, "title", inventory, place); | ||
| 474 | } | ||
| 475 | } | ||
| 476 | _ => {} | ||
| 477 | } | ||
| 478 | } | ||
| 479 | let mut editor = match CanvasEditor::from_page(page.clone(), engine) { | ||
| 480 | Ok(editor) => editor, | ||
| 481 | Err(error) => { | ||
| 482 | inventory.add( | ||
| 483 | Stage::Failure, | ||
| 484 | format!("page does not open: {error}"), | ||
| 485 | place, | ||
| 486 | ); | ||
| 487 | return; | ||
| 488 | } | ||
| 489 | }; | ||
| 490 | for object in &page.objects { | ||
| 491 | let (outlines, title) = match object { | ||
| 492 | PageObject::Outline(outline) => (std::slice::from_ref(outline), None), | ||
| 493 | PageObject::Title(title) => (title.outlines.as_slice(), Some(title)), | ||
| 494 | _ => continue, | ||
| 495 | }; | ||
| 496 | for outline in outlines { | ||
| 497 | if editor.has_page_outline(outline.id) | ||
| 498 | || editor.outlines().iter().any(|o| o.id == outline.id) | ||
| 499 | { | ||
| 500 | continue; | ||
| 501 | } | ||
| 502 | if let Some(title) = title { | ||
| 503 | if title.date == Some(outline.id) { | ||
| 504 | if editor.date().is_none() { | ||
| 505 | inventory.add(Stage::ReadOnly, "page date the date editor declines", place); | ||
| 506 | } | ||
| 507 | continue; | ||
| 508 | } | ||
| 509 | if !outline.title { | ||
| 510 | continue; | ||
| 511 | } | ||
| 512 | } | ||
| 513 | let placeholder = matches!( | ||
| 514 | outline.layout(engine, &page.definitions), | ||
| 515 | Err(LayoutError::UnsupportedContent) | ||
| 516 | ); | ||
| 517 | let at = if title.is_some() { "title" } else { "outline" }; | ||
| 518 | for reason in diagnose(engine, outline, &page) { | ||
| 519 | let stage = if placeholder { | ||
| 520 | Stage::Placeholder | ||
| 521 | } else { | ||
| 522 | Stage::ReadOnly | ||
| 523 | }; | ||
| 524 | let whole = if placeholder { | ||
| 525 | format!("whole {at} as a box: {reason}") | ||
| 526 | } else { | ||
| 527 | format!("{at} read-only: {reason}") | ||
| 528 | }; | ||
| 529 | inventory.add(stage, whole, place); | ||
| 530 | } | ||
| 531 | } | ||
| 532 | } | ||
| 533 | probe(&mut editor, engine, inventory, place); | ||
| 534 | } | ||
| 535 | |||
| 536 | fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) { | ||
| 537 | let Ok(image) = std::fs::read(path) else { | ||
| 538 | return; | ||
| 539 | }; | ||
| 540 | let name = path | ||
| 541 | .strip_prefix(root) | ||
| 542 | .unwrap_or(path) | ||
| 543 | .display() | ||
| 544 | .to_string(); | ||
| 545 | let arena = Arena::default(); | ||
| 546 | let mut section = match Section::open(&arena, image) { | ||
| 547 | Ok(section) => section, | ||
| 548 | Err(error) => { | ||
| 549 | inventory.add( | ||
| 550 | Stage::Failure, | ||
| 551 | format!("section does not open: {}", error.message), | ||
| 552 | &name, | ||
| 553 | ); | ||
| 554 | return; | ||
| 555 | } | ||
| 556 | }; | ||
| 557 | inventory.sections += 1; | ||
| 558 | let pages = match section.pages() { | ||
| 559 | Ok(pages) => pages, | ||
| 560 | Err(error) => { | ||
| 561 | inventory.add( | ||
| 562 | Stage::Failure, | ||
| 563 | format!("page list does not read: {}", error.message), | ||
| 564 | &name, | ||
| 565 | ); | ||
| 566 | return; | ||
| 567 | } | ||
| 568 | }; | ||
| 569 | for (space, title, _) in pages { | ||
| 570 | inventory.pages += 1; | ||
| 571 | let place = format!("{name} — {title}"); | ||
| 572 | match section.page(space) { | ||
| 573 | Ok(model) => page(engine, model, &place, inventory), | ||
| 574 | Err(error) => inventory.add( | ||
| 575 | Stage::Failure, | ||
| 576 | format!("page does not read: {}", error.message), | ||
| 577 | &place, | ||
| 578 | ), | ||
| 579 | } | ||
| 580 | } | ||
| 581 | } | ||
| 582 | |||
| 583 | fn sections(directory: &Path, out: &mut Vec<PathBuf>) { | ||
| 584 | let Ok(entries) = std::fs::read_dir(directory) else { | ||
| 585 | return; | ||
| 586 | }; | ||
| 587 | let mut entries: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect(); | ||
| 588 | entries.sort(); | ||
| 589 | for path in entries { | ||
| 590 | if path.is_dir() { | ||
| 591 | sections(&path, out); | ||
| 592 | } else if path.extension().is_some_and(|e| e == "one") { | ||
| 593 | out.push(path); | ||
| 594 | } | ||
| 595 | } | ||
| 596 | } | ||
| 597 | |||
| 598 | fn main() { | ||
| 599 | let roots: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect(); | ||
| 600 | let mut engine = TextEngine::default(); | ||
| 601 | let mut inventory = Inventory::default(); | ||
| 602 | let mut seen = BTreeSet::new(); | ||
| 603 | let mut duplicates = 0; | ||
| 604 | for root in &roots { | ||
| 605 | let mut paths = Vec::new(); | ||
| 606 | sections(root, &mut paths); | ||
| 607 | for path in paths { | ||
| 608 | let Ok(bytes) = std::fs::read(&path) else { | ||
| 609 | continue; | ||
| 610 | }; | ||
| 611 | let mut hasher = DefaultHasher::new(); | ||
| 612 | bytes.hash(&mut hasher); | ||
| 613 | if !seen.insert(hasher.finish()) { | ||
| 614 | duplicates += 1; | ||
| 615 | continue; | ||
| 616 | } | ||
| 617 | section(&mut engine, &path, root, &mut inventory); | ||
| 618 | } | ||
| 619 | } | ||
| 620 | println!("# Unsupported content inventory\n"); | ||
| 621 | println!( | ||
| 622 | "{} distinct sections ({duplicates} identical copies skipped), {} pages, under {}.\n", | ||
| 623 | inventory.sections, | ||
| 624 | inventory.pages, | ||
| 625 | roots | ||
| 626 | .iter() | ||
| 627 | .map(|r| format!("`{}`", r.display())) | ||
| 628 | .collect::<Vec<_>>() | ||
| 629 | .join(", ") | ||
| 630 | ); | ||
| 631 | let headings = [ | ||
| 632 | (Stage::Placeholder, "Drawn as a placeholder box"), | ||
| 633 | (Stage::ReadOnly, "Drawn as stored, read-only"), | ||
| 634 | ( | ||
| 635 | Stage::Refused, | ||
| 636 | "Edits refused as content the editor cannot edit", | ||
| 637 | ), | ||
| 638 | (Stage::Failure, "Does not open"), | ||
| 639 | ]; | ||
| 640 | for (stage, heading) in headings { | ||
| 641 | let mut rows: Vec<(&String, &Vec<String>)> = inventory | ||
| 642 | .kinds | ||
| 643 | .iter() | ||
| 644 | .filter(|((s, _), _)| *s == stage) | ||
| 645 | .map(|((_, kind), places)| (kind, places)) | ||
| 646 | .collect(); | ||
| 647 | rows.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0))); | ||
| 648 | println!("## {heading}\n"); | ||
| 649 | if rows.is_empty() { | ||
| 650 | println!("None.\n"); | ||
| 651 | continue; | ||
| 652 | } | ||
| 653 | println!("| Kind | Count | Pages | Examples |\n| --- | ---: | ---: | --- |"); | ||
| 654 | for (kind, places) in rows { | ||
| 655 | let pages: BTreeSet<&String> = places.iter().collect(); | ||
| 656 | let examples: Vec<String> = pages.iter().take(3).map(|p| format!("`{p}`")).collect(); | ||
| 657 | println!( | ||
| 658 | "| {} | {} | {} | {} |", | ||
| 659 | kind.replace('|', "\\|"), | ||
| 660 | places.len(), | ||
| 661 | pages.len(), | ||
| 662 | examples.join("<br>").replace('|', "\\|") | ||
| 663 | ); | ||
| 664 | } | ||
| 665 | println!(); | ||
| 666 | } | ||
| 667 | } | ||
crates/canvas/examples/scratch_files.rs deleted-15| ... | @@ -1,15 +0,0 @@ | ||
| 1 | use onestore::{RevisionIndex, Store, document::{Document, Kind}}; | ||
| 2 | fn main() { | ||
| 3 | let bytes = std::fs::read(std::env::args().nth(1).unwrap()).unwrap(); | ||
| 4 | let store = Store::parse(&bytes).unwrap(); | ||
| 5 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 6 | let document = Document::parse(&index).unwrap(); | ||
| 7 | for (space, _) in document.pages().unwrap() { | ||
| 8 | let revision = document.active(space).unwrap(); | ||
| 9 | for (id, node) in &revision.nodes { | ||
| 10 | if let Kind::File { extension, payload, .. } = &node.kind { | ||
| 11 | println!("{id:?} {extension} {:?}", payload.map(|p| (p.len(), &p[..4.min(p.len())]))); | ||
| 12 | } | ||
| 13 | } | ||
| 14 | } | ||
| 15 | } | ||
crates/canvas/examples/scratch_runs.rs deleted-23| ... | @@ -1,23 +0,0 @@ | ||
| 1 | use onestore::{Arena, Section, page::{PageObject}}; | ||
| 2 | fn main() { | ||
| 3 | let path = std::env::args().nth(1).unwrap(); | ||
| 4 | let want = std::env::args().nth(2).unwrap_or_default(); | ||
| 5 | let arena = Arena::default(); | ||
| 6 | let mut section = Section::open(&arena, std::fs::read(path).unwrap()).unwrap(); | ||
| 7 | for (space, _, _) in section.pages().unwrap() { | ||
| 8 | let page = section.page(space).unwrap(); | ||
| 9 | for object in &page.objects { | ||
| 10 | let PageObject::Outline(outline) = object else { continue }; | ||
| 11 | for p in &outline.paragraphs { | ||
| 12 | let Some(text) = p.text() else { continue }; | ||
| 13 | if !text.text.text().contains(want.as_str()) { continue } | ||
| 14 | let mut start = 0; | ||
| 15 | println!("-- {:?}", p.id); | ||
| 16 | for span in text.text.spans() { | ||
| 17 | println!(" {:?} math={:?} obj={:?} emb={:?}", &text.text.text()[start..span.end], span.format.math, span.format.math_object, span.format.embedded_object); | ||
| 18 | start = span.end; | ||
| 19 | } | ||
| 20 | } | ||
| 21 | } | ||
| 22 | } | ||
| 23 | } | ||
crates/canvas/examples/unsupported_inventory.rs+19| ... | @@ -468,6 +468,25 @@ fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Invent | ... | @@ -468,6 +468,25 @@ fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Invent |
| 468 | _ => {} | 468 | _ => {} |
| 469 | } | 469 | } |
| 470 | } | 470 | } |
| 471 | // Pictures with pixels the scene still shows as placeholders, for want of a size or the | ||
| 472 | // like; undecodable ones are counted above. | ||
| 473 | #[cfg(feature = "gpu")] | ||
| 474 | if let Ok((scene, shown)) = canvas::gpu::page::PageScene::from_page(page.clone(), engine) { | ||
| 475 | for object in scene.read_only(Some(&shown)) { | ||
| 476 | if let PageObject::Image(image) = &object.source | ||
| 477 | && [image.bytes.as_ref(), image.display.as_ref()] | ||
| 478 | .into_iter() | ||
| 479 | .flatten() | ||
| 480 | .any(|bytes| draw::RasterImage::measure(bytes).is_ok()) | ||
| 481 | { | ||
| 482 | inventory.add( | ||
| 483 | Stage::Placeholder, | ||
| 484 | "page picture with pixels drawn as \"Image unavailable\"", | ||
| 485 | place, | ||
| 486 | ); | ||
| 487 | } | ||
| 488 | } | ||
| 489 | } | ||
| 471 | let mut editor = match CanvasEditor::from_page(page.clone(), engine) { | 490 | let mut editor = match CanvasEditor::from_page(page.clone(), engine) { |
| 472 | Ok(editor) => editor, | 491 | Ok(editor) => editor, |
| 473 | Err(error) => { | 492 | Err(error) => { |
crates/canvas/src/editor.rs+396-47| ... | @@ -36,7 +36,8 @@ mod recording; | ... | @@ -36,7 +36,8 @@ mod recording; |
| 36 | mod space; | 36 | mod space; |
| 37 | mod table; | 37 | mod table; |
| 38 | pub use format::{ | 38 | pub use format::{ |
| 39 | Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, | 39 | Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, |
| 40 | NoteTag, Toggle, | ||
| 40 | }; | 41 | }; |
| 41 | pub use ink::{FAVORITES, Pen}; | 42 | pub use ink::{FAVORITES, Pen}; |
| 42 | pub use link::{Link, shown_urls}; | 43 | pub use link::{Link, shown_urls}; |
| ... | @@ -81,6 +82,47 @@ pub struct Selection { | ... | @@ -81,6 +82,47 @@ pub struct Selection { |
| 81 | pub affinities: [Affinity; 2], | 82 | pub affinities: [Affinity; 2], |
| 82 | } | 83 | } |
| 83 | 84 | ||
| 85 | /// A piece of pasted content, in the order it comes. | ||
| 86 | pub enum Piece { | ||
| 87 | Text(String), | ||
| 88 | /// An encoded picture and its size in points. | ||
| 89 | Picture(Vec<u8>, [f32; 2]), | ||
| 90 | /// A picture still on its way, which ends the paragraph and goes in where | ||
| 91 | /// [`CanvasEditor::paste_pieces`] says once it arrives. | ||
| 92 | Awaited, | ||
| 93 | } | ||
| 94 | |||
| 95 | /// Where an awaited picture goes: before paragraph `before` of outline `outline`. | ||
| 96 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 97 | pub struct Awaited { | ||
| 98 | outline: ExGuid, | ||
| 99 | before: ExGuid, | ||
| 100 | } | ||
| 101 | |||
| 102 | /// A new picture of encoded `bytes`, `size` points large. | ||
| 103 | pub(crate) fn picture( | ||
| 104 | bytes: Vec<u8>, | ||
| 105 | size: [f32; 2], | ||
| 106 | ) -> Result<onestore::page::Image, EditError> { | ||
| 107 | Ok(onestore::page::Image { | ||
| 108 | id: onestore::page::text::new_id()?, | ||
| 109 | layout: onestore::document::Layout { | ||
| 110 | max_width: Some(size[0]), | ||
| 111 | max_height: Some(size[1]), | ||
| 112 | ..Default::default() | ||
| 113 | }, | ||
| 114 | size: Some(size), | ||
| 115 | bytes: Some(bytes.into()), | ||
| 116 | display: None, | ||
| 117 | alt: None, | ||
| 118 | background: false, | ||
| 119 | printout: None, | ||
| 120 | tags: Vec::new(), | ||
| 121 | link: None, | ||
| 122 | text: None, | ||
| 123 | }) | ||
| 124 | } | ||
| 125 | |||
| 84 | /// What OneNote 2010's Ctrl+A selects beyond text: the focused outline as an object, then every | 126 | /// What OneNote 2010's Ctrl+A selects beyond text: the focused outline as an object, then every |
| 85 | /// body outline on the page. | 127 | /// body outline on the page. |
| 86 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | 128 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| ... | @@ -102,7 +144,7 @@ pub struct CanvasEditor { | ... | @@ -102,7 +144,7 @@ pub struct CanvasEditor { |
| 102 | pub(crate) objects: Vec<page::Content>, | 144 | pub(crate) objects: Vec<page::Content>, |
| 103 | date: Option<PageDate>, | 145 | date: Option<PageDate>, |
| 104 | outlines: Vec<TextOutline>, | 146 | outlines: Vec<TextOutline>, |
| 105 | definitions: BTreeMap<ExGuid, Definition>, | 147 | pub(crate) definitions: BTreeMap<ExGuid, Definition>, |
| 106 | header: PageHeader, | 148 | header: PageHeader, |
| 107 | active: Focus, | 149 | active: Focus, |
| 108 | undo: Vec<History>, | 150 | undo: Vec<History>, |
| ... | @@ -130,6 +172,12 @@ pub struct CanvasEditor { | ... | @@ -130,6 +172,12 @@ pub struct CanvasEditor { |
| 130 | /// and whether it is stored: the first op that reaches it stores it, and undoing back to it | 172 | /// and whether it is stored: the first op that reaches it stores it, and undoing back to it |
| 131 | /// empty and alone removes it again, as OneNote 2010's undo does. | 173 | /// empty and alone removes it again, as OneNote 2010's undo does. |
| 132 | provisional: BTreeMap<ExGuid, (PageParagraph, bool)>, | 174 | provisional: BTreeMap<ExGuid, (PageParagraph, bool)>, |
| 175 | /// What new outlines' text is set in; `PageView::replace` hands it to the next page's | ||
| 176 | /// editor. | ||
| 177 | pub default_font: DefaultFont, | ||
| 178 | /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`, | ||
| 179 | /// and Enter a NextStyle the page lacks from here. | ||
| 180 | pub styles: BTreeMap<String, Definition>, | ||
| 133 | } | 181 | } |
| 134 | 182 | ||
| 135 | /// Imported page state the editable content does not carry. | 183 | /// Imported page state the editable content does not carry. |
| ... | @@ -847,6 +895,11 @@ enum History { | ... | @@ -847,6 +895,11 @@ enum History { |
| 847 | index: usize, | 895 | index: usize, |
| 848 | ink: Option<Box<onestore::page::Ink>>, | 896 | ink: Option<Box<onestore::page::Ink>>, |
| 849 | }, | 897 | }, |
| 898 | /// Gives a picture or file on the page back the note tags it had. | ||
| 899 | Tags { | ||
| 900 | object: ExGuid, | ||
| 901 | tags: Vec<onestore::document::Tag>, | ||
| 902 | }, | ||
| 850 | /// Gives a drawing back the strokes it had. | 903 | /// Gives a drawing back the strokes it had. |
| 851 | Strokes { | 904 | Strokes { |
| 852 | ink: ExGuid, | 905 | ink: ExGuid, |
| ... | @@ -912,6 +965,8 @@ impl CanvasEditor { | ... | @@ -912,6 +965,8 @@ impl CanvasEditor { |
| 912 | recording: None, | 965 | recording: None, |
| 913 | ink_drag: None, | 966 | ink_drag: None, |
| 914 | provisional: BTreeMap::new(), | 967 | provisional: BTreeMap::new(), |
| 968 | default_font: DefaultFont::default(), | ||
| 969 | styles: BTreeMap::new(), | ||
| 915 | }) | 970 | }) |
| 916 | } | 971 | } |
| 917 | 972 | ||
| ... | @@ -1110,6 +1165,7 @@ impl CanvasEditor { | ... | @@ -1110,6 +1165,7 @@ impl CanvasEditor { |
| 1110 | History::Date(_) => date_changed, | 1165 | History::Date(_) => date_changed, |
| 1111 | History::Paper { .. } => false, | 1166 | History::Paper { .. } => false, |
| 1112 | History::Image { .. } | 1167 | History::Image { .. } |
| 1168 | | History::Tags { .. } | ||
| 1113 | | History::Picture { .. } | 1169 | | History::Picture { .. } |
| 1114 | | History::File { .. } | 1170 | | History::File { .. } |
| 1115 | | History::Ink { .. } | 1171 | | History::Ink { .. } |
| ... | @@ -1153,6 +1209,8 @@ impl CanvasEditor { | ... | @@ -1153,6 +1209,8 @@ impl CanvasEditor { |
| 1153 | fresh.preferred_x = self.preferred_x; | 1209 | fresh.preferred_x = self.preferred_x; |
| 1154 | fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new())); | 1210 | fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new())); |
| 1155 | fresh.stored = self.stored.take(); | 1211 | fresh.stored = self.stored.take(); |
| 1212 | fresh.default_font = std::mem::take(&mut self.default_font); | ||
| 1213 | fresh.styles = std::mem::take(&mut self.styles); | ||
| 1156 | *self = fresh; | 1214 | *self = fresh; |
| 1157 | Ok(true) | 1215 | Ok(true) |
| 1158 | } | 1216 | } |
| ... | @@ -1230,6 +1288,12 @@ impl CanvasEditor { | ... | @@ -1230,6 +1288,12 @@ impl CanvasEditor { |
| 1230 | // OneNote stores it, holds only those its paragraphs reference. | 1288 | // OneNote stores it, holds only those its paragraphs reference. |
| 1231 | let mut referenced = BTreeSet::new(); | 1289 | let mut referenced = BTreeSet::new(); |
| 1232 | for object in &objects { | 1290 | for object in &objects { |
| 1291 | let tags = match object { | ||
| 1292 | PageObject::Image(image) => image.tags.as_slice(), | ||
| 1293 | PageObject::Attachment(file) => file.tags.as_slice(), | ||
| 1294 | _ => &[], | ||
| 1295 | }; | ||
| 1296 | referenced.extend(tags.iter().filter_map(|tag| tag.definition)); | ||
| 1233 | let outlines = match object { | 1297 | let outlines = match object { |
| 1234 | PageObject::Outline(outline) => std::slice::from_ref(outline), | 1298 | PageObject::Outline(outline) => std::slice::from_ref(outline), |
| 1235 | PageObject::Title(title) => title.outlines.as_slice(), | 1299 | PageObject::Title(title) => title.outlines.as_slice(), |
| ... | @@ -1327,6 +1391,11 @@ impl CanvasEditor { | ... | @@ -1327,6 +1391,11 @@ impl CanvasEditor { |
| 1327 | self.header.color | 1391 | self.header.color |
| 1328 | } | 1392 | } |
| 1329 | 1393 | ||
| 1394 | /// The page's notebook-management identity, which internal links name. | ||
| 1395 | pub fn identity(&self) -> Option<[u8; 16]> { | ||
| 1396 | self.header.identity | ||
| 1397 | } | ||
| 1398 | |||
| 1330 | /// The page's rule lines (View, Rule Lines). | 1399 | /// The page's rule lines (View, Rule Lines). |
| 1331 | pub fn rule_lines(&self) -> Option<onestore::page::RuleLines> { | 1400 | pub fn rule_lines(&self) -> Option<onestore::page::RuleLines> { |
| 1332 | self.header.rule_lines | 1401 | self.header.rule_lines |
| ... | @@ -1408,16 +1477,8 @@ impl CanvasEditor { | ... | @@ -1408,16 +1477,8 @@ impl CanvasEditor { |
| 1408 | page::Content::Image(image) if !image.background => { | 1477 | page::Content::Image(image) if !image.background => { |
| 1409 | let x = image.layout.x.unwrap_or(0.0); | 1478 | let x = image.layout.x.unwrap_or(0.0); |
| 1410 | let y = image.layout.y.unwrap_or(0.0); | 1479 | let y = image.layout.y.unwrap_or(0.0); |
| 1411 | Some(( | 1480 | let [width, height] = crate::outline::image_size(image)?; |
| 1412 | image.id, | 1481 | Some((image.id, &image.layout, [x, y, x + width, y + height])) |
| 1413 | &image.layout, | ||
| 1414 | [ | ||
| 1415 | x, | ||
| 1416 | y, | ||
| 1417 | x + image.layout.max_width?, | ||
| 1418 | y + image.layout.max_height?, | ||
| 1419 | ], | ||
| 1420 | )) | ||
| 1421 | } | 1482 | } |
| 1422 | page::Content::ReadOnly(object) | 1483 | page::Content::ReadOnly(object) |
| 1423 | if !matches!(object.source, onestore::page::PageObject::Title(_)) => | 1484 | if !matches!(object.source, onestore::page::PageObject::Title(_)) => |
| ... | @@ -1508,6 +1569,8 @@ impl CanvasEditor { | ... | @@ -1508,6 +1569,8 @@ impl CanvasEditor { |
| 1508 | recording: None, | 1569 | recording: None, |
| 1509 | ink_drag: None, | 1570 | ink_drag: None, |
| 1510 | provisional: BTreeMap::new(), | 1571 | provisional: BTreeMap::new(), |
| 1572 | default_font: DefaultFont::default(), | ||
| 1573 | styles: BTreeMap::new(), | ||
| 1511 | }) | 1574 | }) |
| 1512 | } | 1575 | } |
| 1513 | 1576 | ||
| ... | @@ -1709,7 +1772,7 @@ impl CanvasEditor { | ... | @@ -1709,7 +1772,7 @@ impl CanvasEditor { |
| 1709 | position: [f32; 2], | 1772 | position: [f32; 2], |
| 1710 | width: f32, | 1773 | width: f32, |
| 1711 | ) -> Result<(), EditorError> { | 1774 | ) -> Result<(), EditorError> { |
| 1712 | let document = TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?; | 1775 | let document = TextDocument::from_nodes(vec![self.body_paragraph()?])?; |
| 1713 | let outline = TextOutline::new(engine, document, width, position)?; | 1776 | let outline = TextOutline::new(engine, document, width, position)?; |
| 1714 | self.finish_composition(); | 1777 | self.finish_composition(); |
| 1715 | self.active = Focus::Caret { | 1778 | self.active = Focus::Caret { |
| ... | @@ -1736,6 +1799,34 @@ impl CanvasEditor { | ... | @@ -1736,6 +1799,34 @@ impl CanvasEditor { |
| 1736 | }) | 1799 | }) |
| 1737 | } | 1800 | } |
| 1738 | 1801 | ||
| 1802 | /// A new outline's first paragraph as OneNote 2010 starts one: in the page's `p` quick | ||
| 1803 | /// style of the Default font, which a page whose `p` is in another font gains beside it. | ||
| 1804 | fn body_paragraph(&mut self) -> Result<PageParagraph, EditError> { | ||
| 1805 | let definition = match self.styles.get("p") { | ||
| 1806 | Some(themed) => themed.clone(), | ||
| 1807 | None => self.default_font.body_style(), | ||
| 1808 | }; | ||
| 1809 | let known = self.definitions.iter().find(|(_, known)| { | ||
| 1810 | matches!(&known.kind, Kind::Style { name: Some(name), .. } if name == "p") | ||
| 1811 | && known.format.font == definition.format.font | ||
| 1812 | && known.format.font_size == definition.format.font_size | ||
| 1813 | }); | ||
| 1814 | let style = match known { | ||
| 1815 | Some((id, _)) => *id, | ||
| 1816 | None => { | ||
| 1817 | let id = onestore::page::text::new_id()?; | ||
| 1818 | self.definitions.insert(id, definition.clone()); | ||
| 1819 | id | ||
| 1820 | } | ||
| 1821 | }; | ||
| 1822 | let mut node = crate::document::node( | ||
| 1823 | Paragraph::new(String::new(), definition.format), | ||
| 1824 | onestore::document::Format::default(), | ||
| 1825 | )?; | ||
| 1826 | node.style = Some(style); | ||
| 1827 | Ok(node) | ||
| 1828 | } | ||
| 1829 | |||
| 1739 | /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do. | 1830 | /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do. |
| 1740 | fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> { | 1831 | fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> { |
| 1741 | let mut format = self.style_format(base.style)?; | 1832 | let mut format = self.style_format(base.style)?; |
| ... | @@ -1844,10 +1935,7 @@ impl CanvasEditor { | ... | @@ -1844,10 +1935,7 @@ impl CanvasEditor { |
| 1844 | position: [f32; 2], | 1935 | position: [f32; 2], |
| 1845 | width: f32, | 1936 | width: f32, |
| 1846 | ) -> Result<onestore::ExGuid, EditorError> { | 1937 | ) -> Result<onestore::ExGuid, EditorError> { |
| 1847 | let document = TextDocument::new(vec![Paragraph::new( | 1938 | let document = TextDocument::from_nodes(vec![self.body_paragraph()?])?; |
| 1848 | String::new(), | ||
| 1849 | onestore::document::Format::default(), | ||
| 1850 | )])?; | ||
| 1851 | let outline = TextOutline::new(engine, document, width, position)?; | 1939 | let outline = TextOutline::new(engine, document, width, position)?; |
| 1852 | let id = outline.id; | 1940 | let id = outline.id; |
| 1853 | self.finish_composition(); | 1941 | self.finish_composition(); |
| ... | @@ -1935,7 +2023,7 @@ impl CanvasEditor { | ... | @@ -1935,7 +2023,7 @@ impl CanvasEditor { |
| 1935 | let layout = &image.layout; | 2023 | let layout = &image.layout; |
| 1936 | return Some(( | 2024 | return Some(( |
| 1937 | [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], | 2025 | [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], |
| 1938 | [layout.max_width?, layout.max_height?], | 2026 | crate::outline::image_size(image)?, |
| 1939 | )); | 2027 | )); |
| 1940 | } | 2028 | } |
| 1941 | let (outline, ..) = self.outline_picture(id)?; | 2029 | let (outline, ..) = self.outline_picture(id)?; |
| ... | @@ -1963,6 +2051,18 @@ impl CanvasEditor { | ... | @@ -1963,6 +2051,18 @@ impl CanvasEditor { |
| 1963 | }) | 2051 | }) |
| 1964 | } | 2052 | } |
| 1965 | 2053 | ||
| 2054 | /// The address picture `id` links to, in an outline or on the page. | ||
| 2055 | pub fn picture_link(&self, id: ExGuid) -> Option<&str> { | ||
| 2056 | let image = match self.image(id) { | ||
| 2057 | Some(image) => image, | ||
| 2058 | None => match &self.outline_picture(id)?.3.content { | ||
| 2059 | ParagraphContent::Image(image) => image, | ||
| 2060 | _ => return None, | ||
| 2061 | }, | ||
| 2062 | }; | ||
| 2063 | image.link.as_deref() | ||
| 2064 | } | ||
| 2065 | |||
| 1966 | /// The file a paragraph holds or the page shows, which the host opens and saves. | 2066 | /// The file a paragraph holds or the page shows, which the host opens and saves. |
| 1967 | pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> { | 2067 | pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> { |
| 1968 | if let Some((file, _)) = self.page_file(id) { | 2068 | if let Some((file, _)) = self.page_file(id) { |
| ... | @@ -2024,6 +2124,36 @@ impl CanvasEditor { | ... | @@ -2024,6 +2124,36 @@ impl CanvasEditor { |
| 2024 | }) | 2124 | }) |
| 2025 | } | 2125 | } |
| 2026 | 2126 | ||
| 2127 | fn object_tags_mut(&mut self, id: ExGuid) -> Option<&mut Vec<onestore::document::Tag>> { | ||
| 2128 | self.objects.iter_mut().find_map(|object| match object { | ||
| 2129 | page::Content::Image(image) if image.id == id => Some(&mut image.tags), | ||
| 2130 | page::Content::File { source, .. } if source.id == id => Some(&mut source.tags), | ||
| 2131 | _ => None, | ||
| 2132 | }) | ||
| 2133 | } | ||
| 2134 | |||
| 2135 | /// Gives picture or file `id` on the page `tags`, returning those it had. | ||
| 2136 | fn swap_object_tags( | ||
| 2137 | &mut self, | ||
| 2138 | id: ExGuid, | ||
| 2139 | tags: Vec<onestore::document::Tag>, | ||
| 2140 | ) -> Vec<onestore::document::Tag> { | ||
| 2141 | let previous = std::mem::replace(self.object_tags_mut(id).unwrap(), tags); | ||
| 2142 | let tags = self.object_tags_mut(id).unwrap().clone(); | ||
| 2143 | self.record(Ok(ops::tags(id, tags, &self.definitions))); | ||
| 2144 | previous | ||
| 2145 | } | ||
| 2146 | |||
| 2147 | /// Gives picture or file `id` on the page `tags`, as one undo step. | ||
| 2148 | pub(super) fn set_object_tags(&mut self, id: ExGuid, tags: Vec<onestore::document::Tag>) { | ||
| 2149 | let previous = self.swap_object_tags(id, tags); | ||
| 2150 | self.undo.push(History::Tags { | ||
| 2151 | object: id, | ||
| 2152 | tags: previous, | ||
| 2153 | }); | ||
| 2154 | self.redo.clear(); | ||
| 2155 | } | ||
| 2156 | |||
| 2027 | fn image_mut(&mut self, id: ExGuid) -> Option<&mut onestore::page::Image> { | 2157 | fn image_mut(&mut self, id: ExGuid) -> Option<&mut onestore::page::Image> { |
| 2028 | self.objects.iter_mut().find_map(|object| match object { | 2158 | self.objects.iter_mut().find_map(|object| match object { |
| 2029 | page::Content::Image(image) if image.id == id && !image.background => Some(image), | 2159 | page::Content::Image(image) if image.id == id && !image.background => Some(image), |
| ... | @@ -2079,7 +2209,7 @@ impl CanvasEditor { | ... | @@ -2079,7 +2209,7 @@ impl CanvasEditor { |
| 2079 | let image = self.image(id).ok_or(EditError::InvalidRange)?; | 2209 | let image = self.image(id).ok_or(EditError::InvalidRange)?; |
| 2080 | let mut layout = image.layout.clone(); | 2210 | let mut layout = image.layout.clone(); |
| 2081 | [layout.x, layout.y] = origin.map(Some); | 2211 | [layout.x, layout.y] = origin.map(Some); |
| 2082 | if [layout.max_width, layout.max_height] != size.map(Some) { | 2212 | if crate::outline::image_size(image) != Some(size) { |
| 2083 | [layout.max_width, layout.max_height] = size.map(Some); | 2213 | [layout.max_width, layout.max_height] = size.map(Some); |
| 2084 | layout.width_set_by_user = Some(true); | 2214 | layout.width_set_by_user = Some(true); |
| 2085 | } | 2215 | } |
| ... | @@ -2180,12 +2310,39 @@ impl CanvasEditor { | ... | @@ -2180,12 +2310,39 @@ impl CanvasEditor { |
| 2180 | 2310 | ||
| 2181 | /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline | 2311 | /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline |
| 2182 | /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the | 2312 | /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the |
| 2183 | /// caret moving to the grid row below it, where typing starts a new outline. | 2313 | /// caret moving to the grid row below it, where typing starts a new outline. From the |
| 2314 | /// title it goes at the end of an outline where the body starts, in a new one there on a | ||
| 2315 | /// page without body content, and otherwise on the page two grid rows below the content | ||
| 2316 | /// (lab, 2026-09-30). | ||
| 2184 | pub fn insert_picture( | 2317 | pub fn insert_picture( |
| 2185 | &mut self, | 2318 | &mut self, |
| 2186 | engine: &mut TextEngine, | 2319 | engine: &mut TextEngine, |
| 2187 | mut image: onestore::page::Image, | 2320 | mut image: onestore::page::Image, |
| 2188 | ) -> Result<(), EditorError> { | 2321 | ) -> Result<(), EditorError> { |
| 2322 | let margin = self.margin_origin(); | ||
| 2323 | if self.active_outline().title { | ||
| 2324 | let start = self.body_start_of(self.active_outline()); | ||
| 2325 | let first = self | ||
| 2326 | .outlines | ||
| 2327 | .iter() | ||
| 2328 | .find(|outline| !outline.title && outline.origin() == start) | ||
| 2329 | .map(|outline| outline.id); | ||
| 2330 | if let Some(id) = first { | ||
| 2331 | self.focus_outline(id)?; | ||
| 2332 | self.move_selection(engine, Movement::DocumentEnd, false)?; | ||
| 2333 | return self.insert_in_flow(engine, ParagraphContent::Image(image)); | ||
| 2334 | } | ||
| 2335 | let Some(bottom) = self.body_bottom() else { | ||
| 2336 | self.place_caret(engine, start, DEFAULT_OUTLINE_WIDTH)?; | ||
| 2337 | return self.insert_in_flow(engine, ParagraphContent::Image(image)); | ||
| 2338 | }; | ||
| 2339 | let row = ((bottom - margin[1]) / 18.0).floor() + 2.0; | ||
| 2340 | self.place_caret( | ||
| 2341 | engine, | ||
| 2342 | [start[0], margin[1] + row * 18.0], | ||
| 2343 | DEFAULT_OUTLINE_WIDTH, | ||
| 2344 | )?; | ||
| 2345 | } | ||
| 2189 | let Focus::Caret { outline, .. } = &self.active else { | 2346 | let Focus::Caret { outline, .. } = &self.active else { |
| 2190 | return self.insert_in_flow(engine, ParagraphContent::Image(image)); | 2347 | return self.insert_in_flow(engine, ParagraphContent::Image(image)); |
| 2191 | }; | 2348 | }; |
| ... | @@ -2198,9 +2355,123 @@ impl CanvasEditor { | ... | @@ -2198,9 +2355,123 @@ impl CanvasEditor { |
| 2198 | self.add_picture(index, image); | 2355 | self.add_picture(index, image); |
| 2199 | self.undo.push(History::Picture { index, image: None }); | 2356 | self.undo.push(History::Picture { index, image: None }); |
| 2200 | self.redo.clear(); | 2357 | self.redo.clear(); |
| 2201 | let margin = self.margin_origin()[1]; | 2358 | let row = ((y + height - margin[1]) / 18.0).floor() + 1.0; |
| 2202 | let row = ((y + height - margin) / 18.0).floor() + 1.0; | 2359 | self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH) |
| 2203 | self.place_caret(engine, [x, margin + row * 18.0], DEFAULT_OUTLINE_WIDTH) | 2360 | } |
| 2361 | |||
| 2362 | /// Pastes text in `language` and pictures in order, as one undo step, as OneNote 2010 | ||
| 2363 | /// pastes a web page's text with its pictures; the places of the awaited ones, in order. | ||
| 2364 | pub fn paste_pieces( | ||
| 2365 | &mut self, | ||
| 2366 | engine: &mut TextEngine, | ||
| 2367 | pieces: Vec<Piece>, | ||
| 2368 | language: u32, | ||
| 2369 | ) -> Result<Vec<Awaited>, EditorError> { | ||
| 2370 | let depth = self.undo.len(); | ||
| 2371 | let mut result = Ok(()); | ||
| 2372 | let mut awaited = Vec::new(); | ||
| 2373 | for piece in pieces { | ||
| 2374 | result = match piece { | ||
| 2375 | Piece::Text(text) => self.paste(engine, &text, language), | ||
| 2376 | Piece::Picture(bytes, size) => picture(bytes, size) | ||
| 2377 | .map_err(EditorError::from) | ||
| 2378 | .and_then(|image| self.insert_picture(engine, image)), | ||
| 2379 | Piece::Awaited => self.enter(engine, false).and_then(|()| { | ||
| 2380 | let caret = self.selection().positions[1]; | ||
| 2381 | let outline = self.active_outline(); | ||
| 2382 | let (.., node) = outline | ||
| 2383 | .document | ||
| 2384 | .leaf(caret.paragraph) | ||
| 2385 | .ok_or(EditError::InvalidRange)?; | ||
| 2386 | awaited.push(Awaited { | ||
| 2387 | outline: outline.id, | ||
| 2388 | before: node.id, | ||
| 2389 | }); | ||
| 2390 | Ok(()) | ||
| 2391 | }), | ||
| 2392 | }; | ||
| 2393 | if result.is_err() { | ||
| 2394 | break; | ||
| 2395 | } | ||
| 2396 | } | ||
| 2397 | let entries = self.undo.split_off(depth); | ||
| 2398 | if entries.len() > 1 { | ||
| 2399 | self.undo.push(History::Group { | ||
| 2400 | entries, | ||
| 2401 | page: false, | ||
| 2402 | }); | ||
| 2403 | } else { | ||
| 2404 | self.undo.extend(entries); | ||
| 2405 | } | ||
| 2406 | result.map(|()| awaited) | ||
| 2407 | } | ||
| 2408 | |||
| 2409 | /// Puts an awaited picture in its place as an undo step of its own, keeping the focused | ||
| 2410 | /// outline's selection; false where the place has gone. | ||
| 2411 | pub fn insert_awaited( | ||
| 2412 | &mut self, | ||
| 2413 | engine: &mut TextEngine, | ||
| 2414 | at: Awaited, | ||
| 2415 | image: onestore::page::Image, | ||
| 2416 | ) -> Result<bool, EditorError> { | ||
| 2417 | let Some(outline) = self.outlines.iter().find(|outline| outline.id == at.outline) else { | ||
| 2418 | return Ok(false); | ||
| 2419 | }; | ||
| 2420 | let Some((container, index, node)) = descendants(outline.document.nodes(), None) | ||
| 2421 | .find(|(.., node)| node.id == at.before) | ||
| 2422 | .map(|(container, index, node)| (container, index, node.clone())) | ||
| 2423 | else { | ||
| 2424 | return Ok(false); | ||
| 2425 | }; | ||
| 2426 | let picture = PageParagraph { | ||
| 2427 | id: onestore::page::text::new_id()?, | ||
| 2428 | content: ParagraphContent::Image(image), | ||
| 2429 | style: None, | ||
| 2430 | lists: Vec::new(), | ||
| 2431 | tags: Vec::new(), | ||
| 2432 | collapsed: false, | ||
| 2433 | ..node | ||
| 2434 | }; | ||
| 2435 | // A caret on blank page is let go; picture paragraphs take no text positions. | ||
| 2436 | let focused = match self.active { | ||
| 2437 | Focus::Outline(index) => Some((self.outlines[index].id, self.selection())), | ||
| 2438 | _ => None, | ||
| 2439 | }; | ||
| 2440 | self.focus_outline(at.outline)?; | ||
| 2441 | let selection = self.selection(); | ||
| 2442 | self.commit( | ||
| 2443 | engine, | ||
| 2444 | DocumentEdit { | ||
| 2445 | columns: BTreeMap::new(), | ||
| 2446 | container, | ||
| 2447 | range: index..index, | ||
| 2448 | replacement: vec![picture], | ||
| 2449 | }, | ||
| 2450 | selection, | ||
| 2451 | )?; | ||
| 2452 | if let Some((id, selection)) = focused { | ||
| 2453 | self.focus_outline(id)?; | ||
| 2454 | self.select(selection)?; | ||
| 2455 | } | ||
| 2456 | Ok(true) | ||
| 2457 | } | ||
| 2458 | |||
| 2459 | /// The lowest edge of the page's body content, in page points; none on an empty page. | ||
| 2460 | fn body_bottom(&self) -> Option<f32> { | ||
| 2461 | let outlines = self | ||
| 2462 | .outlines | ||
| 2463 | .iter() | ||
| 2464 | .filter(|outline| !outline.title) | ||
| 2465 | .map(|outline| outline.bounds().y1 as f32); | ||
| 2466 | let objects = self | ||
| 2467 | .objects | ||
| 2468 | .iter() | ||
| 2469 | .filter(|object| !object.picture().is_some_and(|image| image.background)) | ||
| 2470 | .filter_map(|object| { | ||
| 2471 | let (_, layout) = object.layout()?; | ||
| 2472 | Some(layout.y? + layout.max_height?) | ||
| 2473 | }); | ||
| 2474 | outlines.chain(objects).reduce(f32::max) | ||
| 2204 | } | 2475 | } |
| 2205 | 2476 | ||
| 2206 | /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the | 2477 | /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the |
| ... | @@ -2316,19 +2587,7 @@ impl CanvasEditor { | ... | @@ -2316,19 +2587,7 @@ impl CanvasEditor { |
| 2316 | }) | 2587 | }) |
| 2317 | .ok_or(EditError::InvalidRange)?; | 2588 | .ok_or(EditError::InvalidRange)?; |
| 2318 | self.finish_composition(); | 2589 | self.finish_composition(); |
| 2319 | let mut image = self.remove_picture(index); | 2590 | let image = self.remove_picture(index); |
| 2320 | // A printout page's XPS package cannot be written back, so undoing its deletion | ||
| 2321 | // restores the picture OneNote showed of it. | ||
| 2322 | if image.printout.is_some() | ||
| 2323 | && image | ||
| 2324 | .bytes | ||
| 2325 | .as_deref() | ||
| 2326 | .is_some_and(|bytes| bytes.starts_with(b"PK\x03\x04")) | ||
| 2327 | && let Some(shown) = image.display.take() | ||
| 2328 | { | ||
| 2329 | image.bytes = Some(shown); | ||
| 2330 | image.printout = None; | ||
| 2331 | } | ||
| 2332 | self.undo.push(History::Picture { | 2591 | self.undo.push(History::Picture { |
| 2333 | index, | 2592 | index, |
| 2334 | image: Some(Box::new(image)), | 2593 | image: Some(Box::new(image)), |
| ... | @@ -3401,8 +3660,13 @@ impl CanvasEditor { | ... | @@ -3401,8 +3660,13 @@ impl CanvasEditor { |
| 3401 | tail.tags = std::mem::take(&mut head.tags); | 3660 | tail.tags = std::mem::take(&mut head.tags); |
| 3402 | tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags); | 3661 | tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags); |
| 3403 | } | 3662 | } |
| 3404 | let style = |id: Option<ExGuid>| match &self.definitions.get(&id?)?.kind { | 3663 | // Enter at a paragraph's end gives the new one its style's NextStyle, as OneNote 2010 |
| 3405 | Kind::Style { name } => name.as_deref(), | 3664 | // follows a heading with Normal. |
| 3665 | let following = match head.style.and_then(|id| self.definitions.get(&id)) { | ||
| 3666 | Some(Definition { | ||
| 3667 | kind: Kind::Style { name, next }, | ||
| 3668 | .. | ||
| 3669 | }) => next.clone().filter(|next| Some(next) != name.as_ref()), | ||
| 3406 | _ => None, | 3670 | _ => None, |
| 3407 | }; | 3671 | }; |
| 3408 | if empty(head) && empty(tail) { | 3672 | if empty(head) && empty(tail) { |
| ... | @@ -3411,16 +3675,9 @@ impl CanvasEditor { | ... | @@ -3411,16 +3675,9 @@ impl CanvasEditor { |
| 3411 | tail.lists.clear(); | 3675 | tail.lists.clear(); |
| 3412 | } | 3676 | } |
| 3413 | } else if empty(tail) | 3677 | } else if empty(tail) |
| 3414 | && matches!( | 3678 | && let Some(following) = following |
| 3415 | style(head.style), | ||
| 3416 | Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6") | ||
| 3417 | ) | ||
| 3418 | { | 3679 | { |
| 3419 | tail.style = self | 3680 | tail.style = self.style_named(&following)?; |
| 3420 | .definitions | ||
| 3421 | .keys() | ||
| 3422 | .copied() | ||
| 3423 | .find(|id| style(Some(*id)) == Some("p")); | ||
| 3424 | // The heading's run formatting carries over under the body style (`c9-h1-*`). | 3681 | // The heading's run formatting carries over under the body style (`c9-h1-*`). |
| 3425 | let run = format.over(&self.style_format(head.style)?); | 3682 | let run = format.over(&self.style_format(head.style)?); |
| 3426 | tail.text_mut().unwrap().text = | 3683 | tail.text_mut().unwrap().text = |
| ... | @@ -3730,6 +3987,26 @@ impl CanvasEditor { | ... | @@ -3730,6 +3987,26 @@ impl CanvasEditor { |
| 3730 | !self.redo.is_empty() | 3987 | !self.redo.is_empty() |
| 3731 | } | 3988 | } |
| 3732 | 3989 | ||
| 3990 | /// How many steps Undo and Redo hold. | ||
| 3991 | pub fn history_depth(&self) -> [usize; 2] { | ||
| 3992 | [self.undo.len(), self.redo.len()] | ||
| 3993 | } | ||
| 3994 | |||
| 3995 | /// Takes up `parked`, the editor this page had when it was last left, history and all, | ||
| 3996 | /// in place of this one: what storage changed since reaches it as `refresh` would. | ||
| 3997 | pub fn resume( | ||
| 3998 | &mut self, | ||
| 3999 | mut parked: CanvasEditor, | ||
| 4000 | engine: &mut TextEngine, | ||
| 4001 | ) -> Result<(), EditorError> { | ||
| 4002 | if let Some((page, _)) = &self.stored { | ||
| 4003 | parked.refresh(page.clone(), engine)?; | ||
| 4004 | parked.default_font = std::mem::take(&mut self.default_font); | ||
| 4005 | *self = parked; | ||
| 4006 | } | ||
| 4007 | Ok(()) | ||
| 4008 | } | ||
| 4009 | |||
| 3733 | pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> { | 4010 | pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> { |
| 3734 | if self.composition.is_some() { | 4011 | if self.composition.is_some() { |
| 3735 | return self.cancel_composition(engine); | 4012 | return self.cancel_composition(engine); |
| ... | @@ -3842,6 +4119,7 @@ impl CanvasEditor { | ... | @@ -3842,6 +4119,7 @@ impl CanvasEditor { |
| 3842 | .iter() | 4119 | .iter() |
| 3843 | .all(|id| self.outlines.iter().any(|item| item.id == **id)), | 4120 | .all(|id| self.outlines.iter().any(|item| item.id == **id)), |
| 3844 | History::Image { image, .. } => self.image(*image).is_some(), | 4121 | History::Image { image, .. } => self.image(*image).is_some(), |
| 4122 | History::Tags { object, .. } => self.object_tags_mut(*object).is_some(), | ||
| 3845 | History::Picture { | 4123 | History::Picture { |
| 3846 | index, | 4124 | index, |
| 3847 | image: Some(_), | 4125 | image: Some(_), |
| ... | @@ -4073,6 +4351,10 @@ impl CanvasEditor { | ... | @@ -4073,6 +4351,10 @@ impl CanvasEditor { |
| 4073 | layout: previous, | 4351 | layout: previous, |
| 4074 | } | 4352 | } |
| 4075 | } | 4353 | } |
| 4354 | History::Tags { object, tags } => History::Tags { | ||
| 4355 | object, | ||
| 4356 | tags: self.swap_object_tags(object, tags), | ||
| 4357 | }, | ||
| 4076 | History::Image { image, layout } => { | 4358 | History::Image { image, layout } => { |
| 4077 | let previous = | 4359 | let previous = |
| 4078 | std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout); | 4360 | std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout); |
| ... | @@ -4262,6 +4544,7 @@ impl CanvasEditor { | ... | @@ -4262,6 +4544,7 @@ impl CanvasEditor { |
| 4262 | .replace(range.clone(), replacement)?; | 4544 | .replace(range.clone(), replacement)?; |
| 4263 | self.own_lists(&mut edit)?; | 4545 | self.own_lists(&mut edit)?; |
| 4264 | self.link_notes(&mut edit)?; | 4546 | self.link_notes(&mut edit)?; |
| 4547 | self.fit_columns(engine, &mut edit)?; | ||
| 4265 | let inverse = self.apply( | 4548 | let inverse = self.apply( |
| 4266 | engine, | 4549 | engine, |
| 4267 | edit, | 4550 | edit, |
| ... | @@ -4361,6 +4644,7 @@ impl CanvasEditor { | ... | @@ -4361,6 +4644,7 @@ impl CanvasEditor { |
| 4361 | ) -> Result<(), EditorError> { | 4644 | ) -> Result<(), EditorError> { |
| 4362 | self.own_lists(&mut edit)?; | 4645 | self.own_lists(&mut edit)?; |
| 4363 | self.link_notes(&mut edit)?; | 4646 | self.link_notes(&mut edit)?; |
| 4647 | self.fit_columns(engine, &mut edit)?; | ||
| 4364 | let inverse = self.apply(engine, edit, selection, true, None)?; | 4648 | let inverse = self.apply(engine, edit, selection, true, None)?; |
| 4365 | self.record_change(inverse); | 4649 | self.record_change(inverse); |
| 4366 | Ok(()) | 4650 | Ok(()) |
| ... | @@ -6152,6 +6436,9 @@ mod tests { | ... | @@ -6152,6 +6436,9 @@ mod tests { |
| 6152 | alt: None, | 6436 | alt: None, |
| 6153 | background, | 6437 | background, |
| 6154 | printout: None, | 6438 | printout: None, |
| 6439 | tags: Vec::new(), | ||
| 6440 | link: None, | ||
| 6441 | text: None, | ||
| 6155 | }); | 6442 | }); |
| 6156 | let (id, background_id) = (picture.id, background.id); | 6443 | let (id, background_id) = (picture.id, background.id); |
| 6157 | let mut editor = CanvasEditor::from_page( | 6444 | let mut editor = CanvasEditor::from_page( |
| ... | @@ -6260,6 +6547,9 @@ mod tests { | ... | @@ -6260,6 +6547,9 @@ mod tests { |
| 6260 | alt: None, | 6547 | alt: None, |
| 6261 | background, | 6548 | background, |
| 6262 | printout: None, | 6549 | printout: None, |
| 6550 | tags: Vec::new(), | ||
| 6551 | link: None, | ||
| 6552 | text: None, | ||
| 6263 | }; | 6553 | }; |
| 6264 | let [old, picture] = [image(true, b"old art"), image(false, b"picture")]; | 6554 | let [old, picture] = [image(true, b"old art"), image(false, b"picture")]; |
| 6265 | let mut editor = CanvasEditor::from_page( | 6555 | let mut editor = CanvasEditor::from_page( |
| ... | @@ -6349,6 +6639,9 @@ mod tests { | ... | @@ -6349,6 +6639,9 @@ mod tests { |
| 6349 | alt: None, | 6639 | alt: None, |
| 6350 | background: false, | 6640 | background: false, |
| 6351 | printout: None, | 6641 | printout: None, |
| 6642 | tags: Vec::new(), | ||
| 6643 | link: None, | ||
| 6644 | text: None, | ||
| 6352 | }; | 6645 | }; |
| 6353 | let id = picture.id; | 6646 | let id = picture.id; |
| 6354 | let objects = vec![ | 6647 | let objects = vec![ |
| ... | @@ -6414,6 +6707,9 @@ mod tests { | ... | @@ -6414,6 +6707,9 @@ mod tests { |
| 6414 | alt: None, | 6707 | alt: None, |
| 6415 | background: false, | 6708 | background: false, |
| 6416 | printout: None, | 6709 | printout: None, |
| 6710 | tags: Vec::new(), | ||
| 6711 | link: None, | ||
| 6712 | text: None, | ||
| 6417 | }; | 6713 | }; |
| 6418 | picture.content = ParagraphContent::Image(image.clone()); | 6714 | picture.content = ParagraphContent::Image(image.clone()); |
| 6419 | source.paragraphs.insert(1, picture); | 6715 | source.paragraphs.insert(1, picture); |
| ... | @@ -6562,6 +6858,7 @@ mod tests { | ... | @@ -6562,6 +6858,7 @@ mod tests { |
| 6562 | bytes: Some(std::sync::Arc::from(b"payload".as_slice())), | 6858 | bytes: Some(std::sync::Arc::from(b"payload".as_slice())), |
| 6563 | preview: None, | 6859 | preview: None, |
| 6564 | recording: None, | 6860 | recording: None, |
| 6861 | tags: Vec::new(), | ||
| 6565 | }; | 6862 | }; |
| 6566 | let at = |paragraph, offset| Selection { | 6863 | let at = |paragraph, offset| Selection { |
| 6567 | positions: [TextPosition { paragraph, offset }; 2], | 6864 | positions: [TextPosition { paragraph, offset }; 2], |
| ... | @@ -6651,6 +6948,9 @@ mod tests { | ... | @@ -6651,6 +6948,9 @@ mod tests { |
| 6651 | alt: None, | 6948 | alt: None, |
| 6652 | background: false, | 6949 | background: false, |
| 6653 | printout: None, | 6950 | printout: None, |
| 6951 | tags: Vec::new(), | ||
| 6952 | link: None, | ||
| 6953 | text: None, | ||
| 6654 | }); | 6954 | }); |
| 6655 | let (mixed_id, picture_id) = (mixed.id, picture_only.id); | 6955 | let (mixed_id, picture_id) = (mixed.id, picture_only.id); |
| 6656 | let editor = CanvasEditor::from_page( | 6956 | let editor = CanvasEditor::from_page( |
| ... | @@ -6736,6 +7036,9 @@ mod tests { | ... | @@ -6736,6 +7036,9 @@ mod tests { |
| 6736 | alt: None, | 7036 | alt: None, |
| 6737 | background, | 7037 | background, |
| 6738 | printout: None, | 7038 | printout: None, |
| 7039 | tags: Vec::new(), | ||
| 7040 | link: None, | ||
| 7041 | text: None, | ||
| 6739 | }); | 7042 | }); |
| 6740 | let image_ids = images.each_ref().map(|image| image.id); | 7043 | let image_ids = images.each_ref().map(|image| image.id); |
| 6741 | let image_bytes = images | 7044 | let image_bytes = images |
| ... | @@ -6990,6 +7293,7 @@ mod tests { | ... | @@ -6990,6 +7293,7 @@ mod tests { |
| 6990 | Definition { | 7293 | Definition { |
| 6991 | kind: onestore::document::Kind::Style { | 7294 | kind: onestore::document::Kind::Style { |
| 6992 | name: Some("p".into()), | 7295 | name: Some("p".into()), |
| 7296 | next: None, | ||
| 6993 | }, | 7297 | }, |
| 6994 | format: base.clone(), | 7298 | format: base.clone(), |
| 6995 | }, | 7299 | }, |
| ... | @@ -7057,6 +7361,7 @@ mod tests { | ... | @@ -7057,6 +7361,7 @@ mod tests { |
| 7057 | Definition { | 7361 | Definition { |
| 7058 | kind: onestore::document::Kind::Style { | 7362 | kind: onestore::document::Kind::Style { |
| 7059 | name: Some("p".into()), | 7363 | name: Some("p".into()), |
| 7364 | next: None, | ||
| 7060 | }, | 7365 | }, |
| 7061 | format: base.clone(), | 7366 | format: base.clone(), |
| 7062 | }, | 7367 | }, |
| ... | @@ -7258,6 +7563,47 @@ mod tests { | ... | @@ -7258,6 +7563,47 @@ mod tests { |
| 7258 | assert_eq!(editor.outlines[0].document, source); | 7563 | assert_eq!(editor.outlines[0].document, source); |
| 7259 | } | 7564 | } |
| 7260 | 7565 | ||
| 7566 | /// New text takes the page's `p` quick style in the Default font, and a page whose `p` | ||
| 7567 | /// is in another font gains a second `p` beside it, as OneNote 2010 does | ||
| 7568 | /// (`corpus/default-font`). | ||
| 7569 | #[test] | ||
| 7570 | fn new_outlines_take_a_body_style_in_the_default_font() { | ||
| 7571 | let mut engine = TextEngine::default(); | ||
| 7572 | let source = | ||
| 7573 | TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(); | ||
| 7574 | let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap(); | ||
| 7575 | let style = |editor: &CanvasEditor, outline: usize| { | ||
| 7576 | let node = &editor.outlines[outline].document.nodes()[0]; | ||
| 7577 | let definition = &editor.definitions[&node.style.unwrap()]; | ||
| 7578 | assert_eq!( | ||
| 7579 | node.text().unwrap().text.spans()[0].format, | ||
| 7580 | definition.format | ||
| 7581 | ); | ||
| 7582 | (node.style.unwrap(), definition.clone()) | ||
| 7583 | }; | ||
| 7584 | editor.default_font = DefaultFont { | ||
| 7585 | face: "Georgia".into(), | ||
| 7586 | size: 14.0, | ||
| 7587 | color: None, | ||
| 7588 | }; | ||
| 7589 | for (typed, at) in [("one", 300.0), ("two", 400.0)] { | ||
| 7590 | editor.place_caret(&mut engine, [300.0, at], 240.0).unwrap(); | ||
| 7591 | editor.insert(&mut engine, typed).unwrap(); | ||
| 7592 | } | ||
| 7593 | let (first, georgia) = style(&editor, 1); | ||
| 7594 | assert_eq!(style(&editor, 2).0, first, "one p per font"); | ||
| 7595 | assert_eq!(georgia, editor.default_font.body_style()); | ||
| 7596 | editor.default_font = DefaultFont::default(); | ||
| 7597 | editor | ||
| 7598 | .place_caret(&mut engine, [300.0, 500.0], 240.0) | ||
| 7599 | .unwrap(); | ||
| 7600 | editor.insert(&mut engine, "three").unwrap(); | ||
| 7601 | let (third, calibri) = style(&editor, 3); | ||
| 7602 | assert_ne!(third, first); | ||
| 7603 | assert_eq!(calibri.format.font.as_deref(), Some("Calibri")); | ||
| 7604 | assert_eq!(calibri.format.font_size, Some(11.0)); | ||
| 7605 | } | ||
| 7606 | |||
| 7261 | #[test] | 7607 | #[test] |
| 7262 | fn deleting_through_provisional_paragraphs_removes_the_outline_and_restores_draft_on_undo() { | 7608 | fn deleting_through_provisional_paragraphs_removes_the_outline_and_restores_draft_on_undo() { |
| 7263 | let mut engine = TextEngine::default(); | 7609 | let mut engine = TextEngine::default(); |
| ... | @@ -9314,6 +9660,9 @@ mod tests { | ... | @@ -9314,6 +9660,9 @@ mod tests { |
| 9314 | alt: None, | 9660 | alt: None, |
| 9315 | background: false, | 9661 | background: false, |
| 9316 | printout: None, | 9662 | printout: None, |
| 9663 | tags: Vec::new(), | ||
| 9664 | link: None, | ||
| 9665 | text: None, | ||
| 9317 | }); | 9666 | }); |
| 9318 | if objects { | 9667 | if objects { |
| 9319 | let selection = [TextPosition { | 9668 | let selection = [TextPosition { |
crates/canvas/src/editor/evidence.rs+2-1| ... | @@ -47,6 +47,7 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { | ... | @@ -47,6 +47,7 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { |
| 47 | Definition { | 47 | Definition { |
| 48 | kind: Kind::Style { | 48 | kind: Kind::Style { |
| 49 | name: Some(name.into()), | 49 | name: Some(name.into()), |
| 50 | next: (name == "h1").then(|| "p".into()), | ||
| 50 | }, | 51 | }, |
| 51 | format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"), | 52 | format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"), |
| 52 | }, | 53 | }, |
| ... | @@ -182,7 +183,7 @@ fn render(editor: &CanvasEditor) -> String { | ... | @@ -182,7 +183,7 @@ fn render(editor: &CanvasEditor) -> String { |
| 182 | let style = node | 183 | let style = node |
| 183 | .style | 184 | .style |
| 184 | .map(|style| match &editor.definitions[&style].kind { | 185 | .map(|style| match &editor.definitions[&style].kind { |
| 185 | Kind::Style { name } => { | 186 | Kind::Style { name, .. } => { |
| 186 | format!("qs{}", usize::from(name.as_deref() == Some("h1"))) | 187 | format!("qs{}", usize::from(name.as_deref() == Some("h1"))) |
| 187 | } | 188 | } |
| 188 | kind => panic!("{kind:?}"), | 189 | kind => panic!("{kind:?}"), |
crates/canvas/src/editor/format.rs+401-46| ... | @@ -43,6 +43,10 @@ pub enum Formatting { | ... | @@ -43,6 +43,10 @@ pub enum Formatting { |
| 43 | /// Format Painter: gives the selection the character formatting and alignment of text | 43 | /// Format Painter: gives the selection the character formatting and alignment of text |
| 44 | /// picked up with [`CanvasEditor::painted_format`], as OneNote's does. | 44 | /// picked up with [`CanvasEditor::painted_format`], as OneNote's does. |
| 45 | Paint(Format), | 45 | Paint(Format), |
| 46 | /// Gives the selected paragraphs a paragraph style, as OneNote 2010's Styles gallery | ||
| 47 | /// does: the page's style of that definition, made where it has none, and their text | ||
| 48 | /// loses its character formatting but links, fields and language. | ||
| 49 | Style(Definition), | ||
| 46 | } | 50 | } |
| 47 | 51 | ||
| 48 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | 52 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| ... | @@ -164,6 +168,57 @@ impl ListStyle { | ... | @@ -164,6 +168,57 @@ impl ListStyle { |
| 164 | } | 168 | } |
| 165 | } | 169 | } |
| 166 | 170 | ||
| 171 | /// OneNote's Default font (Options > General): what new outlines' text is set in, as the | ||
| 172 | /// page's `p` quick style, and the face and colour new titles take. | ||
| 173 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | ||
| 174 | #[serde(default)] | ||
| 175 | pub struct DefaultFont { | ||
| 176 | pub face: String, | ||
| 177 | pub size: f32, | ||
| 178 | /// COLORREF; none is Automatic. | ||
| 179 | pub color: Option<u32>, | ||
| 180 | } | ||
| 181 | |||
| 182 | impl Default for DefaultFont { | ||
| 183 | /// OneNote 2010's: Calibri 11, Automatic. | ||
| 184 | fn default() -> Self { | ||
| 185 | Self { | ||
| 186 | face: "Calibri".into(), | ||
| 187 | size: 11.0, | ||
| 188 | color: None, | ||
| 189 | } | ||
| 190 | } | ||
| 191 | } | ||
| 192 | |||
| 193 | impl DefaultFont { | ||
| 194 | /// The `p` quick style OneNote 2010 stores for new text in this font | ||
| 195 | /// (`corpus/default-font`): plain, in its colour, no highlight or paragraph spacing. | ||
| 196 | pub(crate) fn body_style(&self) -> Definition { | ||
| 197 | Definition { | ||
| 198 | kind: Kind::Style { | ||
| 199 | name: Some("p".into()), | ||
| 200 | next: None, | ||
| 201 | }, | ||
| 202 | format: Format { | ||
| 203 | bold: Some(false), | ||
| 204 | italic: Some(false), | ||
| 205 | underline: Some(false), | ||
| 206 | strike: Some(false), | ||
| 207 | superscript: Some(false), | ||
| 208 | subscript: Some(false), | ||
| 209 | font: Some(self.face.clone()), | ||
| 210 | font_size: Some(self.size), | ||
| 211 | color: Some(self.color.unwrap_or(0xff00_0000)), | ||
| 212 | highlight: Some(0xff00_0000), | ||
| 213 | space_before: Some(0.0), | ||
| 214 | space_after: Some(0.0), | ||
| 215 | line_spacing: Some(0.0), | ||
| 216 | ..Format::default() | ||
| 217 | }, | ||
| 218 | } | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 167 | /// A tag of the user's list as the Customize Tags dialog edits it: what its definition | 222 | /// 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. | 223 | /// stores besides its place in the list, which the page stores as its action type. |
| 169 | #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | 224 | #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| ... | @@ -293,6 +348,8 @@ pub struct FormatState { | ... | @@ -293,6 +348,8 @@ pub struct FormatState { |
| 293 | pub list: Option<ListStyle>, | 348 | pub list: Option<ListStyle>, |
| 294 | /// Tags every selected paragraph has, with their action types. | 349 | /// Tags every selected paragraph has, with their action types. |
| 295 | pub tags: Vec<(NoteTag, u16)>, | 350 | pub tags: Vec<(NoteTag, u16)>, |
| 351 | /// The stored name of the paragraph style every selected paragraph shares. | ||
| 352 | pub style: Option<String>, | ||
| 296 | } | 353 | } |
| 297 | 354 | ||
| 298 | impl Toggle { | 355 | impl Toggle { |
| ... | @@ -477,6 +534,35 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T | ... | @@ -477,6 +534,35 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T |
| 477 | .then_some(first) | 534 | .then_some(first) |
| 478 | } | 535 | } |
| 479 | 536 | ||
| 537 | /// `format` moved from paragraph style `old` to `new`: what the old style gave, or nothing | ||
| 538 | /// did, the new one gives. | ||
| 539 | fn followed(format: &Format, old: &Format, new: &Format) -> Format { | ||
| 540 | let mut format = format.clone(); | ||
| 541 | macro_rules! follow { | ||
| 542 | ($($field:ident),*) => {$( | ||
| 543 | if format.$field.is_none() || old.$field.is_some() && format.$field == old.$field { | ||
| 544 | format.$field = new.$field.clone(); | ||
| 545 | } | ||
| 546 | )*}; | ||
| 547 | } | ||
| 548 | follow!( | ||
| 549 | bold, | ||
| 550 | italic, | ||
| 551 | underline, | ||
| 552 | strike, | ||
| 553 | superscript, | ||
| 554 | subscript, | ||
| 555 | font, | ||
| 556 | font_size, | ||
| 557 | color, | ||
| 558 | highlight, | ||
| 559 | space_before, | ||
| 560 | space_after, | ||
| 561 | line_spacing | ||
| 562 | ); | ||
| 563 | format | ||
| 564 | } | ||
| 565 | |||
| 480 | /// Seconds since 1980, as note tags date themselves. | 566 | /// Seconds since 1980, as note tags date themselves. |
| 481 | fn time32() -> Option<u32> { | 567 | fn time32() -> Option<u32> { |
| 482 | let now = std::time::SystemTime::now() | 568 | let now = std::time::SystemTime::now() |
| ... | @@ -629,6 +715,12 @@ impl CanvasEditor { | ... | @@ -629,6 +715,12 @@ impl CanvasEditor { |
| 629 | shared.sort_by_key(|(_, action_type)| *action_type); | 715 | shared.sort_by_key(|(_, action_type)| *action_type); |
| 630 | shared | 716 | shared |
| 631 | }), | 717 | }), |
| 718 | style: common(paragraphs.iter().map(|node| { | ||
| 719 | match &self.definitions.get(&node.style?)?.kind { | ||
| 720 | Kind::Style { name, .. } => name.clone(), | ||
| 721 | _ => None, | ||
| 722 | } | ||
| 723 | })), | ||
| 632 | }) | 724 | }) |
| 633 | } | 725 | } |
| 634 | 726 | ||
| ... | @@ -765,36 +857,8 @@ impl CanvasEditor { | ... | @@ -765,36 +857,8 @@ impl CanvasEditor { |
| 765 | let ParagraphContent::Text(text) = &mut node.content else { | 857 | let ParagraphContent::Text(text) = &mut node.content else { |
| 766 | unreachable!() | 858 | unreachable!() |
| 767 | }; | 859 | }; |
| 768 | // An element holds one tag of each action type. | 860 | let added = (!remove).then_some((id, tag.shape, created)); |
| 769 | for tags in [&mut node.tags, &mut text.tags] { | 861 | self.retag([&mut node.tags, &mut text.tags], &definition.kind, added); |
| 770 | tags.retain(|tag| match self.tag_kind(tag) { | ||
| 771 | Some(kind) if remove => *kind != definition.kind, | ||
| 772 | Some(Kind::TagDefinition { | ||
| 773 | action_type: other, .. | ||
| 774 | }) => *other != Some(*action_type), | ||
| 775 | _ => true, | ||
| 776 | }); | ||
| 777 | } | ||
| 778 | if !remove { | ||
| 779 | let checkable = crate::outline::checkable(tag.shape); | ||
| 780 | // Stored newest first. | ||
| 781 | text.tags.insert( | ||
| 782 | 0, | ||
| 783 | Tag { | ||
| 784 | definition: Some(id), | ||
| 785 | action_type: None, | ||
| 786 | shape: None, | ||
| 787 | property_status: None, | ||
| 788 | status: u16::from(!checkable), | ||
| 789 | created, | ||
| 790 | completed: if checkable { Some(0) } else { created }, | ||
| 791 | start: None, | ||
| 792 | due: None, | ||
| 793 | task_id: None, | ||
| 794 | extra_set: 0, | ||
| 795 | }, | ||
| 796 | ); | ||
| 797 | } | ||
| 798 | }); | 862 | }); |
| 799 | } | 863 | } |
| 800 | Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| { | 864 | Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| { |
| ... | @@ -804,6 +868,39 @@ impl CanvasEditor { | ... | @@ -804,6 +868,39 @@ impl CanvasEditor { |
| 804 | } | 868 | } |
| 805 | }), | 869 | }), |
| 806 | Formatting::Check => self.check(&mut replacement, ranges, ends), | 870 | Formatting::Check => self.check(&mut replacement, ranges, ends), |
| 871 | Formatting::Style(_) if title => return Ok(()), | ||
| 872 | Formatting::Style(definition) => { | ||
| 873 | let olds = bases(&replacement)?; | ||
| 874 | let style = self.define_style(definition)?; | ||
| 875 | leaves_mut(&mut replacement, &mut |node| { | ||
| 876 | if ranges.remove(&node.id).is_some() { | ||
| 877 | node.style = Some(style); | ||
| 878 | let old = &olds[&node.id]; | ||
| 879 | let text = &mut node.text_mut().unwrap().text; | ||
| 880 | *text = restyle(text, 0..text.text().len(), |format| { | ||
| 881 | // An equation keeps its own formatting; what it took from the | ||
| 882 | // old style it takes from the new. | ||
| 883 | if [format.math, format.embedded_object].contains(&Some(true)) { | ||
| 884 | *format = followed(format, old, &definition.format); | ||
| 885 | return; | ||
| 886 | } | ||
| 887 | *format = Format { | ||
| 888 | hidden: format.hidden, | ||
| 889 | hyperlink: format.hyperlink, | ||
| 890 | hyperlink_label: format.hyperlink_label, | ||
| 891 | math: format.math, | ||
| 892 | embedded_object: format.embedded_object, | ||
| 893 | language: format.language, | ||
| 894 | alignment: format.alignment, | ||
| 895 | rtl: format.rtl, | ||
| 896 | list_spacing: format.list_spacing, | ||
| 897 | math_object: format.math_object.clone(), | ||
| 898 | ..definition.format.clone() | ||
| 899 | }; | ||
| 900 | }); | ||
| 901 | } | ||
| 902 | }); | ||
| 903 | } | ||
| 807 | Formatting::Toggle(_) | 904 | Formatting::Toggle(_) |
| 808 | | Formatting::Font(_) | 905 | | Formatting::Font(_) |
| 809 | | Formatting::FontSize(_) | 906 | | Formatting::FontSize(_) |
| ... | @@ -906,6 +1003,43 @@ impl CanvasEditor { | ... | @@ -906,6 +1003,43 @@ impl CanvasEditor { |
| 906 | } | 1003 | } |
| 907 | } | 1004 | } |
| 908 | 1005 | ||
| 1006 | impl CanvasEditor { | ||
| 1007 | /// The page's paragraph style holding `definition`, made where it has none. | ||
| 1008 | pub(super) fn define_style(&mut self, definition: &Definition) -> Result<ExGuid, EditError> { | ||
| 1009 | if let Some((id, _)) = self | ||
| 1010 | .definitions | ||
| 1011 | .iter() | ||
| 1012 | .find(|(_, kept)| *kept == definition) | ||
| 1013 | { | ||
| 1014 | return Ok(*id); | ||
| 1015 | } | ||
| 1016 | let id = new_id()?; | ||
| 1017 | self.definitions.insert(id, definition.clone()); | ||
| 1018 | Ok(id) | ||
| 1019 | } | ||
| 1020 | |||
| 1021 | /// A paragraph style named `name`: the page's in its theme's formatting, else the page's | ||
| 1022 | /// first of that name, else the theme's made on the page; Normal falls back to the | ||
| 1023 | /// Default font's. None where nothing names it. | ||
| 1024 | pub(super) fn style_named(&mut self, name: &str) -> Result<Option<ExGuid>, EditError> { | ||
| 1025 | let themed = self.styles.get(name).cloned(); | ||
| 1026 | let named = |definition: &Definition| matches!(&definition.kind, Kind::Style { name: Some(own), .. } if own == name); | ||
| 1027 | let known = self | ||
| 1028 | .definitions | ||
| 1029 | .iter() | ||
| 1030 | .filter(|(_, kept)| named(kept)) | ||
| 1031 | .min_by_key(|(_, kept)| Some(*kept) != themed.as_ref()) | ||
| 1032 | .map(|(id, _)| *id); | ||
| 1033 | if known.is_some() { | ||
| 1034 | return Ok(known); | ||
| 1035 | } | ||
| 1036 | let definition = themed.or_else(|| (name == "p").then(|| self.default_font.body_style())); | ||
| 1037 | definition | ||
| 1038 | .map(|definition| self.define_style(&definition)) | ||
| 1039 | .transpose() | ||
| 1040 | } | ||
| 1041 | } | ||
| 1042 | |||
| 909 | impl CanvasEditor { | 1043 | impl CanvasEditor { |
| 910 | /// A toolbar command on OneNote 2010's page selection: each outline takes it as if selected | 1044 | /// A toolbar command on OneNote 2010's page selection: each outline takes it as if selected |
| 911 | /// alone, but a toggle turns on everywhere unless every outline already has it. | 1045 | /// alone, but a toggle turns on everywhere unless every outline already has it. |
| ... | @@ -991,6 +1125,167 @@ impl CanvasEditor { | ... | @@ -991,6 +1125,167 @@ impl CanvasEditor { |
| 991 | .find(|(_, _, node)| node.id == id) | 1125 | .find(|(_, _, node)| node.id == id) |
| 992 | .ok_or(EditError::InvalidRange)?; | 1126 | .ok_or(EditError::InvalidRange)?; |
| 993 | let mut node = node.clone(); | 1127 | let mut node = node.clone(); |
| 1128 | let content = match &mut node.content { | ||
| 1129 | ParagraphContent::Table(table) => table.tags.as_mut_slice(), | ||
| 1130 | ParagraphContent::Image(image) => image.tags.as_mut_slice(), | ||
| 1131 | ParagraphContent::Attachment(file) => file.tags.as_mut_slice(), | ||
| 1132 | _ => &mut [], | ||
| 1133 | }; | ||
| 1134 | self.toggle_checks(node.tags.iter_mut().chain(content).collect()); | ||
| 1135 | let selection = outline.selection; | ||
| 1136 | self.commit( | ||
| 1137 | engine, | ||
| 1138 | DocumentEdit { | ||
| 1139 | columns: BTreeMap::new(), | ||
| 1140 | container, | ||
| 1141 | range: index..index + 1, | ||
| 1142 | replacement: vec![node], | ||
| 1143 | }, | ||
| 1144 | selection, | ||
| 1145 | ) | ||
| 1146 | } | ||
| 1147 | |||
| 1148 | /// A tag command on selected picture or file `id`, which OneNote 2010 tags itself rather | ||
| 1149 | /// than its paragraph (`corpus/object-tags`); false for other commands. | ||
| 1150 | pub fn format_object( | ||
| 1151 | &mut self, | ||
| 1152 | engine: &mut TextEngine, | ||
| 1153 | id: ExGuid, | ||
| 1154 | command: &Formatting, | ||
| 1155 | ) -> Result<bool, EditorError> { | ||
| 1156 | if !matches!( | ||
| 1157 | command, | ||
| 1158 | Formatting::Tag(..) | Formatting::RemoveTags | Formatting::Check | ||
| 1159 | ) { | ||
| 1160 | return Ok(false); | ||
| 1161 | } | ||
| 1162 | let node = self | ||
| 1163 | .outline_picture(id) | ||
| 1164 | .map(|(outline, _, _, node)| (outline, node.clone())); | ||
| 1165 | let mut tags = match &node { | ||
| 1166 | Some((_, node)) => match &node.content { | ||
| 1167 | ParagraphContent::Image(image) => image.tags.clone(), | ||
| 1168 | ParagraphContent::Attachment(file) => file.tags.clone(), | ||
| 1169 | _ => return Ok(false), | ||
| 1170 | }, | ||
| 1171 | None => self | ||
| 1172 | .object_tags_mut(id) | ||
| 1173 | .ok_or(EditError::InvalidRange)? | ||
| 1174 | .clone(), | ||
| 1175 | }; | ||
| 1176 | match command { | ||
| 1177 | Formatting::Tag(tag, action_type) => { | ||
| 1178 | let definition = tag.definition(*action_type); | ||
| 1179 | let existing = self | ||
| 1180 | .definitions | ||
| 1181 | .iter() | ||
| 1182 | .find(|(_, other)| other.kind == definition.kind) | ||
| 1183 | .map(|(id, _)| *id); | ||
| 1184 | let defined = match existing { | ||
| 1185 | Some(id) => id, | ||
| 1186 | None => { | ||
| 1187 | let id = new_id()?; | ||
| 1188 | self.definitions.insert(id, definition.clone()); | ||
| 1189 | id | ||
| 1190 | } | ||
| 1191 | }; | ||
| 1192 | let has = tags | ||
| 1193 | .iter() | ||
| 1194 | .any(|tag| self.tag_kind(tag) == Some(&definition.kind)); | ||
| 1195 | let added = (!has).then_some((defined, tag.shape, time32())); | ||
| 1196 | self.retag([&mut tags], &definition.kind, added); | ||
| 1197 | } | ||
| 1198 | Formatting::RemoveTags => tags.clear(), | ||
| 1199 | _ => self.toggle_checks(tags.iter_mut().collect()), | ||
| 1200 | } | ||
| 1201 | self.finish_composition(); | ||
| 1202 | let Some((outline, mut node)) = node else { | ||
| 1203 | self.set_object_tags(id, tags); | ||
| 1204 | return Ok(true); | ||
| 1205 | }; | ||
| 1206 | match &mut node.content { | ||
| 1207 | ParagraphContent::Image(image) => image.tags = tags, | ||
| 1208 | ParagraphContent::Attachment(file) => file.tags = tags, | ||
| 1209 | _ => unreachable!(), | ||
| 1210 | } | ||
| 1211 | self.focus_outline(outline)?; | ||
| 1212 | let (container, index, _) = descendants(self.active_outline().document.nodes(), None) | ||
| 1213 | .find(|(_, _, other)| other.id == node.id) | ||
| 1214 | .ok_or(EditError::InvalidRange)?; | ||
| 1215 | let selection = self.active_outline().selection; | ||
| 1216 | self.commit( | ||
| 1217 | engine, | ||
| 1218 | DocumentEdit { | ||
| 1219 | columns: BTreeMap::new(), | ||
| 1220 | container, | ||
| 1221 | range: index..index + 1, | ||
| 1222 | replacement: vec![node], | ||
| 1223 | }, | ||
| 1224 | selection, | ||
| 1225 | )?; | ||
| 1226 | Ok(true) | ||
| 1227 | } | ||
| 1228 | |||
| 1229 | /// A click on the check box of picture or file `id` on the page. | ||
| 1230 | pub fn click_object_check(&mut self, id: ExGuid) -> Result<(), EditorError> { | ||
| 1231 | let mut tags = self | ||
| 1232 | .objects | ||
| 1233 | .iter() | ||
| 1234 | .find_map(|object| object.tagged().filter(|tagged| tagged.0 == id)) | ||
| 1235 | .ok_or(EditError::InvalidRange)? | ||
| 1236 | .1 | ||
| 1237 | .to_vec(); | ||
| 1238 | self.toggle_checks(tags.iter_mut().collect()); | ||
| 1239 | self.finish_composition(); | ||
| 1240 | self.set_object_tags(id, tags); | ||
| 1241 | Ok(()) | ||
| 1242 | } | ||
| 1243 | |||
| 1244 | /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its | ||
| 1245 | /// shape and when, gives the last list one in place of any of its action type; an element | ||
| 1246 | /// holds one tag of each action type, stored newest first. | ||
| 1247 | fn retag<const N: usize>( | ||
| 1248 | &self, | ||
| 1249 | mut lists: [&mut Vec<Tag>; N], | ||
| 1250 | kind: &Kind<'static>, | ||
| 1251 | added: Option<(ExGuid, u16, Option<u32>)>, | ||
| 1252 | ) { | ||
| 1253 | let Kind::TagDefinition { action_type, .. } = kind else { | ||
| 1254 | return; | ||
| 1255 | }; | ||
| 1256 | for tags in lists.iter_mut() { | ||
| 1257 | tags.retain(|tag| match self.tag_kind(tag) { | ||
| 1258 | Some(other) if added.is_none() => other != kind, | ||
| 1259 | Some(Kind::TagDefinition { | ||
| 1260 | action_type: other, .. | ||
| 1261 | }) => other != action_type, | ||
| 1262 | _ => true, | ||
| 1263 | }); | ||
| 1264 | } | ||
| 1265 | let (Some((id, shape, created)), Some(tags)) = (added, lists.last_mut()) else { | ||
| 1266 | return; | ||
| 1267 | }; | ||
| 1268 | let checkable = crate::outline::checkable(shape); | ||
| 1269 | tags.insert( | ||
| 1270 | 0, | ||
| 1271 | Tag { | ||
| 1272 | definition: Some(id), | ||
| 1273 | action_type: None, | ||
| 1274 | shape: None, | ||
| 1275 | property_status: None, | ||
| 1276 | status: u16::from(!checkable), | ||
| 1277 | created, | ||
| 1278 | completed: if checkable { Some(0) } else { created }, | ||
| 1279 | start: None, | ||
| 1280 | due: None, | ||
| 1281 | task_id: None, | ||
| 1282 | extra_set: 0, | ||
| 1283 | }, | ||
| 1284 | ); | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | /// Checks the check boxes among `tags`, or clears them once all are checked. | ||
| 1288 | fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { | ||
| 994 | let checkable = |tag: &Tag| { | 1289 | let checkable = |tag: &Tag| { |
| 995 | matches!( | 1290 | matches!( |
| 996 | self.tag_kind(tag), | 1291 | self.tag_kind(tag), |
| ... | @@ -998,11 +1293,6 @@ impl CanvasEditor { | ... | @@ -998,11 +1293,6 @@ impl CanvasEditor { |
| 998 | if crate::outline::checkable(*shape) | 1293 | if crate::outline::checkable(*shape) |
| 999 | ) | 1294 | ) |
| 1000 | }; | 1295 | }; |
| 1001 | let content = match &mut node.content { | ||
| 1002 | ParagraphContent::Table(table) => table.tags.as_mut_slice(), | ||
| 1003 | _ => &mut [], | ||
| 1004 | }; | ||
| 1005 | let mut tags: Vec<&mut Tag> = node.tags.iter_mut().chain(content).collect(); | ||
| 1006 | let checked = tags | 1296 | let checked = tags |
| 1007 | .iter() | 1297 | .iter() |
| 1008 | .filter(|tag| checkable(tag)) | 1298 | .filter(|tag| checkable(tag)) |
| ... | @@ -1014,17 +1304,6 @@ impl CanvasEditor { | ... | @@ -1014,17 +1304,6 @@ impl CanvasEditor { |
| 1014 | tag.completed = completed; | 1304 | tag.completed = completed; |
| 1015 | } | 1305 | } |
| 1016 | } | 1306 | } |
| 1017 | let selection = outline.selection; | ||
| 1018 | self.commit( | ||
| 1019 | engine, | ||
| 1020 | DocumentEdit { | ||
| 1021 | columns: BTreeMap::new(), | ||
| 1022 | container, | ||
| 1023 | range: index..index + 1, | ||
| 1024 | replacement: vec![node], | ||
| 1025 | }, | ||
| 1026 | selection, | ||
| 1027 | ) | ||
| 1028 | } | 1307 | } |
| 1029 | 1308 | ||
| 1030 | /// Checks the check boxes of the `covered` paragraphs, or clears them once all are | 1309 | /// Checks the check boxes of the `covered` paragraphs, or clears them once all are |
| ... | @@ -1344,6 +1623,80 @@ mod tests { | ... | @@ -1344,6 +1623,80 @@ mod tests { |
| 1344 | ); | 1623 | ); |
| 1345 | } | 1624 | } |
| 1346 | 1625 | ||
| 1626 | /// A style from the gallery replaces the paragraph's character formatting but keeps its | ||
| 1627 | /// language, as OneNote 2010's does; Enter at its end takes its NextStyle, from the | ||
| 1628 | /// theme where the page has none (lab, 2026-09-30). | ||
| 1629 | #[test] | ||
| 1630 | fn a_gallery_style_clears_formatting_and_enter_takes_its_next_style() { | ||
| 1631 | let mut engine = TextEngine::default(); | ||
| 1632 | let body = Format { | ||
| 1633 | font: Some("Calibri".into()), | ||
| 1634 | font_size: Some(11.0), | ||
| 1635 | language: Some(0x40c), | ||
| 1636 | ..Format::default() | ||
| 1637 | }; | ||
| 1638 | let source = TextDocument::new(vec![Paragraph::from_runs([ | ||
| 1639 | ("Plain ".to_owned(), body.clone()), | ||
| 1640 | ( | ||
| 1641 | "red".to_owned(), | ||
| 1642 | Format { | ||
| 1643 | color: Some(0xff), | ||
| 1644 | italic: Some(true), | ||
| 1645 | ..body.clone() | ||
| 1646 | }, | ||
| 1647 | ), | ||
| 1648 | ])]) | ||
| 1649 | .unwrap(); | ||
| 1650 | let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap(); | ||
| 1651 | let style = |name: &str, size, next: Option<&str>| Definition { | ||
| 1652 | kind: Kind::Style { | ||
| 1653 | name: Some(name.into()), | ||
| 1654 | next: next.map(Into::into), | ||
| 1655 | }, | ||
| 1656 | format: Format { | ||
| 1657 | bold: Some(name != "p"), | ||
| 1658 | italic: Some(false), | ||
| 1659 | font: Some("Georgia".into()), | ||
| 1660 | font_size: Some(size), | ||
| 1661 | color: Some(0x0033_2211), | ||
| 1662 | space_before: Some(12.0), | ||
| 1663 | ..Format::default() | ||
| 1664 | }, | ||
| 1665 | }; | ||
| 1666 | let heading = style("h2", 16.0, Some("p")); | ||
| 1667 | editor.styles = BTreeMap::from([("p".to_owned(), style("p", 12.0, None))]); | ||
| 1668 | editor.select([at(0, 3); 2].into()).unwrap(); | ||
| 1669 | editor | ||
| 1670 | .format(&mut engine, Formatting::Style(heading.clone())) | ||
| 1671 | .unwrap(); | ||
| 1672 | let node = editor.active_outline().document.nodes()[0].clone(); | ||
| 1673 | assert_eq!(editor.definitions[&node.style.unwrap()], heading); | ||
| 1674 | let spans = node.text().unwrap().text.spans().to_vec(); | ||
| 1675 | assert_eq!(spans.len(), 1); | ||
| 1676 | assert_eq!( | ||
| 1677 | spans[0].format, | ||
| 1678 | Format { | ||
| 1679 | language: Some(0x40c), | ||
| 1680 | ..heading.format.clone() | ||
| 1681 | } | ||
| 1682 | ); | ||
| 1683 | assert_eq!(editor.format_state().unwrap().style.as_deref(), Some("h2")); | ||
| 1684 | let length = node.text().unwrap().text.text().len() as u32; | ||
| 1685 | editor.select([at(0, length); 2].into()).unwrap(); | ||
| 1686 | editor.enter(&mut engine, false).unwrap(); | ||
| 1687 | let next = editor.active_outline().document.nodes()[1].style; | ||
| 1688 | assert_eq!(editor.definitions[&next.unwrap()], editor.styles["p"]); | ||
| 1689 | // Enter after Normal keeps Normal. | ||
| 1690 | editor.insert(&mut engine, "body").unwrap(); | ||
| 1691 | editor.enter(&mut engine, false).unwrap(); | ||
| 1692 | assert_eq!(editor.active_outline().document.nodes()[2].style, next); | ||
| 1693 | editor.undo(&mut engine).unwrap(); | ||
| 1694 | editor.undo(&mut engine).unwrap(); | ||
| 1695 | editor.undo(&mut engine).unwrap(); | ||
| 1696 | editor.undo(&mut engine).unwrap(); | ||
| 1697 | assert_eq!(editor.active_outline().document.nodes()[0].style, None); | ||
| 1698 | } | ||
| 1699 | |||
| 1347 | #[test] | 1700 | #[test] |
| 1348 | fn clearing_and_removing_attributes_return_to_the_paragraph_style() { | 1701 | fn clearing_and_removing_attributes_return_to_the_paragraph_style() { |
| 1349 | let mut engine = TextEngine::default(); | 1702 | let mut engine = TextEngine::default(); |
| ... | @@ -1377,6 +1730,7 @@ mod tests { | ... | @@ -1377,6 +1730,7 @@ mod tests { |
| 1377 | Definition { | 1730 | Definition { |
| 1378 | kind: Kind::Style { | 1731 | kind: Kind::Style { |
| 1379 | name: Some("h1".into()), | 1732 | name: Some("h1".into()), |
| 1733 | next: None, | ||
| 1380 | }, | 1734 | }, |
| 1381 | format: heading.clone(), | 1735 | format: heading.clone(), |
| 1382 | }, | 1736 | }, |
| ... | @@ -2199,6 +2553,7 @@ mod tests { | ... | @@ -2199,6 +2553,7 @@ mod tests { |
| 2199 | (NoteTag::defaults()[0].clone(), 0), | 2553 | (NoteTag::defaults()[0].clone(), 0), |
| 2200 | (NoteTag::defaults()[2].clone(), 2) | 2554 | (NoteTag::defaults()[2].clone(), 2) |
| 2201 | ], | 2555 | ], |
| 2556 | style: state.style.clone(), | ||
| 2202 | } | 2557 | } |
| 2203 | ); | 2558 | ); |
| 2204 | // Format Painter, a gallery tag and an inserted table write as well. | 2559 | // Format Painter, a gallery tag and an inserted table write as well. |
crates/canvas/src/editor/ops.rs+23| ... | @@ -46,6 +46,7 @@ fn reaches(op: &PageOp, outline: ExGuid, blank: &PageParagraph) -> bool { | ... | @@ -46,6 +46,7 @@ fn reaches(op: &PageOp, outline: ExGuid, blank: &PageParagraph) -> bool { |
| 46 | | PageOp::Outline { object: id, .. } | 46 | | PageOp::Outline { object: id, .. } |
| 47 | | PageOp::Paragraph { paragraph: id, .. } | 47 | | PageOp::Paragraph { paragraph: id, .. } |
| 48 | | PageOp::Style { paragraph: id, .. } | 48 | | PageOp::Style { paragraph: id, .. } |
| 49 | | PageOp::Unstyle { paragraph: id } | ||
| 49 | | PageOp::Media { paragraph: id, .. } | 50 | | PageOp::Media { paragraph: id, .. } |
| 50 | | PageOp::List { paragraph: id, .. } | 51 | | PageOp::List { paragraph: id, .. } |
| 51 | | PageOp::Tags { target: id, .. } => own(id), | 52 | | PageOp::Tags { target: id, .. } => own(id), |
| ... | @@ -58,6 +59,8 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + | ... | @@ -58,6 +59,8 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + |
| 58 | let content_tags = match &node.content { | 59 | let content_tags = match &node.content { |
| 59 | ParagraphContent::Text(text) => text.tags.as_slice(), | 60 | ParagraphContent::Text(text) => text.tags.as_slice(), |
| 60 | ParagraphContent::Table(table) => table.tags.as_slice(), | 61 | ParagraphContent::Table(table) => table.tags.as_slice(), |
| 62 | ParagraphContent::Image(image) => image.tags.as_slice(), | ||
| 63 | ParagraphContent::Attachment(file) => file.tags.as_slice(), | ||
| 61 | _ => &[], | 64 | _ => &[], |
| 62 | }; | 65 | }; |
| 63 | node.lists.iter().copied().chain(node.style).chain( | 66 | node.lists.iter().copied().chain(node.style).chain( |
| ... | @@ -283,6 +286,26 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered { | ... | @@ -283,6 +286,26 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered { |
| 283 | } | 286 | } |
| 284 | 287 | ||
| 285 | /// A page picture's stored position, size and description. | 288 | /// A page picture's stored position, size and description. |
| 289 | /// The op giving `target` `tags`, with the definitions they name. | ||
| 290 | pub(super) fn tags( | ||
| 291 | target: ExGuid, | ||
| 292 | tags: Vec<onestore::document::Tag>, | ||
| 293 | definitions: &BTreeMap<ExGuid, onestore::page::Definition>, | ||
| 294 | ) -> Vec<PageOp> { | ||
| 295 | let definitions = tags | ||
| 296 | .iter() | ||
| 297 | .filter_map(|tag| tag.definition) | ||
| 298 | .collect::<BTreeSet<_>>() | ||
| 299 | .into_iter() | ||
| 300 | .filter_map(|id| Some((id, definitions.get(&id)?.clone()))) | ||
| 301 | .collect(); | ||
| 302 | vec![PageOp::Tags { | ||
| 303 | target, | ||
| 304 | tags, | ||
| 305 | definitions, | ||
| 306 | }] | ||
| 307 | } | ||
| 308 | |||
| 286 | pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> { | 309 | pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> { |
| 287 | vec![PageOp::Picture { | 310 | vec![PageOp::Picture { |
| 288 | picture: image.id, | 311 | picture: image.id, |
crates/canvas/src/editor/page.rs+21-15| ... | @@ -100,6 +100,22 @@ impl Content { | ... | @@ -100,6 +100,22 @@ impl Content { |
| 100 | Some((source, [x + x0, y + y0, x + x1, y + y1])) | 100 | Some((source, [x + x0, y + y0, x + x1, y + y1])) |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | /// A tagged picture or file: its identity, note tags and bounds in page points. | ||
| 104 | pub(crate) fn tagged( | ||
| 105 | &self, | ||
| 106 | ) -> Option<(onestore::ExGuid, &[onestore::document::Tag], [f32; 4])> { | ||
| 107 | let (id, tags, bounds) = match self { | ||
| 108 | Self::Image(image) => { | ||
| 109 | let [x, y] = [image.layout.x, image.layout.y].map(|v| v.unwrap_or(0.0)); | ||
| 110 | let [width, height] = crate::outline::image_size(image)?; | ||
| 111 | (image.id, &image.tags, [x, y, x + width, y + height]) | ||
| 112 | } | ||
| 113 | Self::File { source, .. } => (source.id, &source.tags, self.file()?.1), | ||
| 114 | _ => return None, | ||
| 115 | }; | ||
| 116 | (!tags.is_empty()).then_some((id, tags.as_slice(), bounds)) | ||
| 117 | } | ||
| 118 | |||
| 103 | pub(super) fn layout_mut( | 119 | pub(super) fn layout_mut( |
| 104 | &mut self, | 120 | &mut self, |
| 105 | ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> { | 121 | ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> { |
| ... | @@ -354,27 +370,17 @@ pub(crate) fn build( | ... | @@ -354,27 +370,17 @@ pub(crate) fn build( |
| 354 | } | 370 | } |
| 355 | } | 371 | } |
| 356 | PageObject::Image(source) => { | 372 | PageObject::Image(source) => { |
| 357 | if source.bytes.is_none() | 373 | // A picture without a layout size shows at its own, as COM inserts them. |
| 358 | || source.layout.max_width.is_none() | 374 | let Some(size) = |
| 359 | || source.layout.max_height.is_none() | 375 | crate::outline::image_size(source).filter(|_| source.bytes.is_some()) |
| 360 | { | 376 | else { |
| 361 | objects.push(Content::unavailable(object, engine)?); | 377 | objects.push(Content::unavailable(object, engine)?); |
| 362 | continue; | 378 | continue; |
| 363 | } | 379 | }; |
| 364 | let origin = [ | 380 | let origin = [ |
| 365 | source.layout.x.unwrap_or(0.0), | 381 | source.layout.x.unwrap_or(0.0), |
| 366 | source.layout.y.unwrap_or(0.0), | 382 | source.layout.y.unwrap_or(0.0), |
| 367 | ]; | 383 | ]; |
| 368 | let size = [ | ||
| 369 | source | ||
| 370 | .layout | ||
| 371 | .max_width | ||
| 372 | .ok_or(EditorError::InvalidGeometry)?, | ||
| 373 | source | ||
| 374 | .layout | ||
| 375 | .max_height | ||
| 376 | .ok_or(EditorError::InvalidGeometry)?, | ||
| 377 | ]; | ||
| 378 | if origin.iter().any(|v| !v.is_finite()) | 384 | if origin.iter().any(|v| !v.is_finite()) |
| 379 | || size.iter().any(|v| !v.is_finite() || *v <= 0.0) | 385 | || size.iter().any(|v| !v.is_finite() || *v <= 0.0) |
| 380 | || !(origin[0] + size[0]).is_finite() | 386 | || !(origin[0] + size[0]).is_finite() |
crates/canvas/src/editor/recording.rs+2-1| ... | @@ -22,6 +22,7 @@ fn cite() -> Definition { | ... | @@ -22,6 +22,7 @@ fn cite() -> Definition { |
| 22 | Definition { | 22 | Definition { |
| 23 | kind: Kind::Style { | 23 | kind: Kind::Style { |
| 24 | name: Some("cite".into()), | 24 | name: Some("cite".into()), |
| 25 | next: None, | ||
| 25 | }, | 26 | }, |
| 26 | format: Format { | 27 | format: Format { |
| 27 | bold: Some(false), | 28 | bold: Some(false), |
| ... | @@ -107,7 +108,7 @@ impl CanvasEditor { | ... | @@ -107,7 +108,7 @@ impl CanvasEditor { |
| 107 | vec![Paragraph::new(String::new(), format); parts], | 108 | vec![Paragraph::new(String::new(), format); parts], |
| 108 | )?; | 109 | )?; |
| 109 | let style = match self.definitions.iter().find(|(_, definition)| { | 110 | let style = match self.definitions.iter().find(|(_, definition)| { |
| 110 | matches!(&definition.kind, Kind::Style { name: Some(name) } if name == "cite") | 111 | matches!(&definition.kind, Kind::Style { name: Some(name), .. } if name == "cite") |
| 111 | }) { | 112 | }) { |
| 112 | Some((style, _)) => *style, | 113 | Some((style, _)) => *style, |
| 113 | None => { | 114 | None => { |
crates/canvas/src/editor/table.rs+522-5| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | use super::*; | 1 | use super::*; |
| 2 | use onestore::page::text::new_id; | 2 | use onestore::page::text::new_id; |
| 3 | use onestore::page::{ParagraphContent, TableCell, TableColumn, TableRow}; | 3 | use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow}; |
| 4 | 4 | ||
| 5 | struct CellLocation<'a> { | 5 | struct CellLocation<'a> { |
| 6 | container: Option<ExGuid>, | 6 | container: Option<ExGuid>, |
| ... | @@ -35,6 +35,18 @@ fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> { | ... | @@ -35,6 +35,18 @@ fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> { |
| 35 | None | 35 | None |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | /// Table `id`'s paragraph, its container and its index there. | ||
| 39 | fn table_node( | ||
| 40 | document: &TextDocument, | ||
| 41 | id: ExGuid, | ||
| 42 | ) -> Result<(Option<ExGuid>, usize, &PageParagraph), EditError> { | ||
| 43 | descendants(document.nodes(), None) | ||
| 44 | .find(|(_, _, node)| { | ||
| 45 | matches!(&node.content, ParagraphContent::Table(table) if table.id == id) | ||
| 46 | }) | ||
| 47 | .ok_or(EditError::InvalidRange) | ||
| 48 | } | ||
| 49 | |||
| 38 | /// A new cell like `source`, one empty paragraph in its first text's format; a cell holding | 50 | /// A new cell like `source`, one empty paragraph in its first text's format; a cell holding |
| 39 | /// no text, only pictures or files, gives a plain one. | 51 | /// no text, only pictures or files, gives a plain one. |
| 40 | fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> { | 52 | fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> { |
| ... | @@ -85,7 +97,270 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos | ... | @@ -85,7 +97,270 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos |
| 85 | }) | 97 | }) |
| 86 | } | 98 | } |
| 87 | 99 | ||
| 100 | /// OneNote 2010's width for a new column, and the narrowest it fits or drags one to. | ||
| 101 | const COLUMN_WIDTH: f32 = 37.11; | ||
| 102 | /// What OneNote 2010 fits an unlocked column to beyond its widest line (lab, 2026-09-30). | ||
| 103 | const COLUMN_ROOM: f32 = 4.347; | ||
| 104 | |||
| 105 | /// `width` as it reads back from the file, which stores it in half inches. | ||
| 106 | fn stored(width: f32) -> f32 { | ||
| 107 | width / 36.0 * 36.0 | ||
| 108 | } | ||
| 109 | |||
| 110 | /// `table`'s widths with its unlocked columns in `only`, or all, fit to their widest cell | ||
| 111 | /// as `natural` measures it, the table no wider than `room` unless its narrowest columns are. | ||
| 112 | fn fitted( | ||
| 113 | table: &Table, | ||
| 114 | only: Option<usize>, | ||
| 115 | room: f32, | ||
| 116 | mut natural: impl FnMut(&TableCell) -> Result<f32, LayoutError>, | ||
| 117 | ) -> Result<Vec<f32>, LayoutError> { | ||
| 118 | let mut widths = table | ||
| 119 | .columns | ||
| 120 | .iter() | ||
| 121 | .map(|column| column.width) | ||
| 122 | .collect::<Vec<_>>(); | ||
| 123 | for (index, column) in table.columns.iter().enumerate() { | ||
| 124 | if column.locked || only.is_some_and(|only| only != index) { | ||
| 125 | continue; | ||
| 126 | } | ||
| 127 | let mut widest = 0.0_f32; | ||
| 128 | for row in &table.rows { | ||
| 129 | widest = widest.max(natural( | ||
| 130 | row.cells.get(index).ok_or(LayoutError::InvalidWidth)?, | ||
| 131 | )?); | ||
| 132 | } | ||
| 133 | // Columns sit 4.98 pt apart, and the table ends 3.15 pt past the last one. | ||
| 134 | let others = widths.iter().map(|width| width + 4.98).sum::<f32>() - widths[index] - 1.83; | ||
| 135 | widths[index] = stored((widest + COLUMN_ROOM).min(room - others).max(COLUMN_WIDTH)); | ||
| 136 | } | ||
| 137 | Ok(widths) | ||
| 138 | } | ||
| 139 | |||
| 140 | /// Fits the tables in `nodes`, innermost first, placed by `indents` in an outline `wrap` wide. | ||
| 141 | fn fit_tables( | ||
| 142 | nodes: &mut [PageParagraph], | ||
| 143 | indents: &[f32], | ||
| 144 | wrap: f32, | ||
| 145 | natural: &mut impl FnMut(&TableCell) -> Result<f32, LayoutError>, | ||
| 146 | ) -> Result<(), LayoutError> { | ||
| 147 | for node in nodes { | ||
| 148 | let level = node.level; | ||
| 149 | if let ParagraphContent::Table(table) = &mut node.content { | ||
| 150 | for TableCell { | ||
| 151 | paragraphs, | ||
| 152 | indents, | ||
| 153 | .. | ||
| 154 | } in table.rows.iter_mut().flat_map(|row| &mut row.cells) | ||
| 155 | { | ||
| 156 | fit_tables(paragraphs, indents, wrap, natural)?; | ||
| 157 | } | ||
| 158 | let room = wrap - crate::outline::indentation(level, indents, wrap)?; | ||
| 159 | let widths = fitted(table, None, room, &mut *natural)?; | ||
| 160 | for (column, width) in table.columns.iter_mut().zip(widths) { | ||
| 161 | column.width = width; | ||
| 162 | } | ||
| 163 | } | ||
| 164 | } | ||
| 165 | Ok(()) | ||
| 166 | } | ||
| 167 | |||
| 88 | impl CanvasEditor { | 168 | impl CanvasEditor { |
| 169 | /// Fits the unlocked columns `edit` writes in to their content, as OneNote 2010 widens | ||
| 170 | /// and narrows them while typing: the tables it adds or rewrites, and the column of each | ||
| 171 | /// table around the cell it edits. A table stops at the outline's width and wraps there. | ||
| 172 | pub(super) fn fit_columns( | ||
| 173 | &self, | ||
| 174 | engine: &mut TextEngine, | ||
| 175 | edit: &mut DocumentEdit, | ||
| 176 | ) -> Result<(), EditorError> { | ||
| 177 | let outline = self.active_outline(); | ||
| 178 | if outline.title { | ||
| 179 | return Ok(()); | ||
| 180 | } | ||
| 181 | let wrap = outline.wrap_width(); | ||
| 182 | let mut natural = |cell: &TableCell, edit: Option<&DocumentEdit>| { | ||
| 183 | let flow = OutlineLayout::flow( | ||
| 184 | crate::document::edited_nodes(&cell.paragraphs, Some(cell.id), edit), | ||
| 185 | &cell.indents, | ||
| 186 | f32::from(u16::MAX), | ||
| 187 | false, | ||
| 188 | 1, | ||
| 189 | edit, | ||
| 190 | &mut |node, previous, width, indents| { | ||
| 191 | ParagraphLayout::shape( | ||
| 192 | engine, | ||
| 193 | node, | ||
| 194 | previous, | ||
| 195 | width, | ||
| 196 | indents, | ||
| 197 | &self.definitions, | ||
| 198 | ) | ||
| 199 | }, | ||
| 200 | )?; | ||
| 201 | Ok::<_, LayoutError>(flow.content_width()) | ||
| 202 | }; | ||
| 203 | let indents = |container: Option<ExGuid>| match container { | ||
| 204 | Some(cell) => { | ||
| 205 | let location = | ||
| 206 | locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?; | ||
| 207 | let ParagraphContent::Table(table) = &location.node.content else { | ||
| 208 | unreachable!() | ||
| 209 | }; | ||
| 210 | Ok::<_, EditError>(&table.rows[location.row].cells[location.column].indents) | ||
| 211 | } | ||
| 212 | None => Ok(&outline.indents), | ||
| 213 | }; | ||
| 214 | fit_tables( | ||
| 215 | &mut edit.replacement, | ||
| 216 | indents(edit.container)?, | ||
| 217 | wrap, | ||
| 218 | &mut |cell| natural(cell, None), | ||
| 219 | )?; | ||
| 220 | let mut container = edit.container; | ||
| 221 | while let Some(cell) = container { | ||
| 222 | let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?; | ||
| 223 | let ParagraphContent::Table(table) = &location.node.content else { | ||
| 224 | unreachable!() | ||
| 225 | }; | ||
| 226 | if !edit.columns.contains_key(&table.id) { | ||
| 227 | let room = wrap | ||
| 228 | - crate::outline::indentation( | ||
| 229 | location.node.level, | ||
| 230 | indents(location.container)?, | ||
| 231 | wrap, | ||
| 232 | )?; | ||
| 233 | let widths = fitted(table, Some(location.column), room, |cell| { | ||
| 234 | natural(cell, Some(edit)) | ||
| 235 | })?; | ||
| 236 | if widths | ||
| 237 | .iter() | ||
| 238 | .zip(&table.columns) | ||
| 239 | .any(|(width, column)| *width != column.width) | ||
| 240 | { | ||
| 241 | edit.columns.insert(table.id, widths); | ||
| 242 | } | ||
| 243 | } | ||
| 244 | container = location.container; | ||
| 245 | } | ||
| 246 | Ok(()) | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | impl TextOutline { | ||
| 251 | /// The table column whose right border lies within `reach` of outline-local `point`, as | ||
| 252 | /// `(table, column, width)`. | ||
| 253 | pub fn column_border(&self, point: [f32; 2], reach: f32) -> Option<(ExGuid, usize, f32)> { | ||
| 254 | let columns = descendants(self.document.nodes(), None) | ||
| 255 | .filter_map(|(_, _, node)| match &node.content { | ||
| 256 | ParagraphContent::Table(table) => Some((table.id, table.columns.len())), | ||
| 257 | _ => None, | ||
| 258 | }) | ||
| 259 | .collect::<BTreeMap<_, _>>(); | ||
| 260 | // Nested tables follow the tables holding them. | ||
| 261 | self.shaped.tables.iter().rev().find_map(|table| { | ||
| 262 | let (first, last) = (table.cells.first()?, table.cells.last()?); | ||
| 263 | if !(first.rect[1]..=last.rect[3]).contains(&point[1]) { | ||
| 264 | return None; | ||
| 265 | } | ||
| 266 | let row = table.cells.get(..*columns.get(&table.id)?)?; | ||
| 267 | row.iter().enumerate().find_map(|(column, cell)| { | ||
| 268 | // A cell's box reaches 3.6 pt before its column and 1.38 pt past it. | ||
| 269 | ((point[0] - cell.rect[2]).abs() <= reach) | ||
| 270 | .then(|| (table.id, column, cell.rect[2] - cell.rect[0] - 4.98)) | ||
| 271 | }) | ||
| 272 | }) | ||
| 273 | } | ||
| 274 | } | ||
| 275 | |||
| 276 | impl CanvasEditor { | ||
| 277 | /// The active outline with column `column` of `table` `width` wide, as a border drag | ||
| 278 | /// shows it before release. | ||
| 279 | pub fn preview_column( | ||
| 280 | &self, | ||
| 281 | engine: &mut TextEngine, | ||
| 282 | table: ExGuid, | ||
| 283 | column: usize, | ||
| 284 | width: f32, | ||
| 285 | ) -> Result<TextOutline, EditorError> { | ||
| 286 | let outline = self.active_outline(); | ||
| 287 | let (_, _, node) = table_node(&outline.document, table)?; | ||
| 288 | let ParagraphContent::Table(source) = &node.content else { | ||
| 289 | unreachable!() | ||
| 290 | }; | ||
| 291 | let mut widths = source | ||
| 292 | .columns | ||
| 293 | .iter() | ||
| 294 | .map(|column| column.width) | ||
| 295 | .collect::<Vec<_>>(); | ||
| 296 | *widths.get_mut(column).ok_or(EditError::InvalidRange)? = width.max(COLUMN_WIDTH); | ||
| 297 | let edit = DocumentEdit { | ||
| 298 | container: None, | ||
| 299 | range: 0..0, | ||
| 300 | replacement: Vec::new(), | ||
| 301 | columns: [(table, widths)].into(), | ||
| 302 | }; | ||
| 303 | let shaped = OutlineLayout::flow( | ||
| 304 | outline.document.nodes().iter(), | ||
| 305 | &outline.indents, | ||
| 306 | outline.wrap_width(), | ||
| 307 | outline.layout.width_set_by_user == Some(true), | ||
| 308 | 0, | ||
| 309 | Some(&edit), | ||
| 310 | &mut |node, previous, width, indents| { | ||
| 311 | ParagraphLayout::shape(engine, node, previous, width, indents, &self.definitions) | ||
| 312 | }, | ||
| 313 | )?; | ||
| 314 | let mut preview = outline.clone(); | ||
| 315 | preview.document.apply(edit)?; | ||
| 316 | preview.shaped = shaped; | ||
| 317 | Ok(preview) | ||
| 318 | } | ||
| 319 | |||
| 320 | /// Drags a border to make column `column` of `table` `width` wide, as OneNote 2010 does: | ||
| 321 | /// no narrower than a new column, locked against fitting, the columns after it moving | ||
| 322 | /// with it. One edit and one undo step. | ||
| 323 | pub fn resize_column( | ||
| 324 | &mut self, | ||
| 325 | engine: &mut TextEngine, | ||
| 326 | table: ExGuid, | ||
| 327 | column: usize, | ||
| 328 | width: f32, | ||
| 329 | ) -> Result<(), EditorError> { | ||
| 330 | if !width.is_finite() { | ||
| 331 | return Err(LayoutError::InvalidWidth.into()); | ||
| 332 | } | ||
| 333 | let outline = self.active_outline(); | ||
| 334 | let (container, index, node) = table_node(&outline.document, table)?; | ||
| 335 | let mut node = node.clone(); | ||
| 336 | let ParagraphContent::Table(source) = &mut node.content else { | ||
| 337 | unreachable!() | ||
| 338 | }; | ||
| 339 | let resized = TableColumn { | ||
| 340 | width: stored(width.max(COLUMN_WIDTH)), | ||
| 341 | locked: true, | ||
| 342 | }; | ||
| 343 | let slot = source | ||
| 344 | .columns | ||
| 345 | .get_mut(column) | ||
| 346 | .ok_or(EditError::InvalidRange)?; | ||
| 347 | if *slot == resized { | ||
| 348 | return Ok(()); | ||
| 349 | } | ||
| 350 | *slot = resized; | ||
| 351 | let selection = outline.selection; | ||
| 352 | self.finish_composition(); | ||
| 353 | self.commit( | ||
| 354 | engine, | ||
| 355 | DocumentEdit { | ||
| 356 | columns: BTreeMap::new(), | ||
| 357 | container, | ||
| 358 | range: index..index + 1, | ||
| 359 | replacement: vec![node], | ||
| 360 | }, | ||
| 361 | selection, | ||
| 362 | ) | ||
| 363 | } | ||
| 89 | /// Insert, Table: an empty table of `rows` by `columns` at the caret with the caret in | 364 | /// Insert, Table: an empty table of `rows` by `columns` at the caret with the caret in |
| 90 | /// its first cell, as OneNote 2010 inserts one: in place of an empty paragraph, before | 365 | /// its first cell, as OneNote 2010 inserts one: in place of an empty paragraph, before |
| 91 | /// or after the caret's paragraph at its start or end, and between its halves otherwise. | 366 | /// or after the caret's paragraph at its start or end, and between its halves otherwise. |
| ... | @@ -137,7 +412,7 @@ impl CanvasEditor { | ... | @@ -137,7 +412,7 @@ impl CanvasEditor { |
| 137 | id: new_id()?, | 412 | id: new_id()?, |
| 138 | columns: vec![ | 413 | columns: vec![ |
| 139 | TableColumn { | 414 | TableColumn { |
| 140 | width: 37.11, | 415 | width: COLUMN_WIDTH, |
| 141 | locked: false | 416 | locked: false |
| 142 | }; | 417 | }; |
| 143 | columns | 418 | columns |
| ... | @@ -260,7 +535,7 @@ impl CanvasEditor { | ... | @@ -260,7 +535,7 @@ impl CanvasEditor { |
| 260 | id: new_id()?, | 535 | id: new_id()?, |
| 261 | columns: vec![ | 536 | columns: vec![ |
| 262 | TableColumn { | 537 | TableColumn { |
| 263 | width: 37.11, | 538 | width: COLUMN_WIDTH, |
| 264 | locked: false | 539 | locked: false |
| 265 | }; | 540 | }; |
| 266 | 2 | 541 | 2 |
| ... | @@ -341,7 +616,7 @@ impl CanvasEditor { | ... | @@ -341,7 +616,7 @@ impl CanvasEditor { |
| 341 | }; | 616 | }; |
| 342 | table.rows[0].cells.push(target); | 617 | table.rows[0].cells.push(target); |
| 343 | table.columns.push(TableColumn { | 618 | table.columns.push(TableColumn { |
| 344 | width: 37.11, | 619 | width: COLUMN_WIDTH, |
| 345 | locked: false, | 620 | locked: false, |
| 346 | }); | 621 | }); |
| 347 | TextPosition { | 622 | TextPosition { |
| ... | @@ -713,7 +988,9 @@ mod tests { | ... | @@ -713,7 +988,9 @@ mod tests { |
| 713 | }; | 988 | }; |
| 714 | assert_eq!(after.rows.len(), 3); | 989 | assert_eq!(after.rows.len(), 3); |
| 715 | assert_eq!(&after.rows[..2], &before.rows); | 990 | assert_eq!(&after.rows[..2], &before.rows); |
| 716 | assert_eq!(after.columns, before.columns); | 991 | // The first column widens to the table placed in it unfitted. |
| 992 | assert!(after.columns[0].width > before.columns[0].width + 40.0); | ||
| 993 | assert_eq!(after.columns[1], before.columns[1]); | ||
| 717 | for cell in &after.rows[2].cells { | 994 | for cell in &after.rows[2].cells { |
| 718 | assert_eq!(cell.paragraphs.len(), 1); | 995 | assert_eq!(cell.paragraphs.len(), 1); |
| 719 | assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty()); | 996 | assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty()); |
| ... | @@ -912,4 +1189,244 @@ mod tests { | ... | @@ -912,4 +1189,244 @@ mod tests { |
| 912 | ); | 1189 | ); |
| 913 | assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0); | 1190 | assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0); |
| 914 | } | 1191 | } |
| 1192 | |||
| 1193 | fn calibri(engine: &mut TextEngine) -> CanvasEditor { | ||
| 1194 | let format = Format { | ||
| 1195 | font: Some("Calibri".into()), | ||
| 1196 | font_size: Some(11.0), | ||
| 1197 | ..Format::default() | ||
| 1198 | }; | ||
| 1199 | CanvasEditor::new( | ||
| 1200 | engine, | ||
| 1201 | TextDocument::new(vec![Paragraph::new(String::new(), format)]).unwrap(), | ||
| 1202 | 468.0, | ||
| 1203 | ) | ||
| 1204 | .unwrap() | ||
| 1205 | } | ||
| 1206 | |||
| 1207 | fn table(editor: &CanvasEditor) -> &onestore::page::Table { | ||
| 1208 | let ParagraphContent::Table(table) = &editor.active_outline().document.nodes()[0].content | ||
| 1209 | else { | ||
| 1210 | panic!() | ||
| 1211 | }; | ||
| 1212 | table | ||
| 1213 | } | ||
| 1214 | |||
| 1215 | fn widths(editor: &CanvasEditor) -> Vec<f32> { | ||
| 1216 | table(editor) | ||
| 1217 | .columns | ||
| 1218 | .iter() | ||
| 1219 | .map(|column| column.width) | ||
| 1220 | .collect() | ||
| 1221 | } | ||
| 1222 | |||
| 1223 | fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) { | ||
| 1224 | for (index, row) in rows.iter().enumerate() { | ||
| 1225 | if index > 0 { | ||
| 1226 | editor.enter(engine, false).unwrap(); | ||
| 1227 | } | ||
| 1228 | for (column, text) in row.iter().enumerate() { | ||
| 1229 | if column > 0 { | ||
| 1230 | editor.tab(engine, false).unwrap(); | ||
| 1231 | } | ||
| 1232 | editor.insert(engine, text).unwrap(); | ||
| 1233 | } | ||
| 1234 | } | ||
| 1235 | } | ||
| 1236 | |||
| 1237 | /// OneNote 2010's widths for the same table typed the same way (lab, 2026-09-30). | ||
| 1238 | #[test] | ||
| 1239 | fn typing_fits_unlocked_columns_to_their_widest_line() { | ||
| 1240 | let mut engine = TextEngine::default(); | ||
| 1241 | let mut editor = calibri(&mut engine); | ||
| 1242 | type_rows( | ||
| 1243 | &mut editor, | ||
| 1244 | &mut engine, | ||
| 1245 | &[ | ||
| 1246 | &["Fruit", "Colour", "Notes"], | ||
| 1247 | &["Apple", "Red", "Crisp and sweet, good for pies"], | ||
| 1248 | &["Watermelon", "Green", "Summer"], | ||
| 1249 | ], | ||
| 1250 | ); | ||
| 1251 | for (width, onenote) in widths(&editor) | ||
| 1252 | .into_iter() | ||
| 1253 | .zip([60.75945, 37.11, 139.08636]) | ||
| 1254 | { | ||
| 1255 | assert!((width - onenote).abs() < 0.01, "{width} against {onenote}"); | ||
| 1256 | } | ||
| 1257 | // Deleting the widest line narrows the column to the next widest, here the minimum. | ||
| 1258 | let end = TextPosition { | ||
| 1259 | paragraph: 6, | ||
| 1260 | offset: 10, | ||
| 1261 | }; | ||
| 1262 | editor | ||
| 1263 | .select([TextPosition { offset: 0, ..end }, end].into()) | ||
| 1264 | .unwrap(); | ||
| 1265 | editor.delete(&mut engine, true).unwrap(); | ||
| 1266 | assert_eq!(widths(&editor)[0], COLUMN_WIDTH); | ||
| 1267 | assert!(editor.undo(&mut engine).unwrap()); | ||
| 1268 | assert!((widths(&editor)[0] - 60.75945).abs() < 0.01); | ||
| 1269 | } | ||
| 1270 | |||
| 1271 | #[test] | ||
| 1272 | fn a_widening_keystroke_stores_its_width_with_its_text() { | ||
| 1273 | let mut engine = TextEngine::default(); | ||
| 1274 | let mut editor = calibri(&mut engine); | ||
| 1275 | type_rows(&mut editor, &mut engine, &[&["A", ""]]); | ||
| 1276 | editor.take_ops().unwrap(); | ||
| 1277 | let before = widths(&editor); | ||
| 1278 | editor.insert(&mut engine, "Watermelon").unwrap(); | ||
| 1279 | let ops = editor.take_ops().unwrap(); | ||
| 1280 | let columns = ops | ||
| 1281 | .iter() | ||
| 1282 | .filter_map(|op| match op { | ||
| 1283 | PageOp::Table { | ||
| 1284 | edit: onestore::op::TableEdit::Columns(columns), | ||
| 1285 | .. | ||
| 1286 | } => Some( | ||
| 1287 | columns | ||
| 1288 | .iter() | ||
| 1289 | .map(|column| column.width) | ||
| 1290 | .collect::<Vec<_>>(), | ||
| 1291 | ), | ||
| 1292 | _ => None, | ||
| 1293 | }) | ||
| 1294 | .collect::<Vec<_>>(); | ||
| 1295 | assert_eq!(columns, [widths(&editor)]); | ||
| 1296 | assert!(ops.len() > 1); | ||
| 1297 | assert!(widths(&editor)[1] > before[1]); | ||
| 1298 | // A keystroke the column already holds stores no width. | ||
| 1299 | editor.delete(&mut engine, true).unwrap(); | ||
| 1300 | editor.insert(&mut engine, "n").unwrap(); | ||
| 1301 | editor.take_ops().unwrap(); | ||
| 1302 | editor | ||
| 1303 | .select( | ||
| 1304 | [TextPosition { | ||
| 1305 | paragraph: 0, | ||
| 1306 | offset: 1, | ||
| 1307 | }; 2] | ||
| 1308 | .into(), | ||
| 1309 | ) | ||
| 1310 | .unwrap(); | ||
| 1311 | editor.insert(&mut engine, "!").unwrap(); | ||
| 1312 | assert!(!editor.take_ops().unwrap().iter().any(|op| matches!( | ||
| 1313 | op, | ||
| 1314 | PageOp::Table { | ||
| 1315 | edit: onestore::op::TableEdit::Columns(_), | ||
| 1316 | .. | ||
| 1317 | } | ||
| 1318 | ))); | ||
| 1319 | } | ||
| 1320 | |||
| 1321 | /// OneNote 2010 stops a table at the outline's width and wraps the cell from there. | ||
| 1322 | #[test] | ||
| 1323 | fn a_table_stops_widening_at_the_outline_width() { | ||
| 1324 | let mut engine = TextEngine::default(); | ||
| 1325 | let mut editor = calibri(&mut engine); | ||
| 1326 | type_rows(&mut editor, &mut engine, &[&["A", ""]]); | ||
| 1327 | for _ in 0..40 { | ||
| 1328 | editor.insert(&mut engine, "word ").unwrap(); | ||
| 1329 | } | ||
| 1330 | let widths = widths(&editor); | ||
| 1331 | let table = widths.iter().map(|width| width + 4.98).sum::<f32>() - 1.83; | ||
| 1332 | assert!((table - 468.0).abs() < 0.01, "{table}"); | ||
| 1333 | assert!( | ||
| 1334 | editor.active_outline().shaped.paragraphs[1] | ||
| 1335 | .text | ||
| 1336 | .lines() | ||
| 1337 | .count() | ||
| 1338 | > 1 | ||
| 1339 | ); | ||
| 1340 | } | ||
| 1341 | |||
| 1342 | #[test] | ||
| 1343 | fn a_dragged_column_locks_moves_the_columns_after_it_and_undoes_in_one_step() { | ||
| 1344 | let mut engine = TextEngine::default(); | ||
| 1345 | let mut editor = calibri(&mut engine); | ||
| 1346 | type_rows(&mut editor, &mut engine, &[&["B", "Word"]]); | ||
| 1347 | let id = table(&editor).id; | ||
| 1348 | let fitted = widths(&editor); | ||
| 1349 | let cells = |editor: &CanvasEditor| { | ||
| 1350 | editor.active_outline().shaped.tables[0] | ||
| 1351 | .cells | ||
| 1352 | .iter() | ||
| 1353 | .map(|cell| cell.rect) | ||
| 1354 | .collect::<Vec<_>>() | ||
| 1355 | }; | ||
| 1356 | let before = cells(&editor); | ||
| 1357 | // The border of the first column, from its cell's right edge. | ||
| 1358 | let border = [before[0][2], (before[0][1] + before[0][3]) / 2.0]; | ||
| 1359 | assert_eq!( | ||
| 1360 | editor.active_outline().column_border(border, 2.0), | ||
| 1361 | Some((id, 0, fitted[0])) | ||
| 1362 | ); | ||
| 1363 | assert_eq!( | ||
| 1364 | editor | ||
| 1365 | .active_outline() | ||
| 1366 | .column_border([border[0] - 10.0, border[1]], 2.0), | ||
| 1367 | None | ||
| 1368 | ); | ||
| 1369 | let preview = editor.preview_column(&mut engine, id, 0, 88.86).unwrap(); | ||
| 1370 | assert_eq!(widths(&editor), fitted); | ||
| 1371 | editor.take_ops().unwrap(); | ||
| 1372 | let history = editor.undo.len(); | ||
| 1373 | editor.resize_column(&mut engine, id, 0, 88.86).unwrap(); | ||
| 1374 | assert_eq!(editor.undo.len(), history + 1); | ||
| 1375 | assert_eq!( | ||
| 1376 | table(&editor).columns[0], | ||
| 1377 | TableColumn { | ||
| 1378 | width: 88.86, | ||
| 1379 | locked: true | ||
| 1380 | } | ||
| 1381 | ); | ||
| 1382 | assert_eq!( | ||
| 1383 | table(&editor).columns[1], | ||
| 1384 | TableColumn { | ||
| 1385 | width: fitted[1], | ||
| 1386 | locked: false | ||
| 1387 | } | ||
| 1388 | ); | ||
| 1389 | assert_eq!(cells(&editor), { | ||
| 1390 | let ParagraphContent::Table(_) = &preview.document.nodes()[0].content else { | ||
| 1391 | panic!() | ||
| 1392 | }; | ||
| 1393 | preview.shaped.tables[0] | ||
| 1394 | .cells | ||
| 1395 | .iter() | ||
| 1396 | .map(|cell| cell.rect) | ||
| 1397 | .collect::<Vec<_>>() | ||
| 1398 | }); | ||
| 1399 | let moved = cells(&editor); | ||
| 1400 | assert!((moved[1][0] - before[1][0] - (88.86 - fitted[0])).abs() < 0.001); | ||
| 1401 | let ops = editor.take_ops().unwrap(); | ||
| 1402 | assert!( | ||
| 1403 | matches!( | ||
| 1404 | ops.as_slice(), | ||
| 1405 | [PageOp::Table { | ||
| 1406 | edit: onestore::op::TableEdit::Columns(_), | ||
| 1407 | .. | ||
| 1408 | }] | ||
| 1409 | ), | ||
| 1410 | "{ops:?}" | ||
| 1411 | ); | ||
| 1412 | // A locked column keeps its width while typing; dragging stops at a new column's width. | ||
| 1413 | editor | ||
| 1414 | .insert(&mut engine, " and a long line of text") | ||
| 1415 | .unwrap(); | ||
| 1416 | assert_eq!(table(&editor).columns[0].width, 88.86); | ||
| 1417 | editor.resize_column(&mut engine, id, 1, 5.0).unwrap(); | ||
| 1418 | assert_eq!( | ||
| 1419 | table(&editor).columns[1], | ||
| 1420 | TableColumn { | ||
| 1421 | width: COLUMN_WIDTH, | ||
| 1422 | locked: true | ||
| 1423 | } | ||
| 1424 | ); | ||
| 1425 | for _ in 0..3 { | ||
| 1426 | editor.undo(&mut engine).unwrap(); | ||
| 1427 | } | ||
| 1428 | assert_eq!(widths(&editor), fitted); | ||
| 1429 | assert!(table(&editor).columns.iter().all(|column| !column.locked)); | ||
| 1430 | assert_eq!(cells(&editor), before); | ||
| 1431 | } | ||
| 915 | } | 1432 | } |
crates/canvas/src/gpu/page.rs+98-24| ... | @@ -24,6 +24,8 @@ use std::{ | ... | @@ -24,6 +24,8 @@ use std::{ |
| 24 | /// Retained drawing data in the source page's coordinate system. | 24 | /// Retained drawing data in the source page's coordinate system. |
| 25 | pub struct PageScene { | 25 | pub struct PageScene { |
| 26 | reference: Option<Vec<Content>>, | 26 | reference: Option<Vec<Content>>, |
| 27 | /// The definitions a page read without an editor names, as its objects' tags do. | ||
| 28 | definitions: std::collections::BTreeMap<onestore::ExGuid, onestore::page::Definition>, | ||
| 27 | /// Pictures and file icons, by the identity of the object that shows them. | 29 | /// Pictures and file icons, by the identity of the object that shows them. |
| 28 | pictures: std::collections::BTreeMap<onestore::ExGuid, Picture>, | 30 | pictures: std::collections::BTreeMap<onestore::ExGuid, Picture>, |
| 29 | /// Background pictures from OneNote's page templates, painted from their recreations. | 31 | /// Background pictures from OneNote's page templates, painted from their recreations. |
| ... | @@ -80,6 +82,8 @@ pub enum SceneHit<T> { | ... | @@ -80,6 +82,8 @@ pub enum SceneHit<T> { |
| 80 | ReadOnly(usize), | 82 | ReadOnly(usize), |
| 81 | Image(onestore::ExGuid), | 83 | Image(onestore::ExGuid), |
| 82 | File(onestore::ExGuid), | 84 | File(onestore::ExGuid), |
| 85 | /// A check box tag on a picture or file on the page. | ||
| 86 | Check(onestore::ExGuid), | ||
| 83 | } | 87 | } |
| 84 | 88 | ||
| 85 | fn outline_origin( | 89 | fn outline_origin( |
| ... | @@ -116,11 +120,12 @@ pub(crate) fn append_ink( | ... | @@ -116,11 +120,12 @@ pub(crate) fn append_ink( |
| 116 | let size = stroke.width.max(stroke.height); | 120 | let size = stroke.width.max(stroke.height); |
| 117 | let segment = |from: [f32; 2], to: [f32; 2], [t0, t1]: [f32; 2]| { | 121 | let segment = |from: [f32; 2], to: [f32; 2], [t0, t1]: [f32; 2]| { |
| 118 | if !highlighter { | 122 | if !highlighter { |
| 119 | if round && t0 != t1 { | 123 | if t0 != t1 { |
| 120 | return Primitive::Taper { | 124 | return Primitive::Taper { |
| 121 | from, | 125 | from, |
| 122 | to, | 126 | to, |
| 123 | widths: [size * t0, size * t1], | 127 | widths: [size * t0, size * t1], |
| 128 | round, | ||
| 124 | color, | 129 | color, |
| 125 | }; | 130 | }; |
| 126 | } | 131 | } |
| ... | @@ -253,6 +258,7 @@ impl PageScene { | ... | @@ -253,6 +258,7 @@ impl PageScene { |
| 253 | pictures.mark_unavailable(&mut objects, engine)?; | 258 | pictures.mark_unavailable(&mut objects, engine)?; |
| 254 | Ok(Self { | 259 | Ok(Self { |
| 255 | reference: Some(objects), | 260 | reference: Some(objects), |
| 261 | definitions: std::mem::take(&mut page.definitions), | ||
| 256 | ..pictures | 262 | ..pictures |
| 257 | }) | 263 | }) |
| 258 | } | 264 | } |
| ... | @@ -398,6 +404,7 @@ impl PageScene { | ... | @@ -398,6 +404,7 @@ impl PageScene { |
| 398 | } | 404 | } |
| 399 | Ok(Self { | 405 | Ok(Self { |
| 400 | reference: None, | 406 | reference: None, |
| 407 | definitions: Default::default(), | ||
| 401 | pictures, | 408 | pictures, |
| 402 | backgrounds, | 409 | backgrounds, |
| 403 | }) | 410 | }) |
| ... | @@ -438,6 +445,17 @@ impl PageScene { | ... | @@ -438,6 +445,17 @@ impl PageScene { |
| 438 | } | 445 | } |
| 439 | } | 446 | } |
| 440 | 447 | ||
| 448 | /// Bytes of the rasters the scene keeps decoded, for bounding what a cache of scenes holds. | ||
| 449 | pub fn raster_bytes(&self) -> u64 { | ||
| 450 | let size = |image: &draw::RasterImage| { | ||
| 451 | let [width, height] = image.size(); | ||
| 452 | u64::from(width) * u64::from(height) * 4 | ||
| 453 | }; | ||
| 454 | let pictures = self.pictures.values().filter_map(Picture::image); | ||
| 455 | let art = self.backgrounds.values().flat_map(Background::images); | ||
| 456 | pictures.chain(art).map(size).sum() | ||
| 457 | } | ||
| 458 | |||
| 441 | /// The raster a picture or file icon shows, once decoded. | 459 | /// The raster a picture or file icon shows, once decoded. |
| 442 | pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> { | 460 | pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> { |
| 443 | self.pictures.get(&id)?.image() | 461 | self.pictures.get(&id)?.image() |
| ... | @@ -463,9 +481,7 @@ impl PageScene { | ... | @@ -463,9 +481,7 @@ impl PageScene { |
| 463 | source.layout.x.unwrap_or(0.0), | 481 | source.layout.x.unwrap_or(0.0), |
| 464 | source.layout.y.unwrap_or(0.0), | 482 | source.layout.y.unwrap_or(0.0), |
| 465 | ]; | 483 | ]; |
| 466 | if let (Some(width), Some(height)) = | 484 | if let Some([width, height]) = crate::outline::image_size(source) { |
| 467 | (source.layout.max_width, source.layout.max_height) | ||
| 468 | { | ||
| 469 | rects.push((source.id, [x, y, x + width, y + height])); | 485 | rects.push((source.id, [x, y, x + width, y + height])); |
| 470 | } | 486 | } |
| 471 | } | 487 | } |
| ... | @@ -581,6 +597,38 @@ impl PageScene { | ... | @@ -581,6 +597,38 @@ impl PageScene { |
| 581 | .ok_or(SceneError::MissingOutline) | 597 | .ok_or(SceneError::MissingOutline) |
| 582 | } | 598 | } |
| 583 | 599 | ||
| 600 | /// The note tags of the pictures and files on the page, each with the object it marks, | ||
| 601 | /// in page points; `moving` shows one at a previewed rectangle. | ||
| 602 | fn object_tags( | ||
| 603 | &self, | ||
| 604 | editor: Option<&CanvasEditor>, | ||
| 605 | moving: Option<(onestore::ExGuid, [f32; 4])>, | ||
| 606 | ) -> Vec<(onestore::ExGuid, crate::outline::ParagraphTag)> { | ||
| 607 | let definitions = match (&self.reference, editor) { | ||
| 608 | (None, Some(editor)) => &editor.definitions, | ||
| 609 | _ => &self.definitions, | ||
| 610 | }; | ||
| 611 | let Ok(objects) = self.objects(editor) else { | ||
| 612 | return Vec::new(); | ||
| 613 | }; | ||
| 614 | objects | ||
| 615 | .iter() | ||
| 616 | .filter_map(|object| { | ||
| 617 | let (id, tags, bounds) = object.tagged()?; | ||
| 618 | let bounds = match moving { | ||
| 619 | Some((moved, rect)) if moved == id => rect, | ||
| 620 | _ => bounds, | ||
| 621 | }; | ||
| 622 | Some( | ||
| 623 | crate::outline::object_tags(tags, definitions, bounds) | ||
| 624 | .into_iter() | ||
| 625 | .map(move |tag| (id, tag)), | ||
| 626 | ) | ||
| 627 | }) | ||
| 628 | .flatten() | ||
| 629 | .collect() | ||
| 630 | } | ||
| 631 | |||
| 584 | pub fn read_only<'a>( | 632 | pub fn read_only<'a>( |
| 585 | &'a self, | 633 | &'a self, |
| 586 | editor: Option<&'a CanvasEditor>, | 634 | editor: Option<&'a CanvasEditor>, |
| ... | @@ -650,15 +698,11 @@ impl PageScene { | ... | @@ -650,15 +698,11 @@ impl PageScene { |
| 650 | true, | 698 | true, |
| 651 | )) | 699 | )) |
| 652 | } | 700 | } |
| 653 | Content::Image(source) => Some(( | 701 | Content::Image(source) => { |
| 654 | [ | 702 | let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); |
| 655 | source.layout.x.unwrap_or(0.0), | 703 | let [width, height] = crate::outline::image_size(source)?; |
| 656 | source.layout.y.unwrap_or(0.0), | 704 | Some(([x, y, x + width, y + height], false)) |
| 657 | source.layout.x.unwrap_or(0.0) + source.layout.max_width?, | 705 | } |
| 658 | source.layout.y.unwrap_or(0.0) + source.layout.max_height?, | ||
| 659 | ], | ||
| 660 | false, | ||
| 661 | )), | ||
| 662 | Content::File { .. } => Some((object.file()?.1, true)), | 706 | Content::File { .. } => Some((object.file()?.1, true)), |
| 663 | Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)), | 707 | Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)), |
| 664 | Content::ReadOnly(object) => Some((object.rect(), true)), | 708 | Content::ReadOnly(object) => Some((object.rect(), true)), |
| ... | @@ -716,6 +760,14 @@ impl PageScene { | ... | @@ -716,6 +760,14 @@ impl PageScene { |
| 716 | editor: Option<&CanvasEditor>, | 760 | editor: Option<&CanvasEditor>, |
| 717 | mut outline: impl FnMut(onestore::ExGuid) -> Option<T>, | 761 | mut outline: impl FnMut(onestore::ExGuid) -> Option<T>, |
| 718 | ) -> Option<SceneHit<T>> { | 762 | ) -> Option<SceneHit<T>> { |
| 763 | let check = self.object_tags(editor, None).into_iter().find(|(_, tag)| { | ||
| 764 | tag.icon.checkable() | ||
| 765 | && (tag.origin[0]..=tag.origin[0] + tag.size).contains(&point[0]) | ||
| 766 | && (tag.origin[1]..=tag.origin[1] + tag.size).contains(&point[1]) | ||
| 767 | }); | ||
| 768 | if let Some((id, _)) = check { | ||
| 769 | return Some(SceneHit::Check(id)); | ||
| 770 | } | ||
| 719 | let mut readonly = self.read_only(editor).count(); | 771 | let mut readonly = self.read_only(editor).count(); |
| 720 | for object in self.objects(editor).ok()?.iter().rev() { | 772 | for object in self.objects(editor).ok()?.iter().rev() { |
| 721 | match object { | 773 | match object { |
| ... | @@ -746,8 +798,7 @@ impl PageScene { | ... | @@ -746,8 +798,7 @@ impl PageScene { |
| 746 | source.layout.x.unwrap_or(0.0), | 798 | source.layout.x.unwrap_or(0.0), |
| 747 | source.layout.y.unwrap_or(0.0), | 799 | source.layout.y.unwrap_or(0.0), |
| 748 | ]; | 800 | ]; |
| 749 | if let (Some(width), Some(height)) = | 801 | if let Some([width, height]) = crate::outline::image_size(source) |
| 750 | (source.layout.max_width, source.layout.max_height) | ||
| 751 | && (x..=x + width).contains(&point[0]) | 802 | && (x..=x + width).contains(&point[0]) |
| 752 | && (y..=y + height).contains(&point[1]) | 803 | && (y..=y + height).contains(&point[1]) |
| 753 | { | 804 | { |
| ... | @@ -824,11 +875,7 @@ impl PageScene { | ... | @@ -824,11 +875,7 @@ impl PageScene { |
| 824 | ]; | 875 | ]; |
| 825 | let size = match self.backgrounds.get(&source.id) { | 876 | let size = match self.backgrounds.get(&source.id) { |
| 826 | Some(art) => Some(art.size), | 877 | Some(art) => Some(art.size), |
| 827 | None => source | 878 | None => crate::outline::image_size(source), |
| 828 | .layout | ||
| 829 | .max_width | ||
| 830 | .zip(source.layout.max_height) | ||
| 831 | .map(|(width, height)| [width, height]), | ||
| 832 | }; | 879 | }; |
| 833 | if let Some([width, height]) = size { | 880 | if let Some([width, height]) = size { |
| 834 | cover([x, y, x + width, y + height], !source.background); | 881 | cover([x, y, x + width, y + height], !source.background); |
| ... | @@ -975,10 +1022,8 @@ impl PageScene { | ... | @@ -975,10 +1022,8 @@ impl PageScene { |
| 975 | .pictures | 1022 | .pictures |
| 976 | .get(&source.id) | 1023 | .get(&source.id) |
| 977 | .ok_or(SceneError::MissingImage)?; | 1024 | .ok_or(SceneError::MissingImage)?; |
| 978 | let size = [ | 1025 | let size = crate::outline::image_size(source) |
| 979 | source.layout.max_width.ok_or(SceneError::MissingImage)?, | 1026 | .ok_or(SceneError::MissingImage)?; |
| 980 | source.layout.max_height.ok_or(SceneError::MissingImage)?, | ||
| 981 | ]; | ||
| 982 | if picture.failed() { | 1027 | if picture.failed() { |
| 983 | append_placeholder( | 1028 | append_placeholder( |
| 984 | [ | 1029 | [ |
| ... | @@ -1059,6 +1104,16 @@ impl PageScene { | ... | @@ -1059,6 +1104,16 @@ impl PageScene { |
| 1059 | | Content::File { .. } => unreachable!(), | 1104 | | Content::File { .. } => unreachable!(), |
| 1060 | } | 1105 | } |
| 1061 | } | 1106 | } |
| 1107 | let art = super::TagArt::default(); | ||
| 1108 | for (_, tag) in self.object_tags(editor, moving) { | ||
| 1109 | primitives.push(Primitive::Icon { | ||
| 1110 | sources: art.sources(&tag), | ||
| 1111 | origin: [tag.origin[0] + offset[0], tag.origin[1] + offset[1]], | ||
| 1112 | size: tag.size, | ||
| 1113 | tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], | ||
| 1114 | palette: draw::Palette::default(), | ||
| 1115 | }); | ||
| 1116 | } | ||
| 1062 | Ok(()) | 1117 | Ok(()) |
| 1063 | } | 1118 | } |
| 1064 | } | 1119 | } |
| ... | @@ -1547,6 +1602,7 @@ mod tests { | ... | @@ -1547,6 +1602,7 @@ mod tests { |
| 1547 | onestore::page::Definition { | 1602 | onestore::page::Definition { |
| 1548 | kind: onestore::document::Kind::Style { | 1603 | kind: onestore::document::Kind::Style { |
| 1549 | name: Some("p".into()), | 1604 | name: Some("p".into()), |
| 1605 | next: None, | ||
| 1550 | }, | 1606 | }, |
| 1551 | format: onestore::document::Format { | 1607 | format: onestore::document::Format { |
| 1552 | font_size: Some(base_size), | 1608 | font_size: Some(base_size), |
| ... | @@ -1720,6 +1776,9 @@ mod tests { | ... | @@ -1720,6 +1776,9 @@ mod tests { |
| 1720 | alt: None, | 1776 | alt: None, |
| 1721 | background, | 1777 | background, |
| 1722 | printout: None, | 1778 | printout: None, |
| 1779 | tags: Vec::new(), | ||
| 1780 | link: None, | ||
| 1781 | text: None, | ||
| 1723 | })); | 1782 | })); |
| 1724 | } | 1783 | } |
| 1725 | let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | 1784 | let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| ... | @@ -2082,6 +2141,9 @@ mod tests { | ... | @@ -2082,6 +2141,9 @@ mod tests { |
| 2082 | alt: Some("missing diagram".into()), | 2141 | alt: Some("missing diagram".into()), |
| 2083 | background: false, | 2142 | background: false, |
| 2084 | printout: None, | 2143 | printout: None, |
| 2144 | tags: Vec::new(), | ||
| 2145 | link: None, | ||
| 2146 | text: None, | ||
| 2085 | }), | 2147 | }), |
| 2086 | PageObject::Unsupported(unknown.clone()), | 2148 | PageObject::Unsupported(unknown.clone()), |
| 2087 | ], | 2149 | ], |
| ... | @@ -2252,6 +2314,9 @@ mod tests { | ... | @@ -2252,6 +2314,9 @@ mod tests { |
| 2252 | alt: None, | 2314 | alt: None, |
| 2253 | background: false, | 2315 | background: false, |
| 2254 | printout: None, | 2316 | printout: None, |
| 2317 | tags: Vec::new(), | ||
| 2318 | link: None, | ||
| 2319 | text: None, | ||
| 2255 | }), | 2320 | }), |
| 2256 | outline(50.0, "last"), | 2321 | outline(50.0, "last"), |
| 2257 | ], | 2322 | ], |
| ... | @@ -2545,6 +2610,9 @@ mod tests { | ... | @@ -2545,6 +2610,9 @@ mod tests { |
| 2545 | alt: None, | 2610 | alt: None, |
| 2546 | background: false, | 2611 | background: false, |
| 2547 | printout: None, | 2612 | printout: None, |
| 2613 | tags: Vec::new(), | ||
| 2614 | link: None, | ||
| 2615 | text: None, | ||
| 2548 | })], | 2616 | })], |
| 2549 | }; | 2617 | }; |
| 2550 | let mut engine = TextEngine::default(); | 2618 | let mut engine = TextEngine::default(); |
| ... | @@ -2610,6 +2678,9 @@ mod tests { | ... | @@ -2610,6 +2678,9 @@ mod tests { |
| 2610 | alt: None, | 2678 | alt: None, |
| 2611 | background, | 2679 | background, |
| 2612 | printout: None, | 2680 | printout: None, |
| 2681 | tags: Vec::new(), | ||
| 2682 | link: None, | ||
| 2683 | text: None, | ||
| 2613 | }; | 2684 | }; |
| 2614 | let [background, picture] = [image(0.0, true), image(50.0, false)]; | 2685 | let [background, picture] = [image(0.0, true), image(50.0, false)]; |
| 2615 | let id = picture.id; | 2686 | let id = picture.id; |
| ... | @@ -2681,6 +2752,9 @@ mod tests { | ... | @@ -2681,6 +2752,9 @@ mod tests { |
| 2681 | alt: None, | 2752 | alt: None, |
| 2682 | background: false, | 2753 | background: false, |
| 2683 | printout: None, | 2754 | printout: None, |
| 2755 | tags: Vec::new(), | ||
| 2756 | link: None, | ||
| 2757 | text: None, | ||
| 2684 | }) | 2758 | }) |
| 2685 | .collect(); | 2759 | .collect(); |
| 2686 | assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES); | 2760 | assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES); |
crates/canvas/src/gpu/page/background.rs+10| ... | @@ -429,6 +429,13 @@ struct Variant { | ... | @@ -429,6 +429,13 @@ struct Variant { |
| 429 | } | 429 | } |
| 430 | 430 | ||
| 431 | impl Background { | 431 | impl Background { |
| 432 | /// The rasters made so far, for light and for dark paper. | ||
| 433 | pub fn images(&self) -> impl Iterator<Item = &RasterImage> { | ||
| 434 | self.variants | ||
| 435 | .iter() | ||
| 436 | .filter_map(|variant| variant.shown.as_ref().map(|(image, _)| image)) | ||
| 437 | } | ||
| 438 | |||
| 432 | /// Whether both recreate the same template picture at the same size. | 439 | /// Whether both recreate the same template picture at the same size. |
| 433 | pub fn same(&self, other: &Self) -> bool { | 440 | pub fn same(&self, other: &Self) -> bool { |
| 434 | self.svg == other.svg && self.size == other.size | 441 | self.svg == other.svg && self.size == other.size |
| ... | @@ -757,6 +764,9 @@ mod tests { | ... | @@ -757,6 +764,9 @@ mod tests { |
| 757 | alt: None, | 764 | alt: None, |
| 758 | background: true, | 765 | background: true, |
| 759 | printout: None, | 766 | printout: None, |
| 767 | tags: Vec::new(), | ||
| 768 | link: None, | ||
| 769 | text: None, | ||
| 760 | } | 770 | } |
| 761 | } | 771 | } |
| 762 | 772 |
crates/canvas/src/gpu/profile.rs+3| ... | @@ -247,6 +247,9 @@ fn frame_cost() { | ... | @@ -247,6 +247,9 @@ fn frame_cost() { |
| 247 | alt: None, | 247 | alt: None, |
| 248 | background: false, | 248 | background: false, |
| 249 | printout: None, | 249 | printout: None, |
| 250 | tags: Vec::new(), | ||
| 251 | link: None, | ||
| 252 | text: None, | ||
| 250 | }); | 253 | }); |
| 251 | let cell = || TableCell { | 254 | let cell = || TableCell { |
| 252 | id: new_id().unwrap(), | 255 | id: new_id().unwrap(), |
crates/canvas/src/interaction/mod.rs+210-41| ... | @@ -16,7 +16,10 @@ mod tests; | ... | @@ -16,7 +16,10 @@ mod tests; |
| 16 | use crate::gpu::{Paper, Viewport, page::PageScene}; | 16 | use crate::gpu::{Paper, Viewport, page::PageScene}; |
| 17 | use crate::{ | 17 | use crate::{ |
| 18 | date::DateField, | 18 | date::DateField, |
| 19 | editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline, Whole}, | 19 | editor::{ |
| 20 | Awaited, CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection, TextOutline, | ||
| 21 | Whole, | ||
| 22 | }, | ||
| 20 | layout::TextEngine, | 23 | layout::TextEngine, |
| 21 | }; | 24 | }; |
| 22 | use draw::{ | 25 | use draw::{ |
| ... | @@ -177,6 +180,15 @@ enum Drag { | ... | @@ -177,6 +180,15 @@ enum Drag { |
| 177 | outline: Option<Box<TextOutline>>, | 180 | outline: Option<Box<TextOutline>>, |
| 178 | grab: f32, | 181 | grab: f32, |
| 179 | }, | 182 | }, |
| 183 | /// A table column's right border from document x `press`, where the column was `width` | ||
| 184 | /// wide; the preview holds the width it has reached. | ||
| 185 | Column { | ||
| 186 | table: onestore::ExGuid, | ||
| 187 | column: usize, | ||
| 188 | width: f32, | ||
| 189 | press: f32, | ||
| 190 | preview: Option<(f32, Box<TextOutline>)>, | ||
| 191 | }, | ||
| 180 | Outline { | 192 | Outline { |
| 181 | id: onestore::ExGuid, | 193 | id: onestore::ExGuid, |
| 182 | grab: [f32; 2], | 194 | grab: [f32; 2], |
| ... | @@ -346,9 +358,14 @@ impl PageView { | ... | @@ -346,9 +358,14 @@ impl PageView { |
| 346 | view | 358 | view |
| 347 | } | 359 | } |
| 348 | 360 | ||
| 349 | /// Shows a page reloaded from storage in place of the edited one. | 361 | /// Shows a page reloaded from storage in place of the edited one, whose editor it returns. |
| 350 | pub fn replace(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { | 362 | pub fn replace( |
| 351 | self.editor = editor; | 363 | &mut self, |
| 364 | mut editor: CanvasEditor, | ||
| 365 | scene: Option<(PageScene, [f32; 2])>, | ||
| 366 | ) -> CanvasEditor { | ||
| 367 | editor.default_font = std::mem::take(&mut self.editor.default_font); | ||
| 368 | let left = std::mem::replace(&mut self.editor, editor); | ||
| 352 | self.scene = scene; | 369 | self.scene = scene; |
| 353 | self.drag = None; | 370 | self.drag = None; |
| 354 | self.space = false; | 371 | self.space = false; |
| ... | @@ -356,6 +373,7 @@ impl PageView { | ... | @@ -356,6 +373,7 @@ impl PageView { |
| 356 | self.found.clear(); | 373 | self.found.clear(); |
| 357 | self.played = None; | 374 | self.played = None; |
| 358 | self.leave_ink(); | 375 | self.leave_ink(); |
| 376 | left | ||
| 359 | } | 377 | } |
| 360 | 378 | ||
| 361 | /// Shows the stored page after a change made elsewhere in place of the one shown, | 379 | /// Shows the stored page after a change made elsewhere in place of the one shown, |
| ... | @@ -376,23 +394,36 @@ impl PageView { | ... | @@ -376,23 +394,36 @@ impl PageView { |
| 376 | 394 | ||
| 377 | /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was | 395 | /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was |
| 378 | /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and | 396 | /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and |
| 379 | /// does not reveal the restored selection. | 397 | /// does not reveal the restored selection. Returns the editor of the page left. |
| 380 | pub fn open( | 398 | pub fn open( |
| 381 | &mut self, | 399 | &mut self, |
| 382 | editor: CanvasEditor, | 400 | editor: CanvasEditor, |
| 383 | scene: Option<(PageScene, [f32; 2])>, | 401 | scene: Option<(PageScene, [f32; 2])>, |
| 384 | place: Option<Place>, | 402 | place: Option<Place>, |
| 385 | ) { | 403 | ) -> CanvasEditor { |
| 386 | self.replace(editor, scene); | 404 | let left = self.replace(editor, scene); |
| 387 | self.reach = None; | 405 | self.reach = None; |
| 388 | let Some(place) = place else { | 406 | match place { |
| 389 | return self.place_opened(); | 407 | Some(place) => { |
| 390 | }; | 408 | self.viewport.origin = place.origin; |
| 391 | self.viewport.origin = place.origin; | 409 | self.scroll().clamp(&mut self.viewport); |
| 392 | self.scroll().clamp(&mut self.viewport); | 410 | if self.editor.focus_outline(place.outline).is_ok() { |
| 393 | if self.editor.focus_outline(place.outline).is_ok() { | 411 | let _ = self.editor.select(place.selection); |
| 394 | let _ = self.editor.select(place.selection); | 412 | } |
| 413 | } | ||
| 414 | None => self.place_opened(), | ||
| 395 | } | 415 | } |
| 416 | left | ||
| 417 | } | ||
| 418 | |||
| 419 | /// Takes up `parked`, the editor the page `open` just showed was left with, history and | ||
| 420 | /// all, in place of the one it opened with. | ||
| 421 | pub fn resume(&mut self, parked: CanvasEditor) -> Result<()> { | ||
| 422 | self.editor.resume(parked, &mut self.engine)?; | ||
| 423 | if let Some((scene, _)) = &mut self.scene { | ||
| 424 | scene.refresh(&mut self.editor, &mut self.engine)?; | ||
| 425 | } | ||
| 426 | Ok(()) | ||
| 396 | } | 427 | } |
| 397 | 428 | ||
| 398 | /// Where the page is left, for `open` to return to. | 429 | /// Where the page is left, for `open` to return to. |
| ... | @@ -907,10 +938,16 @@ impl PageView { | ... | @@ -907,10 +938,16 @@ impl PageView { |
| 907 | /// Everything to draw this frame. | 938 | /// Everything to draw this frame. |
| 908 | pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> { | 939 | pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> { |
| 909 | let preview = match &self.drag { | 940 | let preview = match &self.drag { |
| 910 | Some(Drag::Resize { | 941 | Some( |
| 911 | outline: Some(outline), | 942 | Drag::Resize { |
| 912 | .. | 943 | outline: Some(outline), |
| 913 | }) => Some(PointerFeedback::Resize(outline)), | 944 | .. |
| 945 | } | ||
| 946 | | Drag::Column { | ||
| 947 | preview: Some((_, outline)), | ||
| 948 | .. | ||
| 949 | }, | ||
| 950 | ) => Some(PointerFeedback::Resize(outline)), | ||
| 914 | _ => self | 951 | _ => self |
| 915 | .drag_delta() | 952 | .drag_delta() |
| 916 | .map(|(id, delta)| PointerFeedback::Move(id, delta)) | 953 | .map(|(id, delta)| PointerFeedback::Move(id, delta)) |
| ... | @@ -958,6 +995,7 @@ impl PageView { | ... | @@ -958,6 +995,7 @@ impl PageView { |
| 958 | Some( | 995 | Some( |
| 959 | Drag::Outline { .. } | 996 | Drag::Outline { .. } |
| 960 | | Drag::Resize { .. } | 997 | | Drag::Resize { .. } |
| 998 | | Drag::Column { .. } | ||
| 961 | | Drag::Image { .. } | 999 | | Drag::Image { .. } |
| 962 | | Drag::Space { .. } | 1000 | | Drag::Space { .. } |
| 963 | ) | 1001 | ) |
| ... | @@ -1038,8 +1076,14 @@ impl PageView { | ... | @@ -1038,8 +1076,14 @@ impl PageView { |
| 1038 | let hit = self.hit_test(point); | 1076 | let hit = self.hit_test(point); |
| 1039 | match (&self.drag, hit) { | 1077 | match (&self.drag, hit) { |
| 1040 | (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), | 1078 | (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), |
| 1079 | (None, Some(Hit::Image { id, handle: [0, 0] })) | ||
| 1080 | if self.modifiers.command && self.editor.picture_link(id).is_some() => | ||
| 1081 | { | ||
| 1082 | Cursor::Pointer | ||
| 1083 | } | ||
| 1041 | (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), | 1084 | (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), |
| 1042 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, | 1085 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, |
| 1086 | (Some(Drag::Column { .. }), _) | (None, Some(Hit::Column { .. })) => Cursor::ColResize, | ||
| 1043 | (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, | 1087 | (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, |
| 1044 | (None, Some(Hit::Date(_))) => Cursor::Pointer, | 1088 | (None, Some(Hit::Date(_))) => Cursor::Pointer, |
| 1045 | (None, Some(Hit::Text { id, point })) | 1089 | (None, Some(Hit::Text { id, point })) |
| ... | @@ -1047,7 +1091,10 @@ impl PageView { | ... | @@ -1047,7 +1091,10 @@ impl PageView { |
| 1047 | { | 1091 | { |
| 1048 | Cursor::Pointer | 1092 | Cursor::Pointer |
| 1049 | } | 1093 | } |
| 1050 | (None, Some(Hit::ReadOnly(_) | Hit::Check { .. } | Hit::File(_))) => Cursor::Default, | 1094 | ( |
| 1095 | None, | ||
| 1096 | Some(Hit::ReadOnly(_) | Hit::Check { .. } | Hit::ObjectCheck(_) | Hit::File(_)), | ||
| 1097 | ) => Cursor::Default, | ||
| 1051 | _ => Cursor::Text, | 1098 | _ => Cursor::Text, |
| 1052 | } | 1099 | } |
| 1053 | } | 1100 | } |
| ... | @@ -1155,6 +1202,23 @@ impl PageView { | ... | @@ -1155,6 +1202,23 @@ impl PageView { |
| 1155 | } | 1202 | } |
| 1156 | Ok(Response::default()) | 1203 | Ok(Response::default()) |
| 1157 | } | 1204 | } |
| 1205 | Some(Drag::Column { | ||
| 1206 | table, | ||
| 1207 | column, | ||
| 1208 | width, | ||
| 1209 | press, | ||
| 1210 | preview, | ||
| 1211 | }) => { | ||
| 1212 | let width = *width + self.viewport.document_point(self.pointer)[0] - *press; | ||
| 1213 | if preview.as_ref().is_none_or(|(shown, _)| *shown != width) { | ||
| 1214 | let outline = | ||
| 1215 | self.editor | ||
| 1216 | .preview_column(&mut self.engine, *table, *column, width)?; | ||
| 1217 | *preview = Some((width, Box::new(outline))); | ||
| 1218 | return self.changed(); | ||
| 1219 | } | ||
| 1220 | Ok(Response::default()) | ||
| 1221 | } | ||
| 1158 | None => Ok(Response::redraw()), | 1222 | None => Ok(Response::redraw()), |
| 1159 | } | 1223 | } |
| 1160 | } | 1224 | } |
| ... | @@ -1195,6 +1259,12 @@ impl PageView { | ... | @@ -1195,6 +1259,12 @@ impl PageView { |
| 1195 | .click_check(&mut self.engine, outline, paragraph)?; | 1259 | .click_check(&mut self.engine, outline, paragraph)?; |
| 1196 | return self.changed(); | 1260 | return self.changed(); |
| 1197 | } | 1261 | } |
| 1262 | Some(Hit::ObjectCheck(id)) => { | ||
| 1263 | self.set_object_focus(None); | ||
| 1264 | self.drag = None; | ||
| 1265 | self.editor.click_object_check(id)?; | ||
| 1266 | return self.changed(); | ||
| 1267 | } | ||
| 1198 | Some(Hit::File(id)) => { | 1268 | Some(Hit::File(id)) => { |
| 1199 | self.set_object_focus(Some(ObjectFocus::File(id))); | 1269 | self.set_object_focus(Some(ObjectFocus::File(id))); |
| 1200 | self.drag = None; | 1270 | self.drag = None; |
| ... | @@ -1213,6 +1283,17 @@ impl PageView { | ... | @@ -1213,6 +1283,17 @@ impl PageView { |
| 1213 | }); | 1283 | }); |
| 1214 | } | 1284 | } |
| 1215 | } | 1285 | } |
| 1286 | // A click selects a linked picture; Ctrl+click follows its link, as in OneNote. | ||
| 1287 | Some(Hit::Image { id, handle: [0, 0] }) | ||
| 1288 | if self.modifiers.command | ||
| 1289 | && let Some(address) = self.editor.picture_link(id) => | ||
| 1290 | { | ||
| 1291 | self.drag = None; | ||
| 1292 | return Ok(Response { | ||
| 1293 | request: Some(Request::OpenLink(address.to_owned())), | ||
| 1294 | ..Response::default() | ||
| 1295 | }); | ||
| 1296 | } | ||
| 1216 | Some(Hit::Image { id, handle }) => { | 1297 | Some(Hit::Image { id, handle }) => { |
| 1217 | self.set_object_focus(Some(ObjectFocus::Image(id))); | 1298 | self.set_object_focus(Some(ObjectFocus::Image(id))); |
| 1218 | self.drag = Some(Drag::Image { | 1299 | self.drag = Some(Drag::Image { |
| ... | @@ -1242,6 +1323,22 @@ impl PageView { | ... | @@ -1242,6 +1323,22 @@ impl PageView { |
| 1242 | grab, | 1323 | grab, |
| 1243 | }); | 1324 | }); |
| 1244 | } | 1325 | } |
| 1326 | Some(Hit::Column { | ||
| 1327 | id, | ||
| 1328 | table, | ||
| 1329 | column, | ||
| 1330 | width, | ||
| 1331 | }) => { | ||
| 1332 | self.set_object_focus(None); | ||
| 1333 | self.editor.focus_outline(id)?; | ||
| 1334 | self.drag = Some(Drag::Column { | ||
| 1335 | table, | ||
| 1336 | column, | ||
| 1337 | width, | ||
| 1338 | press: point[0], | ||
| 1339 | preview: None, | ||
| 1340 | }); | ||
| 1341 | } | ||
| 1245 | Some(Hit::Text { id, point }) | 1342 | Some(Hit::Text { id, point }) |
| 1246 | if unit == SelectionUnit::Grapheme | 1343 | if unit == SelectionUnit::Grapheme |
| 1247 | && !self.modifiers.shift | 1344 | && !self.modifiers.shift |
| ... | @@ -1326,13 +1423,24 @@ impl PageView { | ... | @@ -1326,13 +1423,24 @@ impl PageView { |
| 1326 | self.editor | 1423 | self.editor |
| 1327 | .place_image(&mut self.engine, id, origin, size)?; | 1424 | .place_image(&mut self.engine, id, origin, size)?; |
| 1328 | } | 1425 | } |
| 1329 | if let Some(Drag::Resize { | 1426 | match self.drag.take() { |
| 1330 | outline: Some(outline), | 1427 | Some(Drag::Resize { |
| 1331 | .. | 1428 | outline: Some(outline), |
| 1332 | }) = self.drag.take() | 1429 | .. |
| 1333 | { | 1430 | }) => { |
| 1334 | self.editor | 1431 | self.editor |
| 1335 | .resize(&mut self.engine, outline.bounds().width() as f32)?; | 1432 | .resize(&mut self.engine, outline.bounds().width() as f32)?; |
| 1433 | } | ||
| 1434 | Some(Drag::Column { | ||
| 1435 | table, | ||
| 1436 | column, | ||
| 1437 | preview: Some((width, _)), | ||
| 1438 | .. | ||
| 1439 | }) => { | ||
| 1440 | self.editor | ||
| 1441 | .resize_column(&mut self.engine, table, column, width)?; | ||
| 1442 | } | ||
| 1443 | _ => {} | ||
| 1336 | } | 1444 | } |
| 1337 | if let Some(id) = clicked { | 1445 | if let Some(id) = clicked { |
| 1338 | // A click on an outline's handle selects it as OneNote does. | 1446 | // A click on an outline's handle selects it as OneNote does. |
| ... | @@ -1421,6 +1529,11 @@ impl PageView { | ... | @@ -1421,6 +1529,11 @@ impl PageView { |
| 1421 | /// A toolbar command, ignored like other edits while an input method composes or an object | 1529 | /// A toolbar command, ignored like other edits while an input method composes or an object |
| 1422 | /// holds focus. | 1530 | /// holds focus. |
| 1423 | pub fn format(&mut self, command: Formatting) -> Result<Response> { | 1531 | pub fn format(&mut self, command: Formatting) -> Result<Response> { |
| 1532 | if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus | ||
| 1533 | && self.editor.format_object(&mut self.engine, id, &command)? | ||
| 1534 | { | ||
| 1535 | return self.edited(); | ||
| 1536 | } | ||
| 1424 | if !self.accepts_text() || self.editor.marked_range().is_some() { | 1537 | if !self.accepts_text() || self.editor.marked_range().is_some() { |
| 1425 | return Ok(Response::default()); | 1538 | return Ok(Response::default()); |
| 1426 | } | 1539 | } |
| ... | @@ -1692,25 +1805,44 @@ impl PageView { | ... | @@ -1692,25 +1805,44 @@ impl PageView { |
| 1692 | if !self.accepts_text() || self.editor.marked_range().is_some() { | 1805 | if !self.accepts_text() || self.editor.marked_range().is_some() { |
| 1693 | return Ok(Response::default()); | 1806 | return Ok(Response::default()); |
| 1694 | } | 1807 | } |
| 1695 | let image = onestore::page::Image { | 1808 | let image = crate::editor::picture(bytes, size)?; |
| 1696 | id: onestore::page::text::new_id()?, | ||
| 1697 | layout: onestore::document::Layout { | ||
| 1698 | max_width: Some(size[0]), | ||
| 1699 | max_height: Some(size[1]), | ||
| 1700 | ..Default::default() | ||
| 1701 | }, | ||
| 1702 | size: Some(size), | ||
| 1703 | bytes: Some(bytes.into()), | ||
| 1704 | display: None, | ||
| 1705 | alt: None, | ||
| 1706 | background: false, | ||
| 1707 | printout: None, | ||
| 1708 | }; | ||
| 1709 | self.editor.insert_picture(&mut self.engine, image)?; | 1809 | self.editor.insert_picture(&mut self.engine, image)?; |
| 1710 | self.follow_pictures()?; | 1810 | self.follow_pictures()?; |
| 1711 | self.edited() | 1811 | self.edited() |
| 1712 | } | 1812 | } |
| 1713 | 1813 | ||
| 1814 | /// Pasted text in `language`, an LCID, and pictures, in order and as one undo step; see | ||
| 1815 | /// [`CanvasEditor::paste_pieces`]. | ||
| 1816 | pub fn paste_pieces( | ||
| 1817 | &mut self, | ||
| 1818 | pieces: Vec<Piece>, | ||
| 1819 | language: u32, | ||
| 1820 | ) -> Result<(Vec<Awaited>, Response)> { | ||
| 1821 | if !self.accepts_text() || self.editor.marked_range().is_some() { | ||
| 1822 | return Ok((Vec::new(), Response::default())); | ||
| 1823 | } | ||
| 1824 | let awaited = self | ||
| 1825 | .editor | ||
| 1826 | .paste_pieces(&mut self.engine, pieces, language)?; | ||
| 1827 | self.follow_pictures()?; | ||
| 1828 | Ok((awaited, self.edited()?)) | ||
| 1829 | } | ||
| 1830 | |||
| 1831 | /// An awaited picture arrived: see [`CanvasEditor::insert_awaited`]. The view stays. | ||
| 1832 | pub fn insert_awaited( | ||
| 1833 | &mut self, | ||
| 1834 | at: Awaited, | ||
| 1835 | bytes: Vec<u8>, | ||
| 1836 | size: [f32; 2], | ||
| 1837 | ) -> Result<Response> { | ||
| 1838 | let image = crate::editor::picture(bytes, size)?; | ||
| 1839 | if !self.editor.insert_awaited(&mut self.engine, at, image)? { | ||
| 1840 | return Ok(Response::default()); | ||
| 1841 | } | ||
| 1842 | self.follow_pictures()?; | ||
| 1843 | self.changed() | ||
| 1844 | } | ||
| 1845 | |||
| 1714 | /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go. | 1846 | /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go. |
| 1715 | pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> { | 1847 | pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> { |
| 1716 | if !self.accepts_text() || self.editor.marked_range().is_some() { | 1848 | if !self.accepts_text() || self.editor.marked_range().is_some() { |
| ... | @@ -1765,6 +1897,24 @@ impl PageView { | ... | @@ -1765,6 +1897,24 @@ impl PageView { |
| 1765 | position: [f32; 2], | 1897 | position: [f32; 2], |
| 1766 | file: onestore::page::Attachment, | 1898 | file: onestore::page::Attachment, |
| 1767 | ) -> Result<Response> { | 1899 | ) -> Result<Response> { |
| 1900 | self.drop_caret(position)?; | ||
| 1901 | self.insert_attachment(file) | ||
| 1902 | } | ||
| 1903 | |||
| 1904 | /// A picture file dropped at view point `position`, in device pixels, placed as | ||
| 1905 | /// [`Self::drop_attachment`] places a file and sized as [`Self::insert_picture`]. | ||
| 1906 | pub fn drop_picture( | ||
| 1907 | &mut self, | ||
| 1908 | position: [f32; 2], | ||
| 1909 | bytes: Vec<u8>, | ||
| 1910 | size: [f32; 2], | ||
| 1911 | ) -> Result<Response> { | ||
| 1912 | self.drop_caret(position)?; | ||
| 1913 | self.insert_picture(bytes, size) | ||
| 1914 | } | ||
| 1915 | |||
| 1916 | /// Puts the caret where a drop at `position` lands, as a click there would. | ||
| 1917 | fn drop_caret(&mut self, position: [f32; 2]) -> Result<()> { | ||
| 1768 | let point = self.viewport.document_point(position); | 1918 | let point = self.viewport.document_point(position); |
| 1769 | self.set_object_focus(None); | 1919 | self.set_object_focus(None); |
| 1770 | match self.hit_test(point) { | 1920 | match self.hit_test(point) { |
| ... | @@ -1779,7 +1929,7 @@ impl PageView { | ... | @@ -1779,7 +1929,7 @@ impl PageView { |
| 1779 | None => self.place_caret(point)?, | 1929 | None => self.place_caret(point)?, |
| 1780 | _ => {} | 1930 | _ => {} |
| 1781 | } | 1931 | } |
| 1782 | self.insert_attachment(file) | 1932 | Ok(()) |
| 1783 | } | 1933 | } |
| 1784 | 1934 | ||
| 1785 | /// Professional, or Linear with `linear`, on the equation at the caret. | 1935 | /// Professional, or Linear with `linear`, on the equation at the caret. |
| ... | @@ -1906,6 +2056,7 @@ impl PageView { | ... | @@ -1906,6 +2056,7 @@ impl PageView { |
| 1906 | Some( | 2056 | Some( |
| 1907 | Drag::Outline { .. } | 2057 | Drag::Outline { .. } |
| 1908 | | Drag::Resize { .. } | 2058 | | Drag::Resize { .. } |
| 2059 | | Drag::Column { .. } | ||
| 1909 | | Drag::Image { .. } | 2060 | | Drag::Image { .. } |
| 1910 | | Drag::Space { .. } | 2061 | | Drag::Space { .. } |
| 1911 | ) | 2062 | ) |
| ... | @@ -2086,6 +2237,13 @@ pub enum Hit { | ... | @@ -2086,6 +2237,13 @@ pub enum Hit { |
| 2086 | id: onestore::ExGuid, | 2237 | id: onestore::ExGuid, |
| 2087 | grab: f32, | 2238 | grab: f32, |
| 2088 | }, | 2239 | }, |
| 2240 | /// The right border of `column` of `table`, which is `width` wide. | ||
| 2241 | Column { | ||
| 2242 | id: onestore::ExGuid, | ||
| 2243 | table: onestore::ExGuid, | ||
| 2244 | column: usize, | ||
| 2245 | width: f32, | ||
| 2246 | }, | ||
| 2089 | Text { | 2247 | Text { |
| 2090 | id: onestore::ExGuid, | 2248 | id: onestore::ExGuid, |
| 2091 | point: [f32; 2], | 2249 | point: [f32; 2], |
| ... | @@ -2107,6 +2265,8 @@ pub enum Hit { | ... | @@ -2107,6 +2265,8 @@ pub enum Hit { |
| 2107 | }, | 2265 | }, |
| 2108 | /// A file in an outline's flow or on the page, its icon or name. | 2266 | /// A file in an outline's flow or on the page, its icon or name. |
| 2109 | File(onestore::ExGuid), | 2267 | File(onestore::ExGuid), |
| 2268 | /// A check box tag on a picture or file on the page. | ||
| 2269 | ObjectCheck(onestore::ExGuid), | ||
| 2110 | } | 2270 | } |
| 2111 | 2271 | ||
| 2112 | /// What a document point lands on; `pixel` is document points per device pixel. | 2272 | /// What a document point lands on; `pixel` is document points per device pixel. |
| ... | @@ -2231,6 +2391,14 @@ fn page_hit( | ... | @@ -2231,6 +2391,14 @@ fn page_hit( |
| 2231 | } | 2391 | } |
| 2232 | } | 2392 | } |
| 2233 | let inner = [x - outline.origin()[0], y - outline.origin()[1]]; | 2393 | let inner = [x - outline.origin()[0], y - outline.origin()[1]]; |
| 2394 | if let Some((table, column, width)) = outline.column_border(inner, 3.0 * pixel) { | ||
| 2395 | return Some(Hit::Column { | ||
| 2396 | id: outline.id, | ||
| 2397 | table, | ||
| 2398 | column, | ||
| 2399 | width, | ||
| 2400 | }); | ||
| 2401 | } | ||
| 2234 | if let Some(picture) = outline.shaped().objects.iter().find(|object| { | 2402 | if let Some(picture) = outline.shaped().objects.iter().find(|object| { |
| 2235 | matches!(object.kind, crate::outline::ObjectKind::Picture) | 2403 | matches!(object.kind, crate::outline::ObjectKind::Picture) |
| 2236 | && (object.rect[0]..=object.rect[2]).contains(&inner[0]) | 2404 | && (object.rect[0]..=object.rect[2]).contains(&inner[0]) |
| ... | @@ -2285,6 +2453,7 @@ fn page_hit( | ... | @@ -2285,6 +2453,7 @@ fn page_hit( |
| 2285 | crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), | 2453 | crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), |
| 2286 | crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), | 2454 | crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), |
| 2287 | crate::gpu::page::SceneHit::File(id) => Some(Hit::File(id)), | 2455 | crate::gpu::page::SceneHit::File(id) => Some(Hit::File(id)), |
| 2456 | crate::gpu::page::SceneHit::Check(id) => Some(Hit::ObjectCheck(id)), | ||
| 2288 | } | 2457 | } |
| 2289 | }; | 2458 | }; |
| 2290 | [Layer::Grips, Layer::Body, Layer::Below] | 2459 | [Layer::Grips, Layer::Body, Layer::Below] |
crates/canvas/src/interaction/tests.rs+189| ... | @@ -708,6 +708,9 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { | ... | @@ -708,6 +708,9 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { |
| 708 | alt: None, | 708 | alt: None, |
| 709 | background: false, | 709 | background: false, |
| 710 | printout: None, | 710 | printout: None, |
| 711 | tags: Vec::new(), | ||
| 712 | link: None, | ||
| 713 | text: None, | ||
| 711 | }); | 714 | }); |
| 712 | source.paragraphs.insert(1, picture); | 715 | source.paragraphs.insert(1, picture); |
| 713 | let editor = CanvasEditor::from_page( | 716 | let editor = CanvasEditor::from_page( |
| ... | @@ -882,6 +885,13 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { | ... | @@ -882,6 +885,13 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { |
| 882 | .unwrap(); | 885 | .unwrap(); |
| 883 | editor.tab(&mut engine, false).unwrap(); | 886 | editor.tab(&mut engine, false).unwrap(); |
| 884 | editor.insert(&mut engine, "R").unwrap(); | 887 | editor.insert(&mut engine, "R").unwrap(); |
| 888 | // Columns dragged narrower than the glyphs they hold. | ||
| 889 | let table = editor.active_outline().shaped().tables[0].id; | ||
| 890 | for column in 0..2 { | ||
| 891 | editor | ||
| 892 | .resize_column(&mut engine, table, column, 37.11) | ||
| 893 | .unwrap(); | ||
| 894 | } | ||
| 885 | editor.tab(&mut engine, true).unwrap(); | 895 | editor.tab(&mut engine, true).unwrap(); |
| 886 | let mut primitives = Vec::new(); | 896 | let mut primitives = Vec::new(); |
| 887 | append_outline( | 897 | append_outline( |
| ... | @@ -1545,6 +1555,9 @@ fn picture_view() -> (PageView, onestore::ExGuid) { | ... | @@ -1545,6 +1555,9 @@ fn picture_view() -> (PageView, onestore::ExGuid) { |
| 1545 | alt: None, | 1555 | alt: None, |
| 1546 | background: false, | 1556 | background: false, |
| 1547 | printout: None, | 1557 | printout: None, |
| 1558 | tags: Vec::new(), | ||
| 1559 | link: None, | ||
| 1560 | text: None, | ||
| 1548 | }); | 1561 | }); |
| 1549 | source.paragraphs.insert(1, picture); | 1562 | source.paragraphs.insert(1, picture); |
| 1550 | let editor = CanvasEditor::from_page( | 1563 | let editor = CanvasEditor::from_page( |
| ... | @@ -2768,6 +2781,7 @@ fn an_attached_file_selects_opens_on_a_double_click_and_deletes() { | ... | @@ -2768,6 +2781,7 @@ fn an_attached_file_selects_opens_on_a_double_click_and_deletes() { |
| 2768 | bytes: Some(Arc::from(b"notes".as_slice())), | 2781 | bytes: Some(Arc::from(b"notes".as_slice())), |
| 2769 | preview: None, | 2782 | preview: None, |
| 2770 | recording: None, | 2783 | recording: None, |
| 2784 | tags: Vec::new(), | ||
| 2771 | }; | 2785 | }; |
| 2772 | view.editor | 2786 | view.editor |
| 2773 | .move_selection(&mut view.engine, Movement::DocumentEnd, false) | 2787 | .move_selection(&mut view.engine, Movement::DocumentEnd, false) |
| ... | @@ -2844,6 +2858,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() { | ... | @@ -2844,6 +2858,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() { |
| 2844 | bytes: Some(Arc::from(b"float".as_slice())), | 2858 | bytes: Some(Arc::from(b"float".as_slice())), |
| 2845 | preview: None, | 2859 | preview: None, |
| 2846 | recording: None, | 2860 | recording: None, |
| 2861 | tags: Vec::new(), | ||
| 2847 | }; | 2862 | }; |
| 2848 | let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap(); | 2863 | let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap(); |
| 2849 | assert_eq!(view.editor.outlines().len(), 1); | 2864 | assert_eq!(view.editor.outlines().len(), 1); |
| ... | @@ -3154,3 +3169,177 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() { | ... | @@ -3154,3 +3169,177 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() { |
| 3154 | spelling.learn("qwrt"); | 3169 | spelling.learn("qwrt"); |
| 3155 | assert_eq!(next(&mut view), None); | 3170 | assert_eq!(next(&mut view), None); |
| 3156 | } | 3171 | } |
| 3172 | |||
| 3173 | /// Page `title` of `section`, with a view of it whose scene holds its pictures. | ||
| 3174 | fn scene_view(section: &[u8], title: &str) -> PageView { | ||
| 3175 | let arena = onestore::Arena::default(); | ||
| 3176 | let mut section = onestore::Section::open(&arena, section.to_vec()).unwrap(); | ||
| 3177 | let (space, ..) = section | ||
| 3178 | .pages() | ||
| 3179 | .unwrap() | ||
| 3180 | .into_iter() | ||
| 3181 | .find(|page| page.1 == title) | ||
| 3182 | .unwrap(); | ||
| 3183 | let mut engine = TextEngine::default(); | ||
| 3184 | let (scene, editor) = PageScene::from_page(section.page(space).unwrap(), &mut engine).unwrap(); | ||
| 3185 | PageView::new( | ||
| 3186 | editor, | ||
| 3187 | engine, | ||
| 3188 | Some((scene, [0.0; 2])), | ||
| 3189 | [800, 600], | ||
| 3190 | 1.0, | ||
| 3191 | Duration::from_millis(500), | ||
| 3192 | ) | ||
| 3193 | } | ||
| 3194 | |||
| 3195 | fn device(view: &PageView, point: [f32; 2]) -> [f32; 2] { | ||
| 3196 | [0, 1].map(|axis| point[axis] * view.viewport.scale + view.viewport.origin[axis]) | ||
| 3197 | } | ||
| 3198 | |||
| 3199 | /// A click selects a picture with a link and Ctrl+click (Command on macOS) follows it, as in | ||
| 3200 | /// OneNote 2010, whose tooltip says so (`corpus/picture-link`). | ||
| 3201 | #[test] | ||
| 3202 | fn ctrl_click_follows_a_pictures_link() { | ||
| 3203 | let mut view = scene_view( | ||
| 3204 | include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one"), | ||
| 3205 | "Image png", | ||
| 3206 | ); | ||
| 3207 | let (id, [x, y], [width, height]) = view | ||
| 3208 | .editor | ||
| 3209 | .objects | ||
| 3210 | .iter() | ||
| 3211 | .find_map(|object| match object { | ||
| 3212 | crate::editor::page::Content::Image(image) => Some(image.id), | ||
| 3213 | _ => None, | ||
| 3214 | }) | ||
| 3215 | .and_then(|id| { | ||
| 3216 | let (origin, size) = view.editor.image_placement(id)?; | ||
| 3217 | Some((id, origin, size)) | ||
| 3218 | }) | ||
| 3219 | .unwrap(); | ||
| 3220 | assert_eq!( | ||
| 3221 | view.editor.picture_link(id), | ||
| 3222 | Some("https://example.invalid/image/png") | ||
| 3223 | ); | ||
| 3224 | let _ = view | ||
| 3225 | .pointer_moved(device(&view, [x + width / 2.0, y + height / 2.0])) | ||
| 3226 | .unwrap(); | ||
| 3227 | let response = view.pointer_pressed(Instant::now()).unwrap(); | ||
| 3228 | let _ = view.pointer_released().unwrap(); | ||
| 3229 | assert_eq!(response.request, None); | ||
| 3230 | assert_eq!(view.object_focus, Some(ObjectFocus::Image(id))); | ||
| 3231 | let _ = view | ||
| 3232 | .modifiers_changed(Modifiers { | ||
| 3233 | control: true, | ||
| 3234 | command: true, | ||
| 3235 | ..Modifiers::default() | ||
| 3236 | }) | ||
| 3237 | .unwrap(); | ||
| 3238 | assert_eq!(view.cursor(), Cursor::Pointer); | ||
| 3239 | let response = view.pointer_pressed(Instant::now()).unwrap(); | ||
| 3240 | let _ = view.pointer_released().unwrap(); | ||
| 3241 | assert_eq!( | ||
| 3242 | response.request, | ||
| 3243 | Some(Request::OpenLink( | ||
| 3244 | "https://example.invalid/image/png".into() | ||
| 3245 | )) | ||
| 3246 | ); | ||
| 3247 | } | ||
| 3248 | |||
| 3249 | /// A click on the check box OneNote 2010 draws beside a tagged picture on the page checks | ||
| 3250 | /// it, and undo clears it (`corpus/object-tags`). | ||
| 3251 | #[test] | ||
| 3252 | fn a_check_box_beside_a_page_picture_checks_it() { | ||
| 3253 | let mut view = scene_view( | ||
| 3254 | include_bytes!("../../../../corpus/object-tags/native/notebook/files.one"), | ||
| 3255 | "Pictures and files", | ||
| 3256 | ); | ||
| 3257 | let (id, [x, y], [_, height]) = view | ||
| 3258 | .editor | ||
| 3259 | .objects | ||
| 3260 | .iter() | ||
| 3261 | .find_map(|object| match object { | ||
| 3262 | crate::editor::page::Content::Image(image) if !image.tags.is_empty() => Some(image.id), | ||
| 3263 | _ => None, | ||
| 3264 | }) | ||
| 3265 | .and_then(|id| { | ||
| 3266 | let (origin, size) = view.editor.image_placement(id)?; | ||
| 3267 | Some((id, origin, size)) | ||
| 3268 | }) | ||
| 3269 | .unwrap(); | ||
| 3270 | let status = |view: &PageView| { | ||
| 3271 | view.editor | ||
| 3272 | .objects | ||
| 3273 | .iter() | ||
| 3274 | .find_map(|object| match object { | ||
| 3275 | crate::editor::page::Content::Image(image) if image.id == id => { | ||
| 3276 | Some(image.tags[0].status) | ||
| 3277 | } | ||
| 3278 | _ => None, | ||
| 3279 | }) | ||
| 3280 | .unwrap() | ||
| 3281 | }; | ||
| 3282 | let _ = view | ||
| 3283 | .pointer_moved(device(&view, [x - 18.0, y + height / 2.0])) | ||
| 3284 | .unwrap(); | ||
| 3285 | assert_eq!(view.cursor(), Cursor::Default); | ||
| 3286 | assert!(view.pointer_pressed(Instant::now()).unwrap().changed); | ||
| 3287 | let _ = view.pointer_released().unwrap(); | ||
| 3288 | assert_eq!(status(&view) & 1, 1); | ||
| 3289 | assert!(view.editor.undo(&mut view.engine).unwrap()); | ||
| 3290 | assert_eq!(status(&view) & 1, 0); | ||
| 3291 | } | ||
| 3292 | |||
| 3293 | /// OneNote 2010 resizes a column from its right border: a column-resize pointer over it, the | ||
| 3294 | /// table redrawn as it moves, and one edit on release. | ||
| 3295 | #[test] | ||
| 3296 | fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() { | ||
| 3297 | let mut engine = TextEngine::default(); | ||
| 3298 | let mut editor = CanvasEditor::new( | ||
| 3299 | &mut engine, | ||
| 3300 | TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(), | ||
| 3301 | 468.0, | ||
| 3302 | ) | ||
| 3303 | .unwrap(); | ||
| 3304 | editor.insert_table(&mut engine, 1, 2).unwrap(); | ||
| 3305 | let mut view = PageView::new( | ||
| 3306 | editor, | ||
| 3307 | engine, | ||
| 3308 | None, | ||
| 3309 | [800, 600], | ||
| 3310 | 1.0, | ||
| 3311 | Duration::from_millis(500), | ||
| 3312 | ); | ||
| 3313 | let _ = view.editor.take_ops().unwrap(); | ||
| 3314 | let outline = view.editor.active_outline(); | ||
| 3315 | let cell = outline.shaped().tables[0].cells[0].clone(); | ||
| 3316 | let origin = outline.origin(); | ||
| 3317 | let (scale, offset) = (view.viewport.scale, view.viewport.origin); | ||
| 3318 | let device = |point: [f32; 2]| [0, 1].map(|axis| point[axis] * scale + offset[axis]); | ||
| 3319 | let border = [ | ||
| 3320 | origin[0] + cell.rect[2], | ||
| 3321 | origin[1] + (cell.rect[1] + cell.rect[3]) / 2.0, | ||
| 3322 | ]; | ||
| 3323 | let _ = view.pointer_moved(device(border)).unwrap(); | ||
| 3324 | assert_eq!(view.cursor(), Cursor::ColResize); | ||
| 3325 | let _ = view.pointer_pressed(Instant::now()).unwrap(); | ||
| 3326 | let to = device([border[0] + 50.0, border[1]]); | ||
| 3327 | assert!(view.pointer_moved(to).unwrap().changed); | ||
| 3328 | assert_eq!(view.cursor(), Cursor::ColResize); | ||
| 3329 | view.primitives(COLORS).unwrap(); | ||
| 3330 | let onestore::page::ParagraphContent::Table(table) = | ||
| 3331 | &view.editor.active_outline().document().nodes()[0].content | ||
| 3332 | else { | ||
| 3333 | panic!() | ||
| 3334 | }; | ||
| 3335 | assert!(!table.columns[0].locked); | ||
| 3336 | let _ = view.pointer_released().unwrap(); | ||
| 3337 | let onestore::page::ParagraphContent::Table(table) = | ||
| 3338 | &view.editor.active_outline().document().nodes()[0].content | ||
| 3339 | else { | ||
| 3340 | panic!() | ||
| 3341 | }; | ||
| 3342 | assert!(table.columns[0].locked); | ||
| 3343 | assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01); | ||
| 3344 | assert_eq!(view.editor.take_ops().unwrap().len(), 1); | ||
| 3345 | } |
crates/canvas/src/layout.rs+2| ... | @@ -62,6 +62,7 @@ pub struct TextLayout { | ... | @@ -62,6 +62,7 @@ pub struct TextLayout { |
| 62 | id: u64, | 62 | id: u64, |
| 63 | pub(crate) shaped: Arc<Layout<TextBrush>>, | 63 | pub(crate) shaped: Arc<Layout<TextBrush>>, |
| 64 | /// The paragraph text laid out, which a PDF's text layer maps glyphs back to. | 64 | /// The paragraph text laid out, which a PDF's text layer maps glyphs back to. |
| 65 | #[cfg(feature = "gpu")] | ||
| 65 | pub(crate) text: Arc<str>, | 66 | pub(crate) text: Arc<str>, |
| 66 | lines: Vec<LineBox>, | 67 | lines: Vec<LineBox>, |
| 67 | /// Where each inline space lies: its index in the spaces laid out, its x, and its line. | 68 | /// Where each inline space lies: its index in the spaces laid out, its x, and its line. |
| ... | @@ -520,6 +521,7 @@ impl TextEngine { | ... | @@ -520,6 +521,7 @@ impl TextEngine { |
| 520 | Ok(TextLayout { | 521 | Ok(TextLayout { |
| 521 | id, | 522 | id, |
| 522 | shaped: Arc::new(shaped), | 523 | shaped: Arc::new(shaped), |
| 524 | #[cfg(feature = "gpu")] | ||
| 523 | text: text.into(), | 525 | text: text.into(), |
| 524 | lines, | 526 | lines, |
| 525 | spaces: placed, | 527 | spaces: placed, |
crates/canvas/src/outline.rs+72-5| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | use crate::{ | 1 | use crate::{ |
| 2 | document::{DocumentEdit, edited_nodes, leaves}, | 2 | document::{DocumentEdit, descendants, edited_nodes, leaves}, |
| 3 | layout::{InlineSpace, LayoutError, TextEngine, TextLayout}, | 3 | layout::{InlineSpace, LayoutError, TextEngine, TextLayout}, |
| 4 | }; | 4 | }; |
| 5 | use onestore::page::text::{Paragraph, TextProjection}; | 5 | use onestore::page::text::{Paragraph, TextProjection}; |
| ... | @@ -529,6 +529,51 @@ impl ParagraphTag { | ... | @@ -529,6 +529,51 @@ impl ParagraphTag { |
| 529 | } | 529 | } |
| 530 | } | 530 | } |
| 531 | 531 | ||
| 532 | /// How far a 12 pt icon starts left of a picture or file on the page, as OneNote 2010 draws | ||
| 533 | /// one (`corpus/object-tags`). | ||
| 534 | const PAGE_INSET: f32 = 24.75; | ||
| 535 | |||
| 536 | /// The note tags of a picture or file on the page, whose bounds are `bounds`: oldest first in | ||
| 537 | /// a column ending left of it, centred on it; a tag whose definition is missing draws nothing. | ||
| 538 | pub(crate) fn object_tags( | ||
| 539 | tags: &[onestore::document::Tag], | ||
| 540 | definitions: &BTreeMap<ExGuid, Definition>, | ||
| 541 | [x, top, _, bottom]: [f32; 4], | ||
| 542 | ) -> Vec<ParagraphTag> { | ||
| 543 | let side = ParagraphTag::side(crate::layout::DEFAULT_FONT_SIZE); | ||
| 544 | let mut drawn: Vec<ParagraphTag> = tags | ||
| 545 | .iter() | ||
| 546 | .rev() | ||
| 547 | .filter_map(|tag| { | ||
| 548 | let (icon, label) = if tag.status & 4 != 0 { | ||
| 549 | let shape = tag.shape.unwrap_or(0); | ||
| 550 | (TagIcon::Task { shape }, None) | ||
| 551 | } else { | ||
| 552 | let Kind::TagDefinition { shape, label, .. } = | ||
| 553 | &definitions.get(tag.definition.as_ref()?)?.kind | ||
| 554 | else { | ||
| 555 | return None; | ||
| 556 | }; | ||
| 557 | let checked = tag.status & 1 != 0; | ||
| 558 | (TagIcon::of(shape.unwrap_or(0), checked)?, label.as_deref()) | ||
| 559 | }; | ||
| 560 | Some(ParagraphTag { | ||
| 561 | icon, | ||
| 562 | origin: [0.0, (top + bottom - side) / 2.0], | ||
| 563 | size: side, | ||
| 564 | label: label.unwrap_or_default().to_owned(), | ||
| 565 | disabled: tag.status & 2 != 0, | ||
| 566 | }) | ||
| 567 | }) | ||
| 568 | .collect(); | ||
| 569 | let right = x - PAGE_INSET + ParagraphTag::SIZE; | ||
| 570 | let count = drawn.len() as f32; | ||
| 571 | for (index, tag) in drawn.iter_mut().enumerate() { | ||
| 572 | tag.origin[0] = right - side * (count - index as f32); | ||
| 573 | } | ||
| 574 | drawn | ||
| 575 | } | ||
| 576 | |||
| 532 | /// How much further than its list spacing OneNote 2010 sets a marker's advance from its text. | 577 | /// How much further than its list spacing OneNote 2010 sets a marker's advance from its text. |
| 533 | const MARKER_OFFSET: f32 = 3.9; | 578 | const MARKER_OFFSET: f32 = 3.9; |
| 534 | /// Points an equation's linear text lays out at, holding the caret without showing. | 579 | /// Points an equation's linear text lays out at, holding the caret without showing. |
| ... | @@ -1519,7 +1564,12 @@ impl OutlineLayout { | ... | @@ -1519,7 +1564,12 @@ impl OutlineLayout { |
| 1519 | unreachable!("text and tables are not objects") | 1564 | unreachable!("text and tables are not objects") |
| 1520 | } | 1565 | } |
| 1521 | }; | 1566 | }; |
| 1522 | object.tags = BlockTags::new(node, &[], x, shape)?; | 1567 | let tags = match content { |
| 1568 | ParagraphContent::Image(image) => image.tags.as_slice(), | ||
| 1569 | ParagraphContent::Attachment(file) => file.tags.as_slice(), | ||
| 1570 | _ => &[], | ||
| 1571 | }; | ||
| 1572 | object.tags = BlockTags::new(node, tags, x, shape)?; | ||
| 1523 | let flow = object.place(y, height); | 1573 | let flow = object.place(y, height); |
| 1524 | result.objects.push(object); | 1574 | result.objects.push(object); |
| 1525 | (space, height, flow, extent) | 1575 | (space, height, flow, extent) |
| ... | @@ -1580,8 +1630,9 @@ impl OutlineLayout { | ... | @@ -1580,8 +1630,9 @@ impl OutlineLayout { |
| 1580 | } | 1630 | } |
| 1581 | 1631 | ||
| 1582 | /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table | 1632 | /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table |
| 1583 | /// holding its cell, and works out where the nodes after them move. Resizing columns or | 1633 | /// holding its cell, and works out where the nodes after them move. Resizing columns of |
| 1584 | /// showing or hiding other nodes lays out every node; renumbering lays out the renumbered. | 1634 | /// other nodes or showing or hiding them lays out every node; renumbering lays out the |
| 1635 | /// renumbered. | ||
| 1585 | #[allow(clippy::too_many_arguments)] | 1636 | #[allow(clippy::too_many_arguments)] |
| 1586 | pub(crate) fn relayout( | 1637 | pub(crate) fn relayout( |
| 1587 | &self, | 1638 | &self, |
| ... | @@ -1598,7 +1649,15 @@ impl OutlineLayout { | ... | @@ -1598,7 +1649,15 @@ impl OutlineLayout { |
| 1598 | &[f32], | 1649 | &[f32], |
| 1599 | ) -> Result<ParagraphLayout, LayoutError>, | 1650 | ) -> Result<ParagraphLayout, LayoutError>, |
| 1600 | ) -> Result<Relayout, LayoutError> { | 1651 | ) -> Result<Relayout, LayoutError> { |
| 1601 | let mut range = if edit.columns.is_empty() { | 1652 | let resized = |node: &PageParagraph| match &node.content { |
| 1653 | ParagraphContent::Table(table) => edit.columns.contains_key(&table.id), | ||
| 1654 | _ => false, | ||
| 1655 | }; | ||
| 1656 | let mut range = if edit.columns.len() | ||
| 1657 | == descendants(&nodes[range.clone()], None) | ||
| 1658 | .filter(|(_, _, node)| resized(node)) | ||
| 1659 | .count() | ||
| 1660 | { | ||
| 1602 | range | 1661 | range |
| 1603 | } else { | 1662 | } else { |
| 1604 | 0..nodes.len() | 1663 | 0..nodes.len() |
| ... | @@ -1869,6 +1928,13 @@ impl OutlineLayout { | ... | @@ -1869,6 +1928,13 @@ impl OutlineLayout { |
| 1869 | table_width(&self.tables) | 1928 | table_width(&self.tables) |
| 1870 | } | 1929 | } |
| 1871 | 1930 | ||
| 1931 | /// How far the content reaches, without the 36-point floor of `size`. | ||
| 1932 | pub(crate) fn content_width(&self) -> f32 { | ||
| 1933 | let text = self.paragraphs.iter().map(|paragraph| paragraph.size()[0]); | ||
| 1934 | let objects = self.objects.iter().map(|object| object.rect[2]); | ||
| 1935 | text.chain(objects).fold(self.table_width(), f32::max) | ||
| 1936 | } | ||
| 1937 | |||
| 1872 | fn table( | 1938 | fn table( |
| 1873 | table: &Table, | 1939 | table: &Table, |
| 1874 | depth: usize, | 1940 | depth: usize, |
| ... | @@ -2747,6 +2813,7 @@ mod tests { | ... | @@ -2747,6 +2813,7 @@ mod tests { |
| 2747 | bytes: None, | 2813 | bytes: None, |
| 2748 | preview: None, | 2814 | preview: None, |
| 2749 | recording: None, | 2815 | recording: None, |
| 2816 | tags: Vec::new(), | ||
| 2750 | }); | 2817 | }); |
| 2751 | let mut ink = paragraph(3, "", 1, None); | 2818 | let mut ink = paragraph(3, "", 1, None); |
| 2752 | ink.content = ParagraphContent::Ink(Ink { | 2819 | ink.content = ParagraphContent::Ink(Ink { |
crates/canvas/src/print.rs+133-51| ... | @@ -5,8 +5,8 @@ | ... | @@ -5,8 +5,8 @@ |
| 5 | //! when its content reaches past the paper's right edge ("Scale content to paper width"). | 5 | //! when its content reaches past the paper's right edge ("Scale content to paper width"). |
| 6 | //! A page longer than a sheet continues on the next, which starts at the first line of text | 6 | //! A page longer than a sheet continues on the next, which starts at the first line of text |
| 7 | //! or picture the sheet's foot would have cut. Rule lines and template art print across the | 7 | //! or picture the sheet's foot would have cut. Rule lines and template art print across the |
| 8 | //! paper's width; the page colour does not. Each sheet's footer names the section and | 8 | //! paper's width; the page colour does not. Each sheet's footer names the section, numbers |
| 9 | //! numbers the sheet, counting from the first page printed, in Times New Roman. | 9 | //! the sheet counting from the first page printed, or both, in Times New Roman. |
| 10 | 10 | ||
| 11 | use crate::gpu::{Paper, page::PageScene}; | 11 | use crate::gpu::{Paper, page::PageScene}; |
| 12 | use crate::layout::{LayoutError, TextEngine}; | 12 | use crate::layout::{LayoutError, TextEngine}; |
| ... | @@ -40,15 +40,17 @@ struct Pagination { | ... | @@ -40,15 +40,17 @@ struct Pagination { |
| 40 | 40 | ||
| 41 | impl Pagination { | 41 | impl Pagination { |
| 42 | /// Lays content covering `bounds`, whose `rows` (lines of text, pictures) no sheet | 42 | /// Lays content covering `bounds`, whose `rows` (lines of text, pictures) no sheet |
| 43 | /// should cut, on `paper` from the page's `margin_origin`. | 43 | /// should cut, on `paper` from the page's `margin_origin`, shrunk to the paper's width |
| 44 | /// with `fit_width`. | ||
| 44 | fn new( | 45 | fn new( |
| 45 | bounds: [f32; 4], | 46 | bounds: [f32; 4], |
| 46 | rows: &[[f32; 2]], | 47 | rows: &[[f32; 2]], |
| 47 | margin_origin: [f32; 2], | 48 | margin_origin: [f32; 2], |
| 48 | paper: [f32; 2], | 49 | paper: [f32; 2], |
| 50 | fit_width: bool, | ||
| 49 | ) -> Self { | 51 | ) -> Self { |
| 50 | let left = margin_origin[0] - LEFT; | 52 | let left = margin_origin[0] - LEFT; |
| 51 | let scale = if bounds[2].is_finite() { | 53 | let scale = if fit_width && bounds[2].is_finite() { |
| 52 | (paper[0] / (bounds[2] - left + RIGHT)).min(1.0) | 54 | (paper[0] / (bounds[2] - left + RIGHT)).min(1.0) |
| 53 | } else { | 55 | } else { |
| 54 | 1.0 | 56 | 1.0 |
| ... | @@ -113,29 +115,54 @@ impl fmt::Display for PrintError { | ... | @@ -113,29 +115,54 @@ impl fmt::Display for PrintError { |
| 113 | 115 | ||
| 114 | impl std::error::Error for PrintError {} | 116 | impl std::error::Error for PrintError {} |
| 115 | 117 | ||
| 116 | /// `pages` in order as a PDF on `paper` (points), each on as many sheets as it takes, the | 118 | /// What each sheet's footer says, as OneNote's Print Preview offers it. |
| 117 | /// footers naming `section`. | 119 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 120 | pub enum Footer { | ||
| 121 | /// "Section Page 3", OneNote's default. | ||
| 122 | #[default] | ||
| 123 | SectionAndPage, | ||
| 124 | Page, | ||
| 125 | Section, | ||
| 126 | None, | ||
| 127 | } | ||
| 128 | |||
| 129 | /// How pages go on paper. | ||
| 130 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 131 | pub struct Setup { | ||
| 132 | /// Width and height in points, turned as the sheet prints. | ||
| 133 | pub paper: [f32; 2], | ||
| 134 | /// Shrinks a page whose content reaches past the paper's right edge to fit it. | ||
| 135 | pub fit_width: bool, | ||
| 136 | pub footer: Footer, | ||
| 137 | } | ||
| 138 | |||
| 139 | /// `sections`' pages in order as a PDF, each on as many sheets as it takes, the footers | ||
| 140 | /// naming the section a page is in and numbering the sheets from the first. | ||
| 118 | pub fn pdf( | 141 | pub fn pdf( |
| 119 | pages: Vec<Page>, | 142 | sections: Vec<(String, Vec<Page>)>, |
| 120 | engine: &mut TextEngine, | 143 | engine: &mut TextEngine, |
| 121 | paper: [f32; 2], | 144 | setup: &Setup, |
| 122 | section: &str, | ||
| 123 | ) -> Result<Vec<u8>, PrintError> { | 145 | ) -> Result<Vec<u8>, PrintError> { |
| 124 | let title = pages | 146 | let paper = setup.paper; |
| 125 | .first() | 147 | let title = sections |
| 148 | .iter() | ||
| 149 | .flat_map(|(_, pages)| pages.first()) | ||
| 150 | .next() | ||
| 126 | .map(|page| page.title.clone()) | 151 | .map(|page| page.title.clone()) |
| 127 | .unwrap_or_default(); | 152 | .unwrap_or_default(); |
| 128 | let mut printed = Vec::with_capacity(pages.len()); | 153 | let mut printed = Vec::new(); |
| 129 | for page in pages { | 154 | for (section, pages) in sections { |
| 130 | let (margin_origin, rules) = (page.margin_origin, page.rule_lines); | 155 | for page in pages { |
| 131 | let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?; | 156 | let (margin_origin, rules) = (page.margin_origin, page.rule_lines); |
| 132 | scene.settle(None, DENSITY, Paper::WHITE); | 157 | let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?; |
| 133 | let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?; | 158 | scene.settle(None, DENSITY, Paper::WHITE); |
| 134 | let pagination = Pagination::new(bounds, &rows, margin_origin, paper); | 159 | let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?; |
| 135 | printed.push((scene, pagination, margin_origin, rules)); | 160 | let pagination = Pagination::new(bounds, &rows, margin_origin, paper, setup.fit_width); |
| 161 | printed.push((scene, pagination, margin_origin, rules, section.clone())); | ||
| 162 | } | ||
| 136 | } | 163 | } |
| 137 | let mut contents = Vec::with_capacity(printed.len()); | 164 | let mut contents = Vec::with_capacity(printed.len()); |
| 138 | for (scene, pagination, margin_origin, rules) in &printed { | 165 | for (scene, pagination, margin_origin, rules, _) in &printed { |
| 139 | let mut primitives = Vec::new(); | 166 | let mut primitives = Vec::new(); |
| 140 | scene | 167 | scene |
| 141 | .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE) | 168 | .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE) |
| ... | @@ -161,14 +188,17 @@ pub fn pdf( | ... | @@ -161,14 +188,17 @@ pub fn pdf( |
| 161 | ..Default::default() | 188 | ..Default::default() |
| 162 | }; | 189 | }; |
| 163 | let mut footers = Vec::new(); | 190 | let mut footers = Vec::new(); |
| 164 | for (sheet, _) in printed | 191 | for (sheet, section) in printed |
| 165 | .iter() | 192 | .iter() |
| 166 | .flat_map(|(_, pagination, ..)| pagination.sheets()) | 193 | .flat_map(|(_, pagination, .., section)| pagination.sheets().map(move |_| section)) |
| 167 | .enumerate() | 194 | .enumerate() |
| 168 | { | 195 | { |
| 169 | let text = match section { | 196 | let number = sheet + 1; |
| 170 | "" => format!("Page {}", sheet + 1), | 197 | let text = match (setup.footer, section.as_str()) { |
| 171 | section => format!("{section} Page {}", sheet + 1), | 198 | (Footer::None, _) => continue, |
| 199 | (Footer::Section, section) => section.to_owned(), | ||
| 200 | (Footer::Page, _) | (Footer::SectionAndPage, "") => format!("Page {number}"), | ||
| 201 | (Footer::SectionAndPage, section) => format!("{section} Page {number}"), | ||
| 172 | }; | 202 | }; |
| 173 | let layout = engine | 203 | let layout = engine |
| 174 | .layout(&Paragraph::new(text, format.clone()), f32::MAX) | 204 | .layout(&Paragraph::new(text, format.clone()), f32::MAX) |
| ... | @@ -193,37 +223,33 @@ pub fn pdf( | ... | @@ -193,37 +223,33 @@ pub fn pdf( |
| 193 | let scale = pagination.scale; | 223 | let scale = pagination.scale; |
| 194 | for ([top, bottom], rules) in pagination.sheets().zip(rules) { | 224 | for ([top, bottom], rules) in pagination.sheets().zip(rules) { |
| 195 | let origin = [-pagination.left * scale, MARGIN - top * scale]; | 225 | let origin = [-pagination.left * scale, MARGIN - top * scale]; |
| 196 | // Rule lines run to the bottom margin, the page's content to where the next | 226 | let layer = |origin, clip, primitives| Layer { |
| 197 | // sheet takes it up. | ||
| 198 | let page = |primitives, bottom: f32| Layer { | ||
| 199 | scale, | 227 | scale, |
| 200 | origin, | 228 | origin, |
| 201 | clip: Some([0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale]), | 229 | clip, |
| 202 | backdrop: None, | 230 | backdrop: None, |
| 203 | round: None, | 231 | round: None, |
| 204 | motion: None, | 232 | motion: None, |
| 205 | primitives, | 233 | primitives, |
| 206 | }; | 234 | }; |
| 235 | // Rule lines run to the bottom margin, the page's content to where the next | ||
| 236 | // sheet takes it up. | ||
| 237 | let page = |primitives, bottom: f32| { | ||
| 238 | let clip = [0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale]; | ||
| 239 | layer(origin, Some(clip), primitives) | ||
| 240 | }; | ||
| 241 | let mut layers = vec![page(rules, top + pagination.band), page(primitives, bottom)]; | ||
| 207 | // OneNote shrinks the footer with the page, towards the paper's bottom left. | 242 | // OneNote shrinks the footer with the page, towards the paper's bottom left. |
| 208 | let footer = Layer { | 243 | if let Some(footer) = footer.next() { |
| 209 | scale, | 244 | let at = [ |
| 210 | origin: [ | ||
| 211 | paper[0] / 2.0 * scale, | 245 | paper[0] / 2.0 * scale, |
| 212 | paper[1] - 2.0 - (1.0 - scale) * 16.0, | 246 | paper[1] - 2.0 - (1.0 - scale) * 16.0, |
| 213 | ], | 247 | ]; |
| 214 | clip: None, | 248 | layers.push(layer(at, None, footer)); |
| 215 | backdrop: None, | 249 | } |
| 216 | round: None, | ||
| 217 | motion: None, | ||
| 218 | primitives: footer.next().expect("Each sheet has a footer"), | ||
| 219 | }; | ||
| 220 | sheets.push(Sheet { | 250 | sheets.push(Sheet { |
| 221 | size: paper, | 251 | size: paper, |
| 222 | layers: vec![ | 252 | layers, |
| 223 | page(rules, top + pagination.band), | ||
| 224 | page(primitives, bottom), | ||
| 225 | footer, | ||
| 226 | ], | ||
| 227 | }); | 253 | }); |
| 228 | } | 254 | } |
| 229 | } | 255 | } |
| ... | @@ -236,6 +262,13 @@ mod tests { | ... | @@ -236,6 +262,13 @@ mod tests { |
| 236 | use onestore::{RevisionIndex, Store, document::Document}; | 262 | use onestore::{RevisionIndex, Store, document::Document}; |
| 237 | use std::collections::HashMap; | 263 | use std::collections::HashMap; |
| 238 | 264 | ||
| 265 | /// OneNote 2010's defaults on Letter paper. | ||
| 266 | const ONENOTE: Setup = Setup { | ||
| 267 | paper: LETTER, | ||
| 268 | fit_width: true, | ||
| 269 | footer: Footer::SectionAndPage, | ||
| 270 | }; | ||
| 271 | |||
| 239 | fn section(path: &str) -> Vec<Page> { | 272 | fn section(path: &str) -> Vec<Page> { |
| 240 | let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap(); | 273 | let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap(); |
| 241 | let store = Store::parse(&bytes).unwrap(); | 274 | let store = Store::parse(&bytes).unwrap(); |
| ... | @@ -456,7 +489,13 @@ mod tests { | ... | @@ -456,7 +489,13 @@ mod tests { |
| 456 | let rows: Vec<[f32; 2]> = (0..60) | 489 | let rows: Vec<[f32; 2]> = (0..60) |
| 457 | .map(|line| [90.0 + line as f32 * 20.0, 110.0 + line as f32 * 20.0]) | 490 | .map(|line| [90.0 + line as f32 * 20.0, 110.0 + line as f32 * 20.0]) |
| 458 | .collect(); | 491 | .collect(); |
| 459 | let pagination = Pagination::new([36.0, 14.4, 400.0, 1290.0], &rows, [36.0, 14.4], LETTER); | 492 | let pagination = Pagination::new( |
| 493 | [36.0, 14.4, 400.0, 1290.0], | ||
| 494 | &rows, | ||
| 495 | [36.0, 14.4], | ||
| 496 | LETTER, | ||
| 497 | true, | ||
| 498 | ); | ||
| 460 | assert_eq!(pagination.scale, 1.0); | 499 | assert_eq!(pagination.scale, 1.0); |
| 461 | // 14.4 + 720 cuts the line from 730 to 750, which starts the next sheet. | 500 | // 14.4 + 720 cuts the line from 730 to 750, which starts the next sheet. |
| 462 | assert_eq!(pagination.tops, [14.4, 730.0]); | 501 | assert_eq!(pagination.tops, [14.4, 730.0]); |
| ... | @@ -471,6 +510,7 @@ mod tests { | ... | @@ -471,6 +510,7 @@ mod tests { |
| 471 | &[[20.0, 1000.0]], | 510 | &[[20.0, 1000.0]], |
| 472 | [36.0, 14.4], | 511 | [36.0, 14.4], |
| 473 | LETTER, | 512 | LETTER, |
| 513 | true, | ||
| 474 | ); | 514 | ); |
| 475 | assert_eq!(pagination.tops, [14.4, 734.4]); | 515 | assert_eq!(pagination.tops, [14.4, 734.4]); |
| 476 | } | 516 | } |
| ... | @@ -490,6 +530,7 @@ mod tests { | ... | @@ -490,6 +530,7 @@ mod tests { |
| 490 | &[], | 530 | &[], |
| 491 | [36.0, 14.4], | 531 | [36.0, 14.4], |
| 492 | [612.36, 790.92], | 532 | [612.36, 790.92], |
| 533 | true, | ||
| 493 | ) | 534 | ) |
| 494 | .scale; | 535 | .scale; |
| 495 | assert!( | 536 | assert!( |
| ... | @@ -504,10 +545,9 @@ mod tests { | ... | @@ -504,10 +545,9 @@ mod tests { |
| 504 | #[test] | 545 | #[test] |
| 505 | fn a_section_prints_on_onenotes_sheets() { | 546 | fn a_section_prints_on_onenotes_sheets() { |
| 506 | let pdf = pdf( | 547 | let pdf = pdf( |
| 507 | section("corpus/print/native/Print.one"), | 548 | vec![("Print".into(), section("corpus/print/native/Print.one"))], |
| 508 | &mut TextEngine::default(), | 549 | &mut TextEngine::default(), |
| 509 | LETTER, | 550 | &ONENOTE, |
| 510 | "Print", | ||
| 511 | ) | 551 | ) |
| 512 | .unwrap(); | 552 | .unwrap(); |
| 513 | let sheets = sheets(&pdf); | 553 | let sheets = sheets(&pdf); |
| ... | @@ -538,10 +578,12 @@ mod tests { | ... | @@ -538,10 +578,12 @@ mod tests { |
| 538 | #[test] | 578 | #[test] |
| 539 | fn ruled_pages_and_art_print_on_onenotes_sheets() { | 579 | fn ruled_pages_and_art_print_on_onenotes_sheets() { |
| 540 | let pdf = pdf( | 580 | let pdf = pdf( |
| 541 | section("corpus/page-background/candidate/Rules.one"), | 581 | vec![( |
| 582 | "Rules".into(), | ||
| 583 | section("corpus/page-background/candidate/Rules.one"), | ||
| 584 | )], | ||
| 542 | &mut TextEngine::default(), | 585 | &mut TextEngine::default(), |
| 543 | LETTER, | 586 | &ONENOTE, |
| 544 | "Rules", | ||
| 545 | ) | 587 | ) |
| 546 | .unwrap(); | 588 | .unwrap(); |
| 547 | let sheets = sheets(&pdf); | 589 | let sheets = sheets(&pdf); |
| ... | @@ -550,4 +592,44 @@ mod tests { | ... | @@ -550,4 +592,44 @@ mod tests { |
| 550 | assert!(sheets[9].1.contains("Rules Page 10")); | 592 | assert!(sheets[9].1.contains("Rules Page 10")); |
| 551 | assert!(!sheets[9].1.contains("VeryLargeGrid")); | 593 | assert!(!sheets[9].1.contains("VeryLargeGrid")); |
| 552 | } | 594 | } |
| 595 | |||
| 596 | #[test] | ||
| 597 | fn unscaled_pages_keep_their_size() { | ||
| 598 | let pagination = Pagination::new( | ||
| 599 | [36.0, 14.4, 1200.0, 100.0], | ||
| 600 | &[], | ||
| 601 | [36.0, 14.4], | ||
| 602 | LETTER, | ||
| 603 | false, | ||
| 604 | ); | ||
| 605 | assert_eq!(pagination.scale, 1.0); | ||
| 606 | } | ||
| 607 | |||
| 608 | /// Sheets number on across sections, each footer naming its page's section, or say | ||
| 609 | /// what the footer choice asks for. | ||
| 610 | #[test] | ||
| 611 | fn footers_say_what_was_chosen() { | ||
| 612 | let print = section("corpus/print/native/Print.one"); | ||
| 613 | let rules = section("corpus/page-background/candidate/Rules.one"); | ||
| 614 | let two = vec![ | ||
| 615 | ("Print".into(), print[2..3].to_vec()), | ||
| 616 | ("Rules".into(), rules[..1].to_vec()), | ||
| 617 | ]; | ||
| 618 | let text = |footer| { | ||
| 619 | let setup = Setup { footer, ..ONENOTE }; | ||
| 620 | sheets(&pdf(two.clone(), &mut TextEngine::default(), &setup).unwrap()) | ||
| 621 | .into_iter() | ||
| 622 | .map(|(_, text)| text) | ||
| 623 | .collect::<Vec<_>>() | ||
| 624 | }; | ||
| 625 | let both = text(Footer::SectionAndPage); | ||
| 626 | assert!(both[0].contains("Print Page 1") && both[1].contains("Rules Page 2")); | ||
| 627 | let pages = text(Footer::Page); | ||
| 628 | assert!(pages[1].contains("Page 2") && !pages[1].contains("Rules")); | ||
| 629 | assert!( | ||
| 630 | text(Footer::Section)[1].contains("Rules") | ||
| 631 | && !text(Footer::Section)[1].contains("Page 2") | ||
| 632 | ); | ||
| 633 | assert!(!text(Footer::None)[1].contains("Rules")); | ||
| 634 | } | ||
| 553 | } | 635 | } |
crates/canvas/src/search.rs+39-1| ... | @@ -203,10 +203,48 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, | ... | @@ -203,10 +203,48 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, |
| 203 | } | 203 | } |
| 204 | } | 204 | } |
| 205 | 205 | ||
| 206 | /// A page's text outside its title, a paragraph to a line. | 206 | /// The text OneNote recognised in each of the page's pictures, or printed on a printout's |
| 207 | /// pages, in page order. | ||
| 208 | fn picture_text(page: &Page) -> Vec<&str> { | ||
| 209 | fn walk<'a>(paragraphs: &'a [PageParagraph], out: &mut Vec<&'a str>) { | ||
| 210 | for paragraph in paragraphs { | ||
| 211 | match &paragraph.content { | ||
| 212 | ParagraphContent::Image(image) => { | ||
| 213 | out.extend(image.text.as_ref().map(|text| text.text.as_str())) | ||
| 214 | } | ||
| 215 | ParagraphContent::Table(table) => { | ||
| 216 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | ||
| 217 | walk(&cell.paragraphs, out); | ||
| 218 | } | ||
| 219 | } | ||
| 220 | _ => {} | ||
| 221 | } | ||
| 222 | } | ||
| 223 | } | ||
| 224 | let mut out = Vec::new(); | ||
| 225 | for object in &page.objects { | ||
| 226 | match object { | ||
| 227 | PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), | ||
| 228 | PageObject::Image(image) => { | ||
| 229 | out.extend(image.text.as_ref().map(|text| text.text.as_str())) | ||
| 230 | } | ||
| 231 | _ => {} | ||
| 232 | } | ||
| 233 | } | ||
| 234 | out | ||
| 235 | } | ||
| 236 | |||
| 237 | /// A page's text outside its title, a paragraph to a line, then the text in its pictures, | ||
| 238 | /// which OneNote searches too. | ||
| 207 | pub fn page_text(page: &Page) -> String { | 239 | pub fn page_text(page: &Page) -> String { |
| 208 | let mut out = Vec::new(); | 240 | let mut out = Vec::new(); |
| 209 | text_paragraphs(page, |_, text| out.push(shown(text))); | 241 | text_paragraphs(page, |_, text| out.push(shown(text))); |
| 242 | out.extend( | ||
| 243 | picture_text(page) | ||
| 244 | .into_iter() | ||
| 245 | .flat_map(str::lines) | ||
| 246 | .map(str::to_owned), | ||
| 247 | ); | ||
| 210 | out.retain(|line| !line.trim().is_empty()); | 248 | out.retain(|line| !line.trim().is_empty()); |
| 211 | out.join("\n") | 249 | out.join("\n") |
| 212 | } | 250 | } |
crates/canvas/src/template.rs+3| ... | @@ -47,6 +47,9 @@ impl Template { | ... | @@ -47,6 +47,9 @@ impl Template { |
| 47 | alt: None, | 47 | alt: None, |
| 48 | background: true, | 48 | background: true, |
| 49 | printout: None, | 49 | printout: None, |
| 50 | tags: Vec::new(), | ||
| 51 | link: None, | ||
| 52 | text: None, | ||
| 50 | }) | 53 | }) |
| 51 | }) | 54 | }) |
| 52 | .collect() | 55 | .collect() |
crates/canvas/tests/attachment_insert.rs+1| ... | @@ -58,6 +58,7 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment { | ... | @@ -58,6 +58,7 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment { |
| 58 | bytes: Some(bytes.into()), | 58 | bytes: Some(bytes.into()), |
| 59 | preview, | 59 | preview, |
| 60 | recording: None, | 60 | recording: None, |
| 61 | tags: Vec::new(), | ||
| 61 | } | 62 | } |
| 62 | } | 63 | } |
| 63 | 64 |
crates/canvas/tests/default_font.rs created+122| ... | @@ -0,0 +1,122 @@ | ||
| 1 | //! OneNote's Default font as the editor stores it: a new page's title in its face and colour, | ||
| 2 | //! and new text in the page's `p` quick style in its face, size and colour. `CANVAS_DEFAULT_FONT_EXPORT` | ||
| 3 | //! names a directory receiving the section as a notebook, for a cold reopen in OneNote 2010 | ||
| 4 | //! (`corpus/default-font`). | ||
| 5 | |||
| 6 | use canvas::{ | ||
| 7 | editor::{CanvasEditor, DefaultFont}, | ||
| 8 | layout::TextEngine, | ||
| 9 | }; | ||
| 10 | use onestore::{ | ||
| 11 | Arena, PageCreation, Section, Store, | ||
| 12 | document::Kind, | ||
| 13 | op::{Edit, Op, SectionOp}, | ||
| 14 | page::{PageObject, ParagraphContent}, | ||
| 15 | }; | ||
| 16 | |||
| 17 | /// Office's standard Blue, COLORREF, which OneNote 2010 stores as `DefaultFontColor`. | ||
| 18 | const BLUE: u32 = 0xc07000; | ||
| 19 | |||
| 20 | #[test] | ||
| 21 | fn new_pages_and_text_take_the_default_font() { | ||
| 22 | let source = onestore::create_section("fonts.one", "Calibri page", "Author").unwrap(); | ||
| 23 | let arena = Arena::default(); | ||
| 24 | let mut section = Section::open(&arena, source.clone()).unwrap(); | ||
| 25 | let creation = PageCreation::new(None, Some("Georgia title"), "Author") | ||
| 26 | .unwrap() | ||
| 27 | .titled_in("Georgia", Some(BLUE)) | ||
| 28 | .unwrap(); | ||
| 29 | let mut at = 133_000_000_000_000_000; | ||
| 30 | section | ||
| 31 | .apply( | ||
| 32 | "Author", | ||
| 33 | &Edit { | ||
| 34 | at, | ||
| 35 | ops: vec![Op::Section(SectionOp::Create(creation))], | ||
| 36 | }, | ||
| 37 | ) | ||
| 38 | .unwrap(); | ||
| 39 | let (space, ..) = section.pages().unwrap()[1].clone(); | ||
| 40 | let mut engine = TextEngine::default(); | ||
| 41 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); | ||
| 42 | editor.default_font = DefaultFont { | ||
| 43 | face: "Georgia".into(), | ||
| 44 | size: 14.0, | ||
| 45 | color: Some(BLUE), | ||
| 46 | }; | ||
| 47 | for (text, y) in [("Georgia body", 120.0), ("Georgia again", 200.0)] { | ||
| 48 | editor.place_caret(&mut engine, [72.0, y], 240.0).unwrap(); | ||
| 49 | editor.insert(&mut engine, text).unwrap(); | ||
| 50 | let ops = editor.take_ops().unwrap(); | ||
| 51 | at += 10_000_000; | ||
| 52 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); | ||
| 53 | section.apply("Author", &Edit { at, ops }).unwrap(); | ||
| 54 | } | ||
| 55 | let mut image = source; | ||
| 56 | section.seal().unwrap().unwrap().apply(&mut image).unwrap(); | ||
| 57 | |||
| 58 | let arena = Arena::default(); | ||
| 59 | let reopened = Section::open(&arena, image.clone()).unwrap(); | ||
| 60 | let page = reopened.page(space).unwrap(); | ||
| 61 | let style = |id| { | ||
| 62 | let definition = &page.definitions[&id]; | ||
| 63 | let Kind::Style { name, .. } = &definition.kind else { | ||
| 64 | panic!("{definition:?}") | ||
| 65 | }; | ||
| 66 | ( | ||
| 67 | name.as_deref().unwrap_or_default().to_owned(), | ||
| 68 | definition.format.font.clone().unwrap_or_default(), | ||
| 69 | definition.format.font_size, | ||
| 70 | definition.format.color, | ||
| 71 | ) | ||
| 72 | }; | ||
| 73 | let title = page | ||
| 74 | .objects | ||
| 75 | .iter() | ||
| 76 | .find_map(|object| match object { | ||
| 77 | PageObject::Title(title) => Some(title), | ||
| 78 | _ => None, | ||
| 79 | }) | ||
| 80 | .unwrap(); | ||
| 81 | let titled = title | ||
| 82 | .outlines | ||
| 83 | .iter() | ||
| 84 | .flat_map(|outline| &outline.paragraphs) | ||
| 85 | .filter_map(|paragraph| paragraph.style) | ||
| 86 | .map(style) | ||
| 87 | .find(|(name, ..)| name == "PageTitle"); | ||
| 88 | assert_eq!( | ||
| 89 | titled, | ||
| 90 | Some(("PageTitle".into(), "Georgia".into(), Some(17.0), Some(BLUE))) | ||
| 91 | ); | ||
| 92 | let bodies: Vec<_> = page | ||
| 93 | .objects | ||
| 94 | .iter() | ||
| 95 | .filter_map(|object| match object { | ||
| 96 | PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs[0]), | ||
| 97 | _ => None, | ||
| 98 | }) | ||
| 99 | .collect(); | ||
| 100 | assert_eq!(bodies.len(), 2); | ||
| 101 | for body in &bodies { | ||
| 102 | assert!(matches!(body.content, ParagraphContent::Text(_))); | ||
| 103 | assert_eq!( | ||
| 104 | style(body.style.unwrap()), | ||
| 105 | ("p".into(), "Georgia".into(), Some(14.0), Some(BLUE)) | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | assert_eq!(bodies[0].style, bodies[1].style, "one p per font"); | ||
| 109 | |||
| 110 | if let Some(directory) = std::env::var_os("CANVAS_DEFAULT_FONT_EXPORT") { | ||
| 111 | let directory = std::path::PathBuf::from(directory); | ||
| 112 | std::fs::create_dir_all(&directory).unwrap(); | ||
| 113 | std::fs::write(directory.join("fonts.one"), &image).unwrap(); | ||
| 114 | let file_id = Store::parse(&image).unwrap().header.file_id; | ||
| 115 | std::fs::write( | ||
| 116 | directory.join("Open Notebook.onetoc2"), | ||
| 117 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("fonts.one", file_id)]) | ||
| 118 | .unwrap(), | ||
| 119 | ) | ||
| 120 | .unwrap(); | ||
| 121 | } | ||
| 122 | } | ||
crates/canvas/tests/links_equations.rs+13-3| ... | @@ -874,11 +874,18 @@ fn selections_into_equation_objects_take_them_whole() { | ... | @@ -874,11 +874,18 @@ fn selections_into_equation_objects_take_them_whole() { |
| 874 | 874 | ||
| 875 | // Enter in "Type equation here." leaves the placeholder's paragraph empty above a new one. | 875 | // Enter in "Type equation here." leaves the placeholder's paragraph empty above a new one. |
| 876 | let placeholder = find(&editor, "equation here"); | 876 | let placeholder = find(&editor, "equation here"); |
| 877 | select(&mut editor, placeholder.paragraph, placeholder.offset..placeholder.offset); | 877 | select( |
| 878 | &mut editor, | ||
| 879 | placeholder.paragraph, | ||
| 880 | placeholder.offset..placeholder.offset, | ||
| 881 | ); | ||
| 878 | editor.enter(engine, false).unwrap(); | 882 | editor.enter(engine, false).unwrap(); |
| 879 | save(&mut editor); | 883 | save(&mut editor); |
| 880 | let after = texts(&editor); | 884 | let after = texts(&editor); |
| 881 | assert_eq!(after[placeholder.paragraph..=placeholder.paragraph + 1], ["", ""]); | 885 | assert_eq!( |
| 886 | after[placeholder.paragraph..=placeholder.paragraph + 1], | ||
| 887 | ["", ""] | ||
| 888 | ); | ||
| 882 | assert_eq!(caret(&editor).paragraph, placeholder.paragraph + 1); | 889 | assert_eq!(caret(&editor).paragraph, placeholder.paragraph + 1); |
| 883 | 890 | ||
| 884 | // From inside a square root into a cube root: both paragraphs go whole. | 891 | // From inside a square root into a cube root: both paragraphs go whole. |
| ... | @@ -926,7 +933,10 @@ fn selections_into_equation_objects_take_them_whole() { | ... | @@ -926,7 +933,10 @@ fn selections_into_equation_objects_take_them_whole() { |
| 926 | .utf16_offset(texts(&editor)[end].len()) | 933 | .utf16_offset(texts(&editor)[end].len()) |
| 927 | .unwrap(); | 934 | .unwrap(); |
| 928 | select(&mut editor, end, offset..offset); | 935 | select(&mut editor, end, offset..offset); |
| 929 | for (before, linear, after) in [("One ", "a/b+c ", " two"), ("Three ", "\\sqrt x+y ", " four")] { | 936 | for (before, linear, after) in [ |
| 937 | ("One ", "a/b+c ", " two"), | ||
| 938 | ("Three ", "\\sqrt x+y ", " four"), | ||
| 939 | ] { | ||
| 930 | typed(&mut editor, engine, before); | 940 | typed(&mut editor, engine, before); |
| 931 | editor.insert_equation(engine).unwrap(); | 941 | editor.insert_equation(engine).unwrap(); |
| 932 | typed(&mut editor, engine, linear); | 942 | typed(&mut editor, engine, linear); |
crates/canvas/tests/ops_differential.rs+196-8| ... | @@ -31,7 +31,7 @@ const ART: [u8; 67] = [ | ... | @@ -31,7 +31,7 @@ const ART: [u8; 67] = [ |
| 31 | #[path = "../../onestore/tests/support/sweep.rs"] | 31 | #[path = "../../onestore/tests/support/sweep.rs"] |
| 32 | mod sweep; | 32 | mod sweep; |
| 33 | 33 | ||
| 34 | const SECTIONS: [&str; 19] = [ | 34 | const SECTIONS: [&str; 20] = [ |
| 35 | "corpus/outline-edit/before/notebook/synthetic.one", | 35 | "corpus/outline-edit/before/notebook/synthetic.one", |
| 36 | "corpus/paragraph-edit/before/notebook/synthetic.one", | 36 | "corpus/paragraph-edit/before/notebook/synthetic.one", |
| 37 | "corpus/outline-edit/tree/before/notebook/synthetic.one", | 37 | "corpus/outline-edit/tree/before/notebook/synthetic.one", |
| ... | @@ -51,6 +51,7 @@ const SECTIONS: [&str; 19] = [ | ... | @@ -51,6 +51,7 @@ const SECTIONS: [&str; 19] = [ |
| 51 | "corpus/math-edit/native-enter/notebook/links.one", | 51 | "corpus/math-edit/native-enter/notebook/links.one", |
| 52 | "corpus/paragraph-format/cold/notebook/synthetic.one", | 52 | "corpus/paragraph-format/cold/notebook/synthetic.one", |
| 53 | "corpus/canvas/baseline-anchors.one", | 53 | "corpus/canvas/baseline-anchors.one", |
| 54 | "corpus/styles/onenote/Styles.one", | ||
| 54 | ]; | 55 | ]; |
| 55 | 56 | ||
| 56 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] | 57 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] |
| ... | @@ -130,6 +131,15 @@ enum Kind { | ... | @@ -130,6 +131,15 @@ enum Kind { |
| 130 | /// Lassoed drawings moved or deleted. | 131 | /// Lassoed drawings moved or deleted. |
| 131 | LassoMove, | 132 | LassoMove, |
| 132 | LassoDelete, | 133 | LassoDelete, |
| 134 | /// A style from the Styles gallery on the caret's paragraph or a selection. | ||
| 135 | Style, | ||
| 136 | /// The stored page's styles restyled to a theme, as a theme reaches a page, and the | ||
| 137 | /// editor refreshed from what is stored. | ||
| 138 | Theme, | ||
| 139 | /// A two-by-two table at the caret with a line typed into its first cell, widening it. | ||
| 140 | Table, | ||
| 141 | /// The first table's first column dragged 30 points wider, locking it. | ||
| 142 | ColumnResize, | ||
| 133 | Undo, | 143 | Undo, |
| 134 | Redo, | 144 | Redo, |
| 135 | } | 145 | } |
| ... | @@ -267,6 +277,74 @@ const INK_PLANS: [&[Kind]; 6] = [ | ... | @@ -267,6 +277,74 @@ const INK_PLANS: [&[Kind]; 6] = [ |
| 267 | &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo], | 277 | &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo], |
| 268 | ]; | 278 | ]; |
| 269 | 279 | ||
| 280 | /// Gallery styles and themes, in plans of their own so the random edits' sequences stay as | ||
| 281 | /// they were. | ||
| 282 | /// Tables whose columns fit as they are typed in and dragged, in plans of their own so the | ||
| 283 | /// random edits' sequences stay as they were. | ||
| 284 | const TABLE_PLANS: [&[Kind]; 2] = [ | ||
| 285 | &[Kind::Table, Kind::Undo, Kind::Redo, Kind::TypeOn], | ||
| 286 | &[ | ||
| 287 | Kind::Table, | ||
| 288 | Kind::ColumnResize, | ||
| 289 | Kind::TypeOn, | ||
| 290 | Kind::Undo, | ||
| 291 | Kind::Undo, | ||
| 292 | Kind::Redo, | ||
| 293 | ], | ||
| 294 | ]; | ||
| 295 | |||
| 296 | const STYLE_PLANS: [&[Kind]; 5] = [ | ||
| 297 | &[Kind::Style, Kind::Undo, Kind::Redo], | ||
| 298 | &[Kind::Style, Kind::EnterEnd, Kind::Type, Kind::Undo], | ||
| 299 | &[Kind::Theme, Kind::Style, Kind::TypeEnd, Kind::Undo], | ||
| 300 | &[Kind::Style, Kind::Theme, Kind::EnterEnd, Kind::Type], | ||
| 301 | &[Kind::Theme, Kind::Theme, Kind::Style, Kind::Redo], | ||
| 302 | ]; | ||
| 303 | |||
| 304 | /// A theme's styles for the sweep: OneNote's names in another look, headings followed by | ||
| 305 | /// Normal; `alternate` picks one of two, so a second restyle changes the page again. | ||
| 306 | fn theme(alternate: bool) -> BTreeMap<String, onestore::page::Definition> { | ||
| 307 | use onestore::document::Format; | ||
| 308 | [ | ||
| 309 | "h1", | ||
| 310 | "h2", | ||
| 311 | "h3", | ||
| 312 | "PageTitle", | ||
| 313 | "cite", | ||
| 314 | "blockquote", | ||
| 315 | "code", | ||
| 316 | "p", | ||
| 317 | ] | ||
| 318 | .into_iter() | ||
| 319 | .enumerate() | ||
| 320 | .map(|(at, name)| { | ||
| 321 | let definition = onestore::page::Definition { | ||
| 322 | kind: Node::Style { | ||
| 323 | name: Some(name.into()), | ||
| 324 | next: name.starts_with('h').then(|| "p".into()), | ||
| 325 | }, | ||
| 326 | format: Format { | ||
| 327 | bold: Some(name.starts_with('h')), | ||
| 328 | italic: Some(name == "blockquote"), | ||
| 329 | underline: Some(false), | ||
| 330 | strike: Some(false), | ||
| 331 | superscript: Some(false), | ||
| 332 | subscript: Some(false), | ||
| 333 | font: Some(if alternate { "Georgia" } else { "Arial" }.into()), | ||
| 334 | font_size: Some(10.0 + at as f32), | ||
| 335 | color: Some(if alternate { 0x0022_3344 } else { 0xff00_0000 }), | ||
| 336 | highlight: Some(0xff00_0000), | ||
| 337 | space_before: Some(if alternate { 6.0 } else { 0.0 }), | ||
| 338 | space_after: Some(0.0), | ||
| 339 | line_spacing: Some(0.0), | ||
| 340 | ..Format::default() | ||
| 341 | }, | ||
| 342 | }; | ||
| 343 | (name.to_owned(), definition) | ||
| 344 | }) | ||
| 345 | .collect() | ||
| 346 | } | ||
| 347 | |||
| 270 | /// Page colour, rule lines and art undone and redone across each other. | 348 | /// Page colour, rule lines and art undone and redone across each other. |
| 271 | const PAPER_PLANS: [&[Kind]; 3] = [ | 349 | const PAPER_PLANS: [&[Kind]; 3] = [ |
| 272 | &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo], | 350 | &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo], |
| ... | @@ -327,6 +405,7 @@ fn perform( | ... | @@ -327,6 +405,7 @@ fn perform( |
| 327 | Kind::Undo | 405 | Kind::Undo |
| 328 | | Kind::Redo | 406 | | Kind::Redo |
| 329 | | Kind::TypeOn | 407 | | Kind::TypeOn |
| 408 | | Kind::ColumnResize | ||
| 330 | | Kind::PlaceImage | 409 | | Kind::PlaceImage |
| 331 | | Kind::RemoveImage | 410 | | Kind::RemoveImage |
| 332 | | Kind::CreateOutline | 411 | | Kind::CreateOutline |
| ... | @@ -468,6 +547,29 @@ fn perform( | ... | @@ -468,6 +547,29 @@ fn perform( |
| 468 | let id = outline.id; | 547 | let id = outline.id; |
| 469 | editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok() | 548 | editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok() |
| 470 | } | 549 | } |
| 550 | Kind::Table => { | ||
| 551 | place(editor, middle) | ||
| 552 | && editor.insert_table(engine, 2, 2).is_ok() | ||
| 553 | && editor | ||
| 554 | .insert(engine, "A line long enough to widen its column") | ||
| 555 | .is_ok() | ||
| 556 | } | ||
| 557 | Kind::ColumnResize => { | ||
| 558 | let table = editor | ||
| 559 | .active_outline() | ||
| 560 | .document() | ||
| 561 | .nodes() | ||
| 562 | .iter() | ||
| 563 | .find_map(|node| match &node.content { | ||
| 564 | onestore::page::ParagraphContent::Table(table) => { | ||
| 565 | Some((table.id, table.columns[0].width)) | ||
| 566 | } | ||
| 567 | _ => None, | ||
| 568 | }); | ||
| 569 | table.is_some_and(|(id, width)| { | ||
| 570 | editor.resize_column(engine, id, 0, width + 30.0).is_ok() | ||
| 571 | }) | ||
| 572 | } | ||
| 471 | Kind::ResizeOutline => { | 573 | Kind::ResizeOutline => { |
| 472 | let width = editor.active_outline().wrap_width(); | 574 | let width = editor.active_outline().wrap_width(); |
| 473 | editor.resize(engine, width + 72.0).is_ok() | 575 | editor.resize(engine, width + 72.0).is_ok() |
| ... | @@ -602,6 +704,9 @@ fn perform( | ... | @@ -602,6 +704,9 @@ fn perform( |
| 602 | alt: None, | 704 | alt: None, |
| 603 | background: true, | 705 | background: true, |
| 604 | printout: None, | 706 | printout: None, |
| 707 | tags: Vec::new(), | ||
| 708 | link: None, | ||
| 709 | text: None, | ||
| 605 | }; | 710 | }; |
| 606 | let shown = editor.page().unwrap().objects.iter().any(|object| { | 711 | let shown = editor.page().unwrap().objects.iter().any(|object| { |
| 607 | matches!(object, onestore::page::PageObject::Image(image) if image.background) | 712 | matches!(object, onestore::page::PageObject::Image(image) if image.background) |
| ... | @@ -630,6 +735,7 @@ fn perform( | ... | @@ -630,6 +735,7 @@ fn perform( |
| 630 | kind: 1, | 735 | kind: 1, |
| 631 | duration_ms: Some(1000), | 736 | duration_ms: Some(1000), |
| 632 | }), | 737 | }), |
| 738 | tags: Vec::new(), | ||
| 633 | }; | 739 | }; |
| 634 | editor.insert(engine, "noted").is_ok() | 740 | editor.insert(engine, "noted").is_ok() |
| 635 | && editor.finish_recording(engine, file).is_ok() | 741 | && editor.finish_recording(engine, file).is_ok() |
| ... | @@ -645,6 +751,7 @@ fn perform( | ... | @@ -645,6 +751,7 @@ fn perform( |
| 645 | bytes: Some(std::sync::Arc::from(b"notes".as_slice())), | 751 | bytes: Some(std::sync::Arc::from(b"notes".as_slice())), |
| 646 | preview: Some(std::sync::Arc::from(ART.as_slice())), | 752 | preview: Some(std::sync::Arc::from(ART.as_slice())), |
| 647 | recording: None, | 753 | recording: None, |
| 754 | tags: Vec::new(), | ||
| 648 | }; | 755 | }; |
| 649 | place(editor, middle) && editor.insert_attachment(engine, file).is_ok() | 756 | place(editor, middle) && editor.insert_attachment(engine, file).is_ok() |
| 650 | } | 757 | } |
| ... | @@ -658,6 +765,7 @@ fn perform( | ... | @@ -658,6 +765,7 @@ fn perform( |
| 658 | bytes: Some(std::sync::Arc::from(b"float".as_slice())), | 765 | bytes: Some(std::sync::Arc::from(b"float".as_slice())), |
| 659 | preview: Some(std::sync::Arc::from(ART.as_slice())), | 766 | preview: Some(std::sync::Arc::from(ART.as_slice())), |
| 660 | recording: None, | 767 | recording: None, |
| 768 | tags: Vec::new(), | ||
| 661 | }; | 769 | }; |
| 662 | let x = 36.0 + 18.0 * random.below(20) as f32; | 770 | let x = 36.0 + 18.0 * random.below(20) as f32; |
| 663 | editor.place_caret(engine, [x, 1440.0], 240.0).is_ok() | 771 | editor.place_caret(engine, [x, 1440.0], 240.0).is_ok() |
| ... | @@ -778,6 +886,17 @@ fn perform( | ... | @@ -778,6 +886,17 @@ fn perform( |
| 778 | _ => editor.delete_ink(&[ink.id]).is_ok(), | 886 | _ => editor.delete_ink(&[ink.id]).is_ok(), |
| 779 | } | 887 | } |
| 780 | } | 888 | } |
| 889 | Kind::Style => { | ||
| 890 | let sheet = theme(random.below(2) == 0); | ||
| 891 | let names = ["h1", "h2", "blockquote", "code", "p"]; | ||
| 892 | let style = sheet[names[random.below(names.len())]].clone(); | ||
| 893 | (if random.below(2) == 0 { | ||
| 894 | place(editor, middle) | ||
| 895 | } else { | ||
| 896 | range(editor, random) | ||
| 897 | }) && editor.format(engine, Formatting::Style(style)).is_ok() | ||
| 898 | } | ||
| 899 | Kind::Theme => unreachable!("The sweep restyles what is stored"), | ||
| 781 | Kind::Undo => editor.undo(engine).unwrap_or(false), | 900 | Kind::Undo => editor.undo(engine).unwrap_or(false), |
| 782 | Kind::Redo => editor.redo(engine).unwrap_or(false), | 901 | Kind::Redo => editor.redo(engine).unwrap_or(false), |
| 783 | } | 902 | } |
| ... | @@ -843,6 +962,66 @@ fn comparable(page: &Page) -> serde_json::Value { | ... | @@ -843,6 +962,66 @@ fn comparable(page: &Page) -> serde_json::Value { |
| 843 | } | 962 | } |
| 844 | 963 | ||
| 845 | impl Stored<'_> { | 964 | impl Stored<'_> { |
| 965 | /// Restyles the stored page to a theme, as opening a themed page does, checks it against | ||
| 966 | /// the model and the sealed image, and refreshes the editor from it, which then shows it. | ||
| 967 | fn theme( | ||
| 968 | &mut self, | ||
| 969 | editor: &mut CanvasEditor, | ||
| 970 | engine: &mut TextEngine, | ||
| 971 | alternate: bool, | ||
| 972 | ) -> Option<Outcome> { | ||
| 973 | self.at += 10_000_000; | ||
| 974 | let before = self.section.page(self.space).unwrap(); | ||
| 975 | let sheet = theme(alternate); | ||
| 976 | let ops = onestore::op::restyle(&before, &sheet).unwrap(); | ||
| 977 | if ops.is_empty() { | ||
| 978 | return None; | ||
| 979 | } | ||
| 980 | editor.styles = sheet; | ||
| 981 | let edit = Edit { | ||
| 982 | at: self.at, | ||
| 983 | ops: ops | ||
| 984 | .iter() | ||
| 985 | .map(|op| Op::Page { | ||
| 986 | space: self.space, | ||
| 987 | op: op.clone(), | ||
| 988 | }) | ||
| 989 | .collect(), | ||
| 990 | }; | ||
| 991 | if let Err(error) = self.section.apply("Sweep", &edit) { | ||
| 992 | return Some(Outcome::Differs(format!("a theme is refused: {error}"))); | ||
| 993 | } | ||
| 994 | let stored = self.section.page(self.space).unwrap(); | ||
| 995 | let mut predicted = before; | ||
| 996 | for op in &ops { | ||
| 997 | onestore::op::predict(&mut predicted, op).unwrap(); | ||
| 998 | } | ||
| 999 | if comparable(&predicted) != comparable(&stored) { | ||
| 1000 | return Some(Outcome::Differs(format!( | ||
| 1001 | "the model predicts a theme otherwise: {}", | ||
| 1002 | difference(&stored, &predicted) | ||
| 1003 | ))); | ||
| 1004 | } | ||
| 1005 | self.section.seal().unwrap(); | ||
| 1006 | let arena = Arena::default(); | ||
| 1007 | let reread = Section::open(&arena, self.section.image()) | ||
| 1008 | .unwrap() | ||
| 1009 | .page(self.space) | ||
| 1010 | .unwrap(); | ||
| 1011 | if reread != stored { | ||
| 1012 | return Some(Outcome::Differs("a theme reads back otherwise".into())); | ||
| 1013 | } | ||
| 1014 | editor.refresh(stored.clone(), engine).unwrap(); | ||
| 1015 | Some(if reads_as(&stored, &editor.page().unwrap()) { | ||
| 1016 | Outcome::Same | ||
| 1017 | } else { | ||
| 1018 | Outcome::Differs(format!( | ||
| 1019 | "the refreshed editor shows otherwise: {}", | ||
| 1020 | difference(&stored, &editor.page().unwrap()) | ||
| 1021 | )) | ||
| 1022 | }) | ||
| 1023 | } | ||
| 1024 | |||
| 846 | /// Stores the editor's step as its ops, checks the stored page against what the model | 1025 | /// Stores the editor's step as its ops, checks the stored page against what the model |
| 847 | /// oracle predicts from them and against the sealed image reread, and, unless it is the | 1026 | /// oracle predicts from them and against the sealed image reread, and, unless it is the |
| 848 | /// editor's page, compares with that page lowered whole onto the section as it was | 1027 | /// editor's page, compares with that page lowered whole onto the section as it was |
| ... | @@ -1071,6 +1250,8 @@ fn sweep( | ... | @@ -1071,6 +1250,8 @@ fn sweep( |
| 1071 | .chain(RECORD_PLANS.map(<[Kind]>::to_vec)) | 1250 | .chain(RECORD_PLANS.map(<[Kind]>::to_vec)) |
| 1072 | .chain(FLOATING_PLANS.map(<[Kind]>::to_vec)) | 1251 | .chain(FLOATING_PLANS.map(<[Kind]>::to_vec)) |
| 1073 | .chain(TYPING_PLANS.map(<[Kind]>::to_vec)) | 1252 | .chain(TYPING_PLANS.map(<[Kind]>::to_vec)) |
| 1253 | .chain(STYLE_PLANS.map(<[Kind]>::to_vec)) | ||
| 1254 | .chain(TABLE_PLANS.map(<[Kind]>::to_vec)) | ||
| 1074 | .collect::<Vec<Vec<Kind>>>(); | 1255 | .collect::<Vec<Vec<Kind>>>(); |
| 1075 | for plan in plans { | 1256 | for plan in plans { |
| 1076 | let arena = Arena::default(); | 1257 | let arena = Arena::default(); |
| ... | @@ -1082,13 +1263,20 @@ fn sweep( | ... | @@ -1082,13 +1263,20 @@ fn sweep( |
| 1082 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); | 1263 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); |
| 1083 | for kind in &plan { | 1264 | for kind in &plan { |
| 1084 | let model = editor.page().unwrap(); | 1265 | let model = editor.page().unwrap(); |
| 1085 | if !perform(&mut editor, engine, *kind, random) { | 1266 | let outcome = if *kind == Kind::Theme { |
| 1086 | continue; | 1267 | match stored.theme(&mut editor, engine, random.below(2) == 0) { |
| 1087 | } | 1268 | Some(outcome) => outcome, |
| 1088 | if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { | 1269 | None => continue, |
| 1089 | eprintln!("STEP {kind:?} of {plan:?} on {title:?}"); | 1270 | } |
| 1090 | } | 1271 | } else { |
| 1091 | let outcome = stored.step(&mut editor, &model); | 1272 | if !perform(&mut editor, engine, *kind, random) { |
| 1273 | continue; | ||
| 1274 | } | ||
| 1275 | if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { | ||
| 1276 | eprintln!("STEP {kind:?} of {plan:?} on {title:?}"); | ||
| 1277 | } | ||
| 1278 | stored.step(&mut editor, &model) | ||
| 1279 | }; | ||
| 1092 | *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1; | 1280 | *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1; |
| 1093 | if let Outcome::Differs(reason) = &outcome { | 1281 | if let Outcome::Differs(reason) = &outcome { |
| 1094 | tally | 1282 | tally |
crates/canvas/tests/picture_paste.rs+142-3| ... | @@ -6,13 +6,13 @@ | ... | @@ -6,13 +6,13 @@ |
| 6 | 6 | ||
| 7 | use canvas::{ | 7 | use canvas::{ |
| 8 | document::TextPosition, | 8 | document::TextPosition, |
| 9 | editor::{CanvasEditor, Selection}, | 9 | editor::{CanvasEditor, Piece, Selection}, |
| 10 | layout::TextEngine, | 10 | layout::TextEngine, |
| 11 | }; | 11 | }; |
| 12 | use onestore::{ | 12 | use onestore::{ |
| 13 | Arena, Section, Store, | 13 | Arena, PageCreation, Section, Store, |
| 14 | document::Layout, | 14 | document::Layout, |
| 15 | op::{Edit, Op}, | 15 | op::{Edit, Op, SectionOp}, |
| 16 | page::{Image, PageObject, ParagraphContent}, | 16 | page::{Image, PageObject, ParagraphContent}, |
| 17 | }; | 17 | }; |
| 18 | use parley::Affinity; | 18 | use parley::Affinity; |
| ... | @@ -35,6 +35,9 @@ fn picture(size: [f32; 2]) -> Image { | ... | @@ -35,6 +35,9 @@ fn picture(size: [f32; 2]) -> Image { |
| 35 | alt: None, | 35 | alt: None, |
| 36 | background: false, | 36 | background: false, |
| 37 | printout: None, | 37 | printout: None, |
| 38 | tags: Vec::new(), | ||
| 39 | link: None, | ||
| 40 | text: None, | ||
| 38 | } | 41 | } |
| 39 | } | 42 | } |
| 40 | 43 | ||
| ... | @@ -123,6 +126,72 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { | ... | @@ -123,6 +126,72 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { |
| 123 | store(&mut editor); | 126 | store(&mut editor); |
| 124 | assert_eq!(body(&editor), ["Pasted pictures"]); | 127 | assert_eq!(body(&editor), ["Pasted pictures"]); |
| 125 | 128 | ||
| 129 | // A web page's text and pictures in order, undone in one step. | ||
| 130 | editor.select(caret(0, 7)).unwrap(); | ||
| 131 | let png = picture([1.0; 2]).bytes.unwrap().to_vec(); | ||
| 132 | editor | ||
| 133 | .paste_pieces( | ||
| 134 | &mut engine, | ||
| 135 | vec![ | ||
| 136 | Piece::Text("web ".into()), | ||
| 137 | Piece::Picture(png, [150.0, 75.0]), | ||
| 138 | Piece::Text("page\ntext ".into()), | ||
| 139 | ], | ||
| 140 | 0x0409, | ||
| 141 | ) | ||
| 142 | .unwrap(); | ||
| 143 | store(&mut editor); | ||
| 144 | assert_eq!( | ||
| 145 | body(&editor), | ||
| 146 | [ | ||
| 147 | "Pasted web ", | ||
| 148 | "[150.0, 75.0]", | ||
| 149 | "", | ||
| 150 | "page", | ||
| 151 | "text ", | ||
| 152 | "pictures" | ||
| 153 | ] | ||
| 154 | ); | ||
| 155 | editor.undo(&mut engine).unwrap(); | ||
| 156 | store(&mut editor); | ||
| 157 | assert_eq!(body(&editor), ["Pasted pictures"]); | ||
| 158 | |||
| 159 | // A web picture still on its way ends the paragraph and goes in when it arrives, an | ||
| 160 | // undo step of its own, while the caret stays. | ||
| 161 | editor.select(caret(0, 7)).unwrap(); | ||
| 162 | let awaited = editor | ||
| 163 | .paste_pieces( | ||
| 164 | &mut engine, | ||
| 165 | vec![ | ||
| 166 | Piece::Text("web".into()), | ||
| 167 | Piece::Awaited, | ||
| 168 | Piece::Text("page ".into()), | ||
| 169 | ], | ||
| 170 | 0x0409, | ||
| 171 | ) | ||
| 172 | .unwrap(); | ||
| 173 | store(&mut editor); | ||
| 174 | assert_eq!(body(&editor), ["Pasted web", "page pictures"]); | ||
| 175 | let typing = editor.selection(); | ||
| 176 | let arrived = picture([150.0, 75.0]); | ||
| 177 | assert!( | ||
| 178 | editor | ||
| 179 | .insert_awaited(&mut engine, awaited[0], arrived) | ||
| 180 | .unwrap() | ||
| 181 | ); | ||
| 182 | store(&mut editor); | ||
| 183 | assert_eq!( | ||
| 184 | body(&editor), | ||
| 185 | ["Pasted web", "[150.0, 75.0]", "page pictures"] | ||
| 186 | ); | ||
| 187 | assert_eq!(editor.selection(), typing); | ||
| 188 | editor.undo(&mut engine).unwrap(); | ||
| 189 | store(&mut editor); | ||
| 190 | assert_eq!(body(&editor), ["Pasted web", "page pictures"]); | ||
| 191 | editor.undo(&mut engine).unwrap(); | ||
| 192 | store(&mut editor); | ||
| 193 | assert_eq!(body(&editor), ["Pasted pictures"]); | ||
| 194 | |||
| 126 | // At the end, an empty paragraph follows for the caret; at an empty paragraph's start | 195 | // At the end, an empty paragraph follows for the caret; at an empty paragraph's start |
| 127 | // the picture takes its place. | 196 | // the picture takes its place. |
| 128 | editor.select(caret(0, 15)).unwrap(); | 197 | editor.select(caret(0, 15)).unwrap(); |
| ... | @@ -194,3 +263,73 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { | ... | @@ -194,3 +263,73 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { |
| 194 | .unwrap(); | 263 | .unwrap(); |
| 195 | } | 264 | } |
| 196 | } | 265 | } |
| 266 | |||
| 267 | /// From the title, OneNote 2010 (lab, 2026-09-30) put a pasted picture at the end of the | ||
| 268 | /// outline where the body starts; on a page with no body, in a new outline there; and on a | ||
| 269 | /// page whose content lay elsewhere, on the page two grid rows below it. | ||
| 270 | #[test] | ||
| 271 | fn a_picture_pasted_from_the_title_goes_into_the_body() { | ||
| 272 | let source = onestore::create_section("pictures.one", "First page", "Author").unwrap(); | ||
| 273 | let arena = Arena::default(); | ||
| 274 | let mut section = Section::open(&arena, source).unwrap(); | ||
| 275 | let creation = PageCreation::new(None, Some("Title paste"), "Author").unwrap(); | ||
| 276 | section | ||
| 277 | .apply( | ||
| 278 | "Author", | ||
| 279 | &Edit { | ||
| 280 | at: 133_000_000_000_000_000, | ||
| 281 | ops: vec![Op::Section(SectionOp::Create(creation))], | ||
| 282 | }, | ||
| 283 | ) | ||
| 284 | .unwrap(); | ||
| 285 | let (space, ..) = section.pages().unwrap()[1].clone(); | ||
| 286 | let page = section.page(space).unwrap(); | ||
| 287 | let mut engine = TextEngine::default(); | ||
| 288 | let opened = |engine: &mut TextEngine| { | ||
| 289 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); | ||
| 290 | let title = editor.outlines().iter().find(|o| o.title).unwrap().id; | ||
| 291 | editor.focus_outline(title).unwrap(); | ||
| 292 | editor | ||
| 293 | }; | ||
| 294 | |||
| 295 | // No body: a new outline where it starts. | ||
| 296 | let mut editor = opened(&mut engine); | ||
| 297 | let start = editor.body_start().unwrap(); | ||
| 298 | editor | ||
| 299 | .insert_picture(&mut engine, picture([150.0, 75.0])) | ||
| 300 | .unwrap(); | ||
| 301 | assert_eq!(body(&editor), ["[150.0, 75.0]", ""]); | ||
| 302 | assert_eq!(editor.active_outline().origin(), start); | ||
| 303 | |||
| 304 | // A body where it starts: at its end. | ||
| 305 | let mut editor = opened(&mut engine); | ||
| 306 | editor.leave_title(&mut engine).unwrap(); | ||
| 307 | editor.insert(&mut engine, "body").unwrap(); | ||
| 308 | assert_eq!(editor.active_outline().origin(), start); | ||
| 309 | let title = editor.outlines().iter().find(|o| o.title).unwrap().id; | ||
| 310 | editor.focus_outline(title).unwrap(); | ||
| 311 | editor | ||
| 312 | .insert_picture(&mut engine, picture([150.0, 75.0])) | ||
| 313 | .unwrap(); | ||
| 314 | assert_eq!(body(&editor), ["body", "[150.0, 75.0]", ""]); | ||
| 315 | |||
| 316 | // Content elsewhere: on the page two grid rows below it. | ||
| 317 | let mut editor = opened(&mut engine); | ||
| 318 | let margin = editor.margin_origin(); | ||
| 319 | editor | ||
| 320 | .place_caret(&mut engine, [start[0] + 270.0, start[1] + 72.0], 468.0) | ||
| 321 | .unwrap(); | ||
| 322 | editor.insert(&mut engine, "far").unwrap(); | ||
| 323 | let bottom = editor.active_outline().bounds().y1 as f32; | ||
| 324 | let title = editor.outlines().iter().find(|o| o.title).unwrap().id; | ||
| 325 | editor.focus_outline(title).unwrap(); | ||
| 326 | editor | ||
| 327 | .insert_picture(&mut engine, picture([150.0, 75.0])) | ||
| 328 | .unwrap(); | ||
| 329 | let placed = page_pictures(&editor).pop().unwrap(); | ||
| 330 | let row = ((bottom - margin[1]) / 18.0).floor() + 2.0; | ||
| 331 | assert_eq!( | ||
| 332 | [placed.layout.x, placed.layout.y], | ||
| 333 | [Some(start[0]), Some(margin[1] + row * 18.0)] | ||
| 334 | ); | ||
| 335 | } |
crates/canvas/tests/pictures.rs created+463| ... | @@ -0,0 +1,463 @@ | ||
| 1 | //! Pictures as OneNote 2010 stores them in the wild. | ||
| 2 | |||
| 3 | use canvas::{editor::CanvasEditor, layout::TextEngine}; | ||
| 4 | use onestore::{ | ||
| 5 | Arena, Section, | ||
| 6 | op::{Edit, Op}, | ||
| 7 | page::{Page, PageObject, ParagraphContent}, | ||
| 8 | }; | ||
| 9 | |||
| 10 | fn page(bytes: &[u8], title: &str) -> Page { | ||
| 11 | let arena = Arena::default(); | ||
| 12 | let mut section = Section::open(&arena, bytes.to_vec()).unwrap(); | ||
| 13 | let (space, ..) = section | ||
| 14 | .pages() | ||
| 15 | .unwrap() | ||
| 16 | .into_iter() | ||
| 17 | .find(|page| page.1 == title) | ||
| 18 | .unwrap(); | ||
| 19 | section.page(space).unwrap() | ||
| 20 | } | ||
| 21 | |||
| 22 | /// A picture COM put on the page stores no layout size, only its own (PictureWidth and | ||
| 23 | /// PictureHeight), and shows at that size (`corpus/print`). | ||
| 24 | #[test] | ||
| 25 | fn a_page_picture_without_a_layout_size_shows_at_its_own() { | ||
| 26 | let page = page( | ||
| 27 | include_bytes!("../../../corpus/print/native/Print.one"), | ||
| 28 | "Printing test", | ||
| 29 | ); | ||
| 30 | let [picture] = &page | ||
| 31 | .objects | ||
| 32 | .iter() | ||
| 33 | .filter_map(|object| match object { | ||
| 34 | PageObject::Image(image) => Some(image), | ||
| 35 | _ => None, | ||
| 36 | }) | ||
| 37 | .collect::<Vec<_>>()[..] | ||
| 38 | else { | ||
| 39 | panic!("one picture"); | ||
| 40 | }; | ||
| 41 | assert_eq!(picture.layout.max_width, None); | ||
| 42 | let mut engine = TextEngine::default(); | ||
| 43 | let editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); | ||
| 44 | let origin = [picture.layout.x.unwrap(), picture.layout.y.unwrap()]; | ||
| 45 | assert_eq!( | ||
| 46 | editor.image_placement(picture.id), | ||
| 47 | Some((origin, [150.0, 90.0])) | ||
| 48 | ); | ||
| 49 | #[cfg(feature = "gpu")] | ||
| 50 | { | ||
| 51 | let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap(); | ||
| 52 | assert_eq!(scene.read_only(Some(&editor)).count(), 0); | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | const OBJECTS: &[u8] = include_bytes!("../../../corpus/object-tags/native/notebook/files.one"); | ||
| 57 | |||
| 58 | /// The picture or file `id` holds, wherever it is on the page. | ||
| 59 | fn tags_of(page: &Page, id: onestore::ExGuid) -> Vec<onestore::document::Tag> { | ||
| 60 | let contents = page.objects.iter().flat_map(|object| match object { | ||
| 61 | PageObject::Outline(outline) => outline | ||
| 62 | .paragraphs | ||
| 63 | .iter() | ||
| 64 | .map(|paragraph| match &paragraph.content { | ||
| 65 | ParagraphContent::Image(image) => (image.id, image.tags.clone()), | ||
| 66 | ParagraphContent::Attachment(file) => (file.id, file.tags.clone()), | ||
| 67 | _ => (paragraph.id, Vec::new()), | ||
| 68 | }) | ||
| 69 | .collect(), | ||
| 70 | PageObject::Image(image) => vec![(image.id, image.tags.clone())], | ||
| 71 | PageObject::Attachment(file) => vec![(file.id, file.tags.clone())], | ||
| 72 | _ => Vec::new(), | ||
| 73 | }); | ||
| 74 | contents | ||
| 75 | .filter(|(object, _)| *object == id) | ||
| 76 | .map(|(_, tags)| tags) | ||
| 77 | .next() | ||
| 78 | .unwrap() | ||
| 79 | } | ||
| 80 | |||
| 81 | /// OneNote 2010 stores a tag given to a picture or file on the object itself, in an | ||
| 82 | /// outline or on the page, and draws it in a column to its left centred on it; a click on a | ||
| 83 | /// check box checks it, one revision each, and undo clears it again | ||
| 84 | /// (`corpus/object-tags`). `CANVAS_OBJECT_TAGS_EXPORT` names a new directory receiving the | ||
| 85 | /// candidate for a cold reopen. | ||
| 86 | #[test] | ||
| 87 | fn tags_on_pictures_and_files_draw_and_check() { | ||
| 88 | let arena = Arena::default(); | ||
| 89 | let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); | ||
| 90 | let (space, ..) = section | ||
| 91 | .pages() | ||
| 92 | .unwrap() | ||
| 93 | .into_iter() | ||
| 94 | .find(|page| page.1 == "Pictures and files") | ||
| 95 | .unwrap(); | ||
| 96 | let page = section.page(space).unwrap(); | ||
| 97 | let outline = page | ||
| 98 | .objects | ||
| 99 | .iter() | ||
| 100 | .find_map(|object| match object { | ||
| 101 | PageObject::Outline(outline) if outline.paragraphs.len() == 4 => Some(outline), | ||
| 102 | _ => None, | ||
| 103 | }) | ||
| 104 | .unwrap(); | ||
| 105 | let [(picture_paragraph, picture), (_, file)] = [1, 2].map(|index| { | ||
| 106 | let paragraph = &outline.paragraphs[index]; | ||
| 107 | let id = match &paragraph.content { | ||
| 108 | ParagraphContent::Image(image) => image.id, | ||
| 109 | ParagraphContent::Attachment(file) => file.id, | ||
| 110 | _ => panic!("a picture and a file"), | ||
| 111 | }; | ||
| 112 | (paragraph.id, id) | ||
| 113 | }); | ||
| 114 | let outline_id = outline.id; | ||
| 115 | let (page_picture, page_file) = ( | ||
| 116 | page.objects | ||
| 117 | .iter() | ||
| 118 | .find_map(|object| match object { | ||
| 119 | PageObject::Image(image) if !image.tags.is_empty() => Some(image.id), | ||
| 120 | _ => None, | ||
| 121 | }) | ||
| 122 | .unwrap(), | ||
| 123 | page.objects | ||
| 124 | .iter() | ||
| 125 | .find_map(|object| match object { | ||
| 126 | PageObject::Attachment(file) => Some(file.id), | ||
| 127 | _ => None, | ||
| 128 | }) | ||
| 129 | .unwrap(), | ||
| 130 | ); | ||
| 131 | for id in [picture, file, page_picture, page_file] { | ||
| 132 | let tags = tags_of(&page, id); | ||
| 133 | assert_eq!(tags.len(), 1); | ||
| 134 | assert_eq!(tags[0].status & 1, 0); | ||
| 135 | assert!(page.definitions.contains_key(&tags[0].definition.unwrap())); | ||
| 136 | } | ||
| 137 | |||
| 138 | let mut engine = TextEngine::default(); | ||
| 139 | let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); | ||
| 140 | // The outline's tags stand beside the picture and the file they mark. | ||
| 141 | let shaped = editor | ||
| 142 | .outlines() | ||
| 143 | .iter() | ||
| 144 | .find(|item| item.id == outline_id) | ||
| 145 | .unwrap() | ||
| 146 | .shaped(); | ||
| 147 | let marked: Vec<_> = shaped.tags().map(|(paragraph, ..)| paragraph).collect(); | ||
| 148 | assert_eq!(marked, [picture_paragraph, outline.paragraphs[2].id]); | ||
| 149 | #[cfg(feature = "gpu")] | ||
| 150 | { | ||
| 151 | // The page picture at (400, 86), 120 by 90, has its tag 24.75 points to its left, | ||
| 152 | // centred on it. | ||
| 153 | let (scene, shown) = | ||
| 154 | canvas::gpu::page::PageScene::from_page(page.clone(), &mut engine).unwrap(); | ||
| 155 | let hit = |point| scene.hit_test(point, Some(&shown), |_| None::<()>); | ||
| 156 | assert_eq!( | ||
| 157 | hit([376.0, 126.0]), | ||
| 158 | Some(canvas::gpu::page::SceneHit::Check(page_picture)) | ||
| 159 | ); | ||
| 160 | assert_eq!( | ||
| 161 | hit([374.0, 126.0]), | ||
| 162 | None, | ||
| 163 | "the tag's column starts 24.75 points left" | ||
| 164 | ); | ||
| 165 | } | ||
| 166 | |||
| 167 | let save = |editor: &mut CanvasEditor, section: &mut Section| { | ||
| 168 | let ops = editor | ||
| 169 | .take_ops() | ||
| 170 | .unwrap() | ||
| 171 | .into_iter() | ||
| 172 | .map(|op| Op::Page { space, op }) | ||
| 173 | .collect(); | ||
| 174 | section | ||
| 175 | .apply( | ||
| 176 | "Author", | ||
| 177 | &Edit { | ||
| 178 | at: 134_000_000_000_000_000, | ||
| 179 | ops, | ||
| 180 | }, | ||
| 181 | ) | ||
| 182 | .unwrap(); | ||
| 183 | section.seal().unwrap().unwrap(); | ||
| 184 | }; | ||
| 185 | editor.click_object_check(page_picture).unwrap(); | ||
| 186 | save(&mut editor, &mut section); | ||
| 187 | editor.click_object_check(page_file).unwrap(); | ||
| 188 | save(&mut editor, &mut section); | ||
| 189 | editor | ||
| 190 | .click_check(&mut engine, outline_id, picture_paragraph) | ||
| 191 | .unwrap(); | ||
| 192 | save(&mut editor, &mut section); | ||
| 193 | let stored = section.page(space).unwrap(); | ||
| 194 | for (id, checked) in [ | ||
| 195 | (page_picture, true), | ||
| 196 | (page_file, true), | ||
| 197 | (picture, true), | ||
| 198 | (file, false), | ||
| 199 | ] { | ||
| 200 | assert_eq!(tags_of(&stored, id)[0].status & 1 != 0, checked, "{id:?}"); | ||
| 201 | } | ||
| 202 | if let Some(directory) = std::env::var_os("CANVAS_OBJECT_TAGS_EXPORT") { | ||
| 203 | let directory = std::path::PathBuf::from(directory); | ||
| 204 | std::fs::create_dir(&directory).unwrap(); | ||
| 205 | let written = section.image(); | ||
| 206 | std::fs::write(directory.join("files.one"), &written).unwrap(); | ||
| 207 | let file_id = onestore::Store::parse(&written).unwrap().header.file_id; | ||
| 208 | std::fs::write( | ||
| 209 | directory.join("Open Notebook.onetoc2"), | ||
| 210 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) | ||
| 211 | .unwrap(), | ||
| 212 | ) | ||
| 213 | .unwrap(); | ||
| 214 | } | ||
| 215 | assert!(editor.undo(&mut engine).unwrap()); | ||
| 216 | assert!(editor.undo(&mut engine).unwrap()); | ||
| 217 | save(&mut editor, &mut section); | ||
| 218 | let stored = section.page(space).unwrap(); | ||
| 219 | assert_eq!(tags_of(&stored, page_file)[0].status & 1, 0); | ||
| 220 | assert_eq!(tags_of(&stored, picture)[0].status & 1, 0); | ||
| 221 | assert_eq!(tags_of(&stored, page_picture)[0].status & 1, 1); | ||
| 222 | } | ||
| 223 | |||
| 224 | /// Text OneNote recognised in a picture is found by search, as OneNote finds it | ||
| 225 | /// (`corpus/object-tags`). | ||
| 226 | #[test] | ||
| 227 | fn search_finds_text_recognised_in_pictures() { | ||
| 228 | let page = page(OBJECTS, "Pictures and files"); | ||
| 229 | let recognised: Vec<_> = page | ||
| 230 | .objects | ||
| 231 | .iter() | ||
| 232 | .filter_map(|object| match object { | ||
| 233 | PageObject::Image(image) => image.text.as_ref().map(|text| text.text.as_str()), | ||
| 234 | _ => None, | ||
| 235 | }) | ||
| 236 | .collect(); | ||
| 237 | assert_eq!(recognised, ["GLACIER HARBOR\nInvoice 4471"]); | ||
| 238 | let mut index = canvas::search::Index::default(); | ||
| 239 | index.set(canvas::search::Entry::new( | ||
| 240 | "files", | ||
| 241 | onestore::ExGuid::default(), | ||
| 242 | &page, | ||
| 243 | 0, | ||
| 244 | )); | ||
| 245 | let found = index.search(&canvas::search::Query::new("invoice 4471"), |_| true); | ||
| 246 | assert_eq!(found.len(), 1); | ||
| 247 | assert_eq!(found[0].snippet, "Invoice 4471"); | ||
| 248 | assert!( | ||
| 249 | index | ||
| 250 | .search(&canvas::search::Query::new("harb"), |_| true) | ||
| 251 | .len() | ||
| 252 | == 1 | ||
| 253 | ); | ||
| 254 | } | ||
| 255 | |||
| 256 | /// A picture's link reads back from where OneNote stores it (WzHyperlinkUrl), and a linked | ||
| 257 | /// picture deleted and brought back by undo keeps it (`corpus/picture-link`). | ||
| 258 | /// `CANVAS_PICTURE_LINK_EXPORT` names a new directory receiving the candidate for a cold | ||
| 259 | /// reopen. | ||
| 260 | #[test] | ||
| 261 | fn a_linked_picture_keeps_its_link_through_undo() { | ||
| 262 | let source = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"); | ||
| 263 | let arena = Arena::default(); | ||
| 264 | let mut section = Section::open(&arena, source.to_vec()).unwrap(); | ||
| 265 | let (space, ..) = section | ||
| 266 | .pages() | ||
| 267 | .unwrap() | ||
| 268 | .into_iter() | ||
| 269 | .find(|page| page.1 == "Image png") | ||
| 270 | .unwrap(); | ||
| 271 | let page = section.page(space).unwrap(); | ||
| 272 | let linked = |page: &Page| -> Vec<(onestore::ExGuid, Option<String>)> { | ||
| 273 | page.objects | ||
| 274 | .iter() | ||
| 275 | .filter_map(|object| match object { | ||
| 276 | PageObject::Image(image) => Some((image.id, image.link.clone())), | ||
| 277 | _ => None, | ||
| 278 | }) | ||
| 279 | .collect() | ||
| 280 | }; | ||
| 281 | let before = linked(&page); | ||
| 282 | let [(id, Some(link))] = &before[..] else { | ||
| 283 | panic!("one linked picture"); | ||
| 284 | }; | ||
| 285 | assert_eq!(link, "https://example.invalid/image/png"); | ||
| 286 | let mut engine = TextEngine::default(); | ||
| 287 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); | ||
| 288 | editor.remove_image(&mut engine, *id).unwrap(); | ||
| 289 | assert!(editor.undo(&mut engine).unwrap()); | ||
| 290 | let ops = editor | ||
| 291 | .take_ops() | ||
| 292 | .unwrap() | ||
| 293 | .into_iter() | ||
| 294 | .map(|op| Op::Page { space, op }) | ||
| 295 | .collect(); | ||
| 296 | section | ||
| 297 | .apply( | ||
| 298 | "Author", | ||
| 299 | &Edit { | ||
| 300 | at: 134_000_000_000_000_000, | ||
| 301 | ops, | ||
| 302 | }, | ||
| 303 | ) | ||
| 304 | .unwrap(); | ||
| 305 | section.seal().unwrap(); | ||
| 306 | assert_eq!(linked(&section.page(space).unwrap()), before); | ||
| 307 | if let Some(directory) = std::env::var_os("CANVAS_PICTURE_LINK_EXPORT") { | ||
| 308 | let directory = std::path::PathBuf::from(directory); | ||
| 309 | std::fs::create_dir(&directory).unwrap(); | ||
| 310 | let written = section.image(); | ||
| 311 | std::fs::write(directory.join("Features.one"), &written).unwrap(); | ||
| 312 | let file_id = onestore::Store::parse(&written).unwrap().header.file_id; | ||
| 313 | std::fs::write( | ||
| 314 | directory.join("Open Notebook.onetoc2"), | ||
| 315 | onestore::create_table_of_contents( | ||
| 316 | "Open Notebook.onetoc2", | ||
| 317 | &[("Features.one", file_id)], | ||
| 318 | ) | ||
| 319 | .unwrap(), | ||
| 320 | ) | ||
| 321 | .unwrap(); | ||
| 322 | } | ||
| 323 | } | ||
| 324 | |||
| 325 | /// A tag command on a selected picture or file tags the object itself, as OneNote 2010 | ||
| 326 | /// does (`corpus/object-tags`): a tag it has comes off, one it lacks goes on unchecked. | ||
| 327 | #[test] | ||
| 328 | fn tag_commands_tag_a_selected_picture_or_file() { | ||
| 329 | use canvas::editor::{Formatting, NoteTag}; | ||
| 330 | let arena = Arena::default(); | ||
| 331 | let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); | ||
| 332 | let (space, ..) = section | ||
| 333 | .pages() | ||
| 334 | .unwrap() | ||
| 335 | .into_iter() | ||
| 336 | .find(|page| page.1 == "Pictures and files") | ||
| 337 | .unwrap(); | ||
| 338 | let page = section.page(space).unwrap(); | ||
| 339 | let page_picture = page | ||
| 340 | .objects | ||
| 341 | .iter() | ||
| 342 | .find_map(|object| match object { | ||
| 343 | PageObject::Image(image) if !image.tags.is_empty() => Some(image.id), | ||
| 344 | _ => None, | ||
| 345 | }) | ||
| 346 | .unwrap(); | ||
| 347 | let file = page | ||
| 348 | .objects | ||
| 349 | .iter() | ||
| 350 | .find_map(|object| match object { | ||
| 351 | PageObject::Outline(outline) => { | ||
| 352 | outline.paragraphs.iter().find_map(|p| match &p.content { | ||
| 353 | ParagraphContent::Attachment(file) => Some(file.id), | ||
| 354 | _ => None, | ||
| 355 | }) | ||
| 356 | } | ||
| 357 | _ => None, | ||
| 358 | }) | ||
| 359 | .unwrap(); | ||
| 360 | let mut engine = TextEngine::default(); | ||
| 361 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); | ||
| 362 | let to_do = Formatting::Tag(NoteTag::defaults()[0].clone(), 0); | ||
| 363 | let important = Formatting::Tag(NoteTag::defaults()[1].clone(), 1); | ||
| 364 | assert!( | ||
| 365 | editor | ||
| 366 | .format_object(&mut engine, page_picture, &to_do) | ||
| 367 | .unwrap() | ||
| 368 | ); | ||
| 369 | assert!(editor.format_object(&mut engine, file, &important).unwrap()); | ||
| 370 | assert!( | ||
| 371 | !editor | ||
| 372 | .format_object(&mut engine, file, &Formatting::Bullets) | ||
| 373 | .unwrap() | ||
| 374 | ); | ||
| 375 | let ops = editor | ||
| 376 | .take_ops() | ||
| 377 | .unwrap() | ||
| 378 | .into_iter() | ||
| 379 | .map(|op| Op::Page { space, op }) | ||
| 380 | .collect(); | ||
| 381 | section | ||
| 382 | .apply( | ||
| 383 | "Author", | ||
| 384 | &Edit { | ||
| 385 | at: 134_000_000_000_000_000, | ||
| 386 | ops, | ||
| 387 | }, | ||
| 388 | ) | ||
| 389 | .unwrap(); | ||
| 390 | section.seal().unwrap(); | ||
| 391 | let stored = section.page(space).unwrap(); | ||
| 392 | assert!(tags_of(&stored, page_picture).is_empty()); | ||
| 393 | let tags = tags_of(&stored, file); | ||
| 394 | assert_eq!(tags.len(), 2); | ||
| 395 | assert!(matches!( | ||
| 396 | &stored.definitions[&tags[0].definition.unwrap()].kind, | ||
| 397 | onestore::document::Kind::TagDefinition { label: Some(label), .. } if label == "Important" | ||
| 398 | )); | ||
| 399 | } | ||
| 400 | |||
| 401 | /// A picture deleted and brought back by undo keeps the text OneNote recognised in it, | ||
| 402 | /// written back as OneNote stores it (`corpus/object-tags/restored`). | ||
| 403 | /// `CANVAS_RECOGNIZED_EXPORT` names a new directory receiving the candidate for a cold | ||
| 404 | /// reopen. | ||
| 405 | #[test] | ||
| 406 | fn a_restored_picture_keeps_its_recognised_text() { | ||
| 407 | let arena = Arena::default(); | ||
| 408 | let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); | ||
| 409 | let (space, ..) = section | ||
| 410 | .pages() | ||
| 411 | .unwrap() | ||
| 412 | .into_iter() | ||
| 413 | .find(|page| page.1 == "Pictures and files") | ||
| 414 | .unwrap(); | ||
| 415 | let page = section.page(space).unwrap(); | ||
| 416 | let recognized = |page: &Page| { | ||
| 417 | page.objects | ||
| 418 | .iter() | ||
| 419 | .find_map(|object| match object { | ||
| 420 | PageObject::Image(image) if image.text.is_some() => { | ||
| 421 | Some((image.id, image.text.clone().unwrap())) | ||
| 422 | } | ||
| 423 | _ => None, | ||
| 424 | }) | ||
| 425 | .unwrap() | ||
| 426 | }; | ||
| 427 | let (id, text) = recognized(&page); | ||
| 428 | assert_eq!(text.stored.len(), 2, "its language and word layout"); | ||
| 429 | let mut engine = TextEngine::default(); | ||
| 430 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); | ||
| 431 | editor.remove_image(&mut engine, id).unwrap(); | ||
| 432 | assert!(editor.undo(&mut engine).unwrap()); | ||
| 433 | let ops = editor | ||
| 434 | .take_ops() | ||
| 435 | .unwrap() | ||
| 436 | .into_iter() | ||
| 437 | .map(|op| Op::Page { space, op }) | ||
| 438 | .collect(); | ||
| 439 | section | ||
| 440 | .apply( | ||
| 441 | "Author", | ||
| 442 | &Edit { | ||
| 443 | at: 134_000_000_000_000_000, | ||
| 444 | ops, | ||
| 445 | }, | ||
| 446 | ) | ||
| 447 | .unwrap(); | ||
| 448 | section.seal().unwrap(); | ||
| 449 | assert_eq!(recognized(&section.page(space).unwrap()), (id, text)); | ||
| 450 | if let Some(directory) = std::env::var_os("CANVAS_RECOGNIZED_EXPORT") { | ||
| 451 | let directory = std::path::PathBuf::from(directory); | ||
| 452 | std::fs::create_dir(&directory).unwrap(); | ||
| 453 | let written = section.image(); | ||
| 454 | std::fs::write(directory.join("files.one"), &written).unwrap(); | ||
| 455 | let file_id = onestore::Store::parse(&written).unwrap().header.file_id; | ||
| 456 | std::fs::write( | ||
| 457 | directory.join("Open Notebook.onetoc2"), | ||
| 458 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) | ||
| 459 | .unwrap(), | ||
| 460 | ) | ||
| 461 | .unwrap(); | ||
| 462 | } | ||
| 463 | } | ||
crates/canvas/tests/printouts.rs+55-3| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | //! A file printout, whose pictures OneNote 2010 stores as pages of an XPS package with a PNG | 1 | //! A file printout, whose pictures OneNote 2010 stores as pages of an XPS package with a PNG |
| 2 | //! rendering of each beside it (WebPictureContainer14), shows that rendering and edits like | 2 | //! rendering of each beside it (WebPictureContainer14), shows that rendering and edits like |
| 3 | //! any picture: its pages stay printouts when moved, and a page deleted comes back on undo | 3 | //! any picture: its pages stay printouts when moved, and a page deleted comes back on undo |
| 4 | //! as the picture OneNote showed of it (`corpus/printout`). | 4 | //! as the printout page it was (`corpus/printout`). |
| 5 | 5 | ||
| 6 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; | 6 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; |
| 7 | use onestore::{ | 7 | use onestore::{ |
| ... | @@ -100,9 +100,16 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() { | ... | @@ -100,9 +100,16 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() { |
| 100 | let moved = after.iter().find(|picture| picture.id == moved).unwrap(); | 100 | let moved = after.iter().find(|picture| picture.id == moved).unwrap(); |
| 101 | assert_eq!([moved.layout.x, moved.layout.y], origin.map(Some)); | 101 | assert_eq!([moved.layout.x, moved.layout.y], origin.map(Some)); |
| 102 | assert!(moved.printout.is_some() && moved.display.is_some()); | 102 | assert!(moved.printout.is_some() && moved.display.is_some()); |
| 103 | // The page deleted and brought back by undo is the printout page it was: its package, | ||
| 104 | // page number and the raster OneNote showed of it. | ||
| 105 | let original = printed | ||
| 106 | .iter() | ||
| 107 | .find(|picture| picture.id == restored) | ||
| 108 | .unwrap(); | ||
| 103 | let restored = after.iter().find(|picture| picture.id == restored).unwrap(); | 109 | let restored = after.iter().find(|picture| picture.id == restored).unwrap(); |
| 104 | assert_eq!(restored.printout, None); | 110 | assert_eq!(restored.printout, original.printout); |
| 105 | assert_eq!(restored.bytes, printed[0].display); | 111 | assert_eq!(restored.bytes, original.bytes); |
| 112 | assert_eq!(restored.display, original.display); | ||
| 106 | if let Some(directory) = std::env::var_os("SNOWBOUND_PRINTOUT_EXPORT") { | 113 | if let Some(directory) = std::env::var_os("SNOWBOUND_PRINTOUT_EXPORT") { |
| 107 | let directory = std::path::PathBuf::from(directory); | 114 | let directory = std::path::PathBuf::from(directory); |
| 108 | std::fs::create_dir(&directory).unwrap(); | 115 | std::fs::create_dir(&directory).unwrap(); |
| ... | @@ -119,3 +126,48 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() { | ... | @@ -119,3 +126,48 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() { |
| 119 | .unwrap(); | 126 | .unwrap(); |
| 120 | } | 127 | } |
| 121 | } | 128 | } |
| 129 | |||
| 130 | /// A copy of a printout's page keeps its pages printouts, as a page copied in OneNote does. | ||
| 131 | #[test] | ||
| 132 | fn a_copied_printout_page_keeps_its_printouts() { | ||
| 133 | let arena = Arena::default(); | ||
| 134 | let mut section = Section::open(&arena, PRINTOUTS.to_vec()).unwrap(); | ||
| 135 | let (space, ..) = section | ||
| 136 | .pages() | ||
| 137 | .unwrap() | ||
| 138 | .into_iter() | ||
| 139 | .find(|page| page.1 == "Printout") | ||
| 140 | .unwrap(); | ||
| 141 | let page = section.page(space).unwrap(); | ||
| 142 | let creation = onestore::PageCreation::new(None, Some("Copied"), "Author").unwrap(); | ||
| 143 | let edit = Edit { | ||
| 144 | at: 134_000_000_000_000_000, | ||
| 145 | ops: vec![Op::Section(onestore::op::SectionOp::Import { | ||
| 146 | creation, | ||
| 147 | page: page.copy().unwrap(), | ||
| 148 | })], | ||
| 149 | }; | ||
| 150 | section.apply("Author", &edit).unwrap(); | ||
| 151 | section.seal().unwrap(); | ||
| 152 | let (copied, ..) = section | ||
| 153 | .pages() | ||
| 154 | .unwrap() | ||
| 155 | .into_iter() | ||
| 156 | .find(|page| page.1 == "Copied") | ||
| 157 | .unwrap(); | ||
| 158 | let copy = section.page(copied).unwrap(); | ||
| 159 | let shown = |page: &Page| -> Vec<_> { | ||
| 160 | pictures(page) | ||
| 161 | .into_iter() | ||
| 162 | .map(|picture| { | ||
| 163 | ( | ||
| 164 | picture.printout.clone(), | ||
| 165 | picture.bytes.clone(), | ||
| 166 | picture.display.clone(), | ||
| 167 | picture.text.clone(), | ||
| 168 | ) | ||
| 169 | }) | ||
| 170 | .collect() | ||
| 171 | }; | ||
| 172 | assert_eq!(shown(&copy), shown(&page)); | ||
| 173 | } |
crates/canvas/tests/recording.rs+2| ... | @@ -89,6 +89,7 @@ fn a_recording_stores_its_line_its_linked_notes_and_its_file() { | ... | @@ -89,6 +89,7 @@ fn a_recording_stores_its_line_its_linked_notes_and_its_file() { |
| 89 | kind: 1, | 89 | kind: 1, |
| 90 | duration_ms: Some(1000), | 90 | duration_ms: Some(1000), |
| 91 | }), | 91 | }), |
| 92 | tags: Vec::new(), | ||
| 92 | }; | 93 | }; |
| 93 | editor.finish_recording(&mut engine, file).unwrap(); | 94 | editor.finish_recording(&mut engine, file).unwrap(); |
| 94 | assert_eq!(editor.recording(), None); | 95 | assert_eq!(editor.recording(), None); |
| ... | @@ -161,6 +162,7 @@ fn a_recording_from_the_title_goes_to_the_body_s_start() { | ... | @@ -161,6 +162,7 @@ fn a_recording_from_the_title_goes_to_the_body_s_start() { |
| 161 | kind: 1, | 162 | kind: 1, |
| 162 | duration_ms: None, | 163 | duration_ms: None, |
| 163 | }), | 164 | }), |
| 165 | tags: Vec::new(), | ||
| 164 | }; | 166 | }; |
| 165 | editor.finish_recording(&mut engine, file).unwrap(); | 167 | editor.finish_recording(&mut engine, file).unwrap(); |
| 166 | let ops = editor.take_ops().unwrap(); | 168 | let ops = editor.take_ops().unwrap(); |
crates/canvas/tests/table_widths.rs created+241| ... | @@ -0,0 +1,241 @@ | ||
| 1 | //! Table columns fit their content as it is typed and lock where a border is dragged, each | ||
| 2 | //! keystroke's width stored in the revision with its text, and OneNote 2010 reads them back | ||
| 3 | //! as it sizes them itself (`corpus/table-widths`). | ||
| 4 | |||
| 5 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; | ||
| 6 | use onestore::{ | ||
| 7 | Arena, ExGuid, Section, Store, | ||
| 8 | op::{Edit, Op}, | ||
| 9 | page::{Page, PageObject, PageParagraph, ParagraphContent, TableColumn}, | ||
| 10 | }; | ||
| 11 | |||
| 12 | const TABLES: &[u8] = include_bytes!("../../../corpus/table-tags/native/notebook/Tables.one"); | ||
| 13 | |||
| 14 | /// Each table's first cell text and columns, in page order. | ||
| 15 | fn tables(page: &Page) -> Vec<(String, Vec<TableColumn>)> { | ||
| 16 | fn walk(nodes: &[PageParagraph], out: &mut Vec<(String, Vec<TableColumn>)>) { | ||
| 17 | for node in nodes { | ||
| 18 | if let ParagraphContent::Table(table) = &node.content { | ||
| 19 | let first = table.rows[0].cells[0].paragraphs[0] | ||
| 20 | .text() | ||
| 21 | .map_or(String::new(), |text| text.text.text().to_owned()); | ||
| 22 | out.push((first, table.columns.clone())); | ||
| 23 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | ||
| 24 | walk(&cell.paragraphs, out); | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
| 29 | let mut out = Vec::new(); | ||
| 30 | for object in &page.objects { | ||
| 31 | if let PageObject::Outline(outline) = object { | ||
| 32 | walk(&outline.paragraphs, &mut out); | ||
| 33 | } | ||
| 34 | } | ||
| 35 | out | ||
| 36 | } | ||
| 37 | |||
| 38 | fn body(page: &Page) -> ExGuid { | ||
| 39 | page.objects | ||
| 40 | .iter() | ||
| 41 | .find_map(|object| match object { | ||
| 42 | PageObject::Outline(outline) if !outline.paragraphs.is_empty() => Some(outline.id), | ||
| 43 | _ => None, | ||
| 44 | }) | ||
| 45 | .unwrap() | ||
| 46 | } | ||
| 47 | |||
| 48 | fn publish(section: &mut Section<'_>, space: ExGuid, editor: &mut CanvasEditor, at: u64) { | ||
| 49 | let ops = editor | ||
| 50 | .take_ops() | ||
| 51 | .unwrap() | ||
| 52 | .into_iter() | ||
| 53 | .map(|op| Op::Page { space, op }) | ||
| 54 | .collect(); | ||
| 55 | section.apply("Author", &Edit { at, ops }).unwrap(); | ||
| 56 | } | ||
| 57 | |||
| 58 | /// Opens `space` with the caret at the end of its body's last paragraph. | ||
| 59 | fn at_end(section: &Section<'_>, space: ExGuid, engine: &mut TextEngine) -> CanvasEditor { | ||
| 60 | let page = section.page(space).unwrap(); | ||
| 61 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); | ||
| 62 | editor.focus_outline(body(&page)).unwrap(); | ||
| 63 | let document = editor.active_outline().document(); | ||
| 64 | let paragraph = document.paragraphs().count() - 1; | ||
| 65 | let offset = document | ||
| 66 | .paragraphs() | ||
| 67 | .last() | ||
| 68 | .unwrap() | ||
| 69 | .text() | ||
| 70 | .encode_utf16() | ||
| 71 | .count() as u32; | ||
| 72 | editor | ||
| 73 | .select([TextPosition { paragraph, offset }; 2].into()) | ||
| 74 | .unwrap(); | ||
| 75 | editor | ||
| 76 | } | ||
| 77 | |||
| 78 | fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) { | ||
| 79 | for (index, row) in rows.iter().enumerate() { | ||
| 80 | if index > 0 { | ||
| 81 | editor.enter(engine, false).unwrap(); | ||
| 82 | } | ||
| 83 | for (column, text) in row.iter().enumerate() { | ||
| 84 | if column > 0 { | ||
| 85 | editor.tab(engine, false).unwrap(); | ||
| 86 | } | ||
| 87 | editor.insert(engine, text).unwrap(); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | fn close(columns: &[TableColumn], expected: &[(f32, bool)]) -> bool { | ||
| 93 | columns.len() == expected.len() | ||
| 94 | && columns | ||
| 95 | .iter() | ||
| 96 | .zip(expected) | ||
| 97 | .all(|(column, (width, locked))| { | ||
| 98 | (column.width - width).abs() < 0.01 && column.locked == *locked | ||
| 99 | }) | ||
| 100 | } | ||
| 101 | |||
| 102 | /// `SNOWBOUND_TABLE_WIDTHS_EXPORT` names a new directory receiving the candidate for a cold | ||
| 103 | /// reopen. | ||
| 104 | #[test] | ||
| 105 | fn typed_and_dragged_columns_store_onenote_s_widths() { | ||
| 106 | let arena = Arena::default(); | ||
| 107 | let mut section = Section::open(&arena, TABLES.to_vec()).unwrap(); | ||
| 108 | let pages = section.pages().unwrap(); | ||
| 109 | let space = |title: &str| pages.iter().find(|page| page.1 == title).unwrap().0; | ||
| 110 | let (fruit, typed, dragged) = ( | ||
| 111 | space("Check a table"), | ||
| 112 | space("Edit a tagged table"), | ||
| 113 | space("Delete and undo"), | ||
| 114 | ); | ||
| 115 | let mut engine = TextEngine::default(); | ||
| 116 | let mut at = 134_000_000_000_000_000; | ||
| 117 | let mut step = |section: &mut Section<'_>, space, editor: &mut CanvasEditor| { | ||
| 118 | at += 10_000_000; | ||
| 119 | publish(section, space, editor, at); | ||
| 120 | }; | ||
| 121 | |||
| 122 | // A table typed after the page's text, one revision per keystroke. | ||
| 123 | let mut editor = at_end(&section, fruit, &mut engine); | ||
| 124 | editor.enter(&mut engine, false).unwrap(); | ||
| 125 | editor.insert_table(&mut engine, 1, 1).unwrap(); | ||
| 126 | step(&mut section, fruit, &mut editor); | ||
| 127 | for (index, row) in [ | ||
| 128 | ["Fruit", "Colour", "Notes"], | ||
| 129 | ["Apple", "Red", "Crisp and sweet, good for pies"], | ||
| 130 | ["Watermelon", "Green", "Summer"], | ||
| 131 | ] | ||
| 132 | .iter() | ||
| 133 | .enumerate() | ||
| 134 | { | ||
| 135 | if index > 0 { | ||
| 136 | editor.enter(&mut engine, false).unwrap(); | ||
| 137 | step(&mut section, fruit, &mut editor); | ||
| 138 | } | ||
| 139 | for (column, text) in row.iter().enumerate() { | ||
| 140 | if column > 0 { | ||
| 141 | editor.tab(&mut engine, false).unwrap(); | ||
| 142 | step(&mut section, fruit, &mut editor); | ||
| 143 | } | ||
| 144 | for character in text.chars() { | ||
| 145 | editor.insert(&mut engine, &character.to_string()).unwrap(); | ||
| 146 | step(&mut section, fruit, &mut editor); | ||
| 147 | } | ||
| 148 | } | ||
| 149 | } | ||
| 150 | let stored = tables(&section.page(fruit).unwrap()); | ||
| 151 | // OneNote 2010 typing the same table (lab, 2026-09-30). | ||
| 152 | assert!( | ||
| 153 | close( | ||
| 154 | &stored[1].1, | ||
| 155 | &[(60.759, false), (37.11, false), (139.086, false)] | ||
| 156 | ), | ||
| 157 | "{stored:?}" | ||
| 158 | ); | ||
| 159 | |||
| 160 | // Typing into a native table's cell widens its column; deleting narrows it again. | ||
| 161 | let mut editor = CanvasEditor::from_page(section.page(typed).unwrap(), &mut engine).unwrap(); | ||
| 162 | editor | ||
| 163 | .focus_outline(body(&section.page(typed).unwrap())) | ||
| 164 | .unwrap(); | ||
| 165 | editor | ||
| 166 | .select( | ||
| 167 | [TextPosition { | ||
| 168 | paragraph: 1, | ||
| 169 | offset: 10, | ||
| 170 | }; 2] | ||
| 171 | .into(), | ||
| 172 | ) | ||
| 173 | .unwrap(); | ||
| 174 | editor | ||
| 175 | .insert(&mut engine, " and typed on in Snowbound") | ||
| 176 | .unwrap(); | ||
| 177 | step(&mut section, typed, &mut editor); | ||
| 178 | let widened = tables(&section.page(typed).unwrap()); | ||
| 179 | assert!(widened[0].1[0].width > 100.0, "{widened:?}"); | ||
| 180 | |||
| 181 | // A dragged column locks and keeps its width while text wraps in it, and the column | ||
| 182 | // after it moves; a long line in that column stops at the outline's width. | ||
| 183 | let mut editor = at_end(&section, dragged, &mut engine); | ||
| 184 | editor.enter(&mut engine, false).unwrap(); | ||
| 185 | editor.insert_table(&mut engine, 1, 1).unwrap(); | ||
| 186 | type_rows(&mut editor, &mut engine, &[&["Dragged", "Fitted"]]); | ||
| 187 | step(&mut section, dragged, &mut editor); | ||
| 188 | let table = editor | ||
| 189 | .active_outline() | ||
| 190 | .document() | ||
| 191 | .nodes() | ||
| 192 | .iter() | ||
| 193 | .find_map(|node| match &node.content { | ||
| 194 | ParagraphContent::Table(table) | ||
| 195 | if table.rows[0].cells[0].paragraphs[0] | ||
| 196 | .text() | ||
| 197 | .is_some_and(|text| text.text.text() == "Dragged") => | ||
| 198 | { | ||
| 199 | Some(table.id) | ||
| 200 | } | ||
| 201 | _ => None, | ||
| 202 | }) | ||
| 203 | .unwrap(); | ||
| 204 | editor.resize_column(&mut engine, table, 0, 96.0).unwrap(); | ||
| 205 | step(&mut section, dragged, &mut editor); | ||
| 206 | editor | ||
| 207 | .insert( | ||
| 208 | &mut engine, | ||
| 209 | " and then a line long enough to reach the outline's edge and wrap there", | ||
| 210 | ) | ||
| 211 | .unwrap(); | ||
| 212 | step(&mut section, dragged, &mut editor); | ||
| 213 | let resized = tables(&section.page(dragged).unwrap()); | ||
| 214 | assert!( | ||
| 215 | resized[2].1[0].locked && resized[2].1[0].width == 96.0, | ||
| 216 | "{resized:?}" | ||
| 217 | ); | ||
| 218 | assert!( | ||
| 219 | !resized[2].1[1].locked && resized[2].1[1].width > 200.0, | ||
| 220 | "{resized:?}" | ||
| 221 | ); | ||
| 222 | |||
| 223 | section.seal().unwrap(); | ||
| 224 | let written = section.image(); | ||
| 225 | let reread = Section::open(&arena, written.clone()).unwrap(); | ||
| 226 | assert_eq!(tables(&reread.page(fruit).unwrap()), stored); | ||
| 227 | assert_eq!(tables(&reread.page(typed).unwrap()), widened); | ||
| 228 | assert_eq!(tables(&reread.page(dragged).unwrap()), resized); | ||
| 229 | if let Some(directory) = std::env::var_os("SNOWBOUND_TABLE_WIDTHS_EXPORT") { | ||
| 230 | let directory = std::path::PathBuf::from(directory); | ||
| 231 | std::fs::create_dir(&directory).unwrap(); | ||
| 232 | std::fs::write(directory.join("Tables.one"), &written).unwrap(); | ||
| 233 | let file_id = Store::parse(&written).unwrap().header.file_id; | ||
| 234 | std::fs::write( | ||
| 235 | directory.join("Open Notebook.onetoc2"), | ||
| 236 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Tables.one", file_id)]) | ||
| 237 | .unwrap(), | ||
| 238 | ) | ||
| 239 | .unwrap(); | ||
| 240 | } | ||
| 241 | } | ||
crates/draw/src/render.rs+5-2| ... | @@ -448,12 +448,14 @@ pub enum Primitive<'a> { | ... | @@ -448,12 +448,14 @@ pub enum Primitive<'a> { |
| 448 | round: bool, | 448 | round: bool, |
| 449 | color: [f32; 4], | 449 | color: [f32; 4], |
| 450 | }, | 450 | }, |
| 451 | /// A round pen's stroke between two points, `widths` units across at each, joined by the | 451 | /// A pen's stroke between two points, `widths` units across at each, joined by the |
| 452 | /// lines touching both ends, as a pressure pen's width changes along a stroke. | 452 | /// lines touching both ends, as a pressure pen's width changes along a stroke. A square |
| 453 | /// pen tip (not `round`) squares the ends in a PDF; the screen rounds them either way. | ||
| 453 | Taper { | 454 | Taper { |
| 454 | from: [f32; 2], | 455 | from: [f32; 2], |
| 455 | to: [f32; 2], | 456 | to: [f32; 2], |
| 456 | widths: [f32; 2], | 457 | widths: [f32; 2], |
| 458 | round: bool, | ||
| 457 | color: [f32; 4], | 459 | color: [f32; 4], |
| 458 | }, | 460 | }, |
| 459 | /// A highlighter's stroke between two points, `width` units across with square ends: | 461 | /// A highlighter's stroke between two points, `width` units across with square ends: |
| ... | @@ -897,6 +899,7 @@ impl Renderer { | ... | @@ -897,6 +899,7 @@ impl Renderer { |
| 897 | to, | 899 | to, |
| 898 | widths, | 900 | widths, |
| 899 | color, | 901 | color, |
| 902 | .. | ||
| 900 | } => self.segment(space, *from, *to, *widths, true, *color)?, | 903 | } => self.segment(space, *from, *to, *widths, true, *color)?, |
| 901 | Primitive::Highlight { | 904 | Primitive::Highlight { |
| 902 | from, | 905 | from, |
crates/draw/src/render/pdf.rs+38| ... | @@ -521,7 +521,45 @@ impl Document { | ... | @@ -521,7 +521,45 @@ impl Document { |
| 521 | from, | 521 | from, |
| 522 | to, | 522 | to, |
| 523 | widths, | 523 | widths, |
| 524 | round: false, | ||
| 524 | color, | 525 | color, |
| 526 | } => { | ||
| 527 | content.save_state(); | ||
| 528 | self.paint(content, used, *color, false); | ||
| 529 | // Square ends reach half their width past each point, as a square tip does. | ||
| 530 | let [from_radius, to_radius] = widths.map(|width| width / 2.0); | ||
| 531 | let [dx, dy] = [to[0] - from[0], to[1] - from[1]]; | ||
| 532 | let length = dx.hypot(dy); | ||
| 533 | let [ux, uy] = if length > 0.0 { | ||
| 534 | [dx / length, dy / length] | ||
| 535 | } else { | ||
| 536 | [1.0, 0.0] | ||
| 537 | }; | ||
| 538 | let corner = |center: &[f32; 2], radius: f32, ahead: f32, side: f32| { | ||
| 539 | [ | ||
| 540 | center[0] + radius * (ux * ahead - uy * side), | ||
| 541 | center[1] + radius * (uy * ahead + ux * side), | ||
| 542 | ] | ||
| 543 | }; | ||
| 544 | let corners = [ | ||
| 545 | corner(from, from_radius, -1.0, 1.0), | ||
| 546 | corner(to, to_radius, 1.0, 1.0), | ||
| 547 | corner(to, to_radius, 1.0, -1.0), | ||
| 548 | corner(from, from_radius, -1.0, -1.0), | ||
| 549 | ]; | ||
| 550 | content.move_to(corners[0][0], corners[0][1]); | ||
| 551 | for corner in &corners[1..] { | ||
| 552 | content.line_to(corner[0], corner[1]); | ||
| 553 | } | ||
| 554 | content.close_path().fill_nonzero(); | ||
| 555 | content.restore_state(); | ||
| 556 | } | ||
| 557 | Primitive::Taper { | ||
| 558 | from, | ||
| 559 | to, | ||
| 560 | widths, | ||
| 561 | color, | ||
| 562 | .. | ||
| 525 | } => { | 563 | } => { |
| 526 | content.save_state(); | 564 | content.save_state(); |
| 527 | self.paint(content, used, *color, false); | 565 | self.paint(content, used, *color, false); |
crates/mobile/Cargo.toml+1-1| ... | @@ -9,7 +9,7 @@ publish = false | ... | @@ -9,7 +9,7 @@ publish = false |
| 9 | crate-type = ["staticlib"] | 9 | crate-type = ["staticlib"] |
| 10 | 10 | ||
| 11 | [dependencies] | 11 | [dependencies] |
| 12 | canvas = { path = "../canvas", features = ["interaction"] } | 12 | canvas = { path = "../canvas", features = ["interaction", "pdf"] } |
| 13 | draw = { path = "../draw" } | 13 | draw = { path = "../draw" } |
| 14 | notebook = { path = "../notebook", features = ["smb"] } | 14 | notebook = { path = "../notebook", features = ["smb"] } |
| 15 | onestore = { path = "../onestore" } | 15 | onestore = { path = "../onestore" } |
crates/mobile/include/snowbound.h+8| ... | @@ -52,6 +52,8 @@ char *sb_section_pages(const Section *section); | ... | @@ -52,6 +52,8 @@ char *sb_section_pages(const Section *section); |
| 52 | uint32_t sb_section_poll(const Section *section, uint8_t *status); | 52 | uint32_t sb_section_poll(const Section *section, uint8_t *status); |
| 53 | char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time); | 53 | char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time); |
| 54 | bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time); | 54 | bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time); |
| 55 | char *sb_section_themes(const Section *section, const char *id); | ||
| 56 | bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); | ||
| 55 | bool sb_section_flush(const Section *section, double seconds); | 57 | bool sb_section_flush(const Section *section, double seconds); |
| 56 | void sb_section_wake(const Section *section); | 58 | void sb_section_wake(const Section *section); |
| 57 | void sb_section_free(Section *section); | 59 | void sb_section_free(Section *section); |
| ... | @@ -70,6 +72,8 @@ bool sb_view_block(const View *view, float x, float y, float rect[4]); | ... | @@ -70,6 +72,8 @@ bool sb_view_block(const View *view, float x, float y, float rect[4]); |
| 70 | void sb_view_set_transform(View *view, float zoom, float x, float y); | 72 | void sb_view_set_transform(View *view, float zoom, float x, float y); |
| 71 | uint8_t sb_view_target(const View *view, float x, float y); | 73 | uint8_t sb_view_target(const View *view, float x, float y); |
| 72 | void sb_view_pressure(View *view, float pressure); | 74 | void sb_view_pressure(View *view, float pressure); |
| 75 | void sb_set_snap_to_grid(bool on); | ||
| 76 | void sb_set_default_font(const char *face, float size); | ||
| 73 | bool sb_view_press(View *view, float x, float y); | 77 | bool sb_view_press(View *view, float x, float y); |
| 74 | bool sb_view_drag(View *view, float x, float y); | 78 | bool sb_view_drag(View *view, float x, float y); |
| 75 | bool sb_view_release(View *view); | 79 | bool sb_view_release(View *view); |
| ... | @@ -77,6 +81,8 @@ bool sb_view_undo(View *view, bool redo); | ... | @@ -77,6 +81,8 @@ bool sb_view_undo(View *view, bool redo); |
| 77 | bool sb_view_can_undo(const View *view, bool redo); | 81 | bool sb_view_can_undo(const View *view, bool redo); |
| 78 | uint64_t sb_view_format(const View *view); | 82 | uint64_t sb_view_format(const View *view); |
| 79 | bool sb_view_apply(View *view, uint8_t command); | 83 | bool sb_view_apply(View *view, uint8_t command); |
| 84 | bool sb_view_style(View *view, uint8_t place); | ||
| 85 | int32_t sb_view_style_place(const View *view); | ||
| 80 | char *sb_view_correction(const View *view); | 86 | char *sb_view_correction(const View *view); |
| 81 | bool sb_view_correct(View *view, uint8_t command, const char *text); | 87 | bool sb_view_correct(View *view, uint8_t command, const char *text); |
| 82 | char *sb_view_title(const View *view); | 88 | char *sb_view_title(const View *view); |
| ... | @@ -92,6 +98,8 @@ char *sb_view_paper(const View *view); | ... | @@ -92,6 +98,8 @@ char *sb_view_paper(const View *view); |
| 92 | bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); | 98 | bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); |
| 93 | bool sb_view_set_art(View *view, const char *name); | 99 | bool sb_view_set_art(View *view, const char *name); |
| 94 | char *sb_view_page_text(const View *view); | 100 | char *sb_view_page_text(const View *view); |
| 101 | uint8_t *sb_view_pdf(const View *view, float width, float height, const char *section, size_t *length); | ||
| 102 | void sb_bytes_free(uint8_t *bytes, size_t length); | ||
| 95 | int8_t sb_view_date_request(View *view, int64_t *seconds); | 103 | int8_t sb_view_date_request(View *view, int64_t *seconds); |
| 96 | bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time); | 104 | bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time); |
| 97 | bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height); | 105 | bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height); |
crates/mobile/src/lib.rs+132-3| ... | @@ -122,6 +122,26 @@ struct Canvas { | ... | @@ -122,6 +122,26 @@ struct Canvas { |
| 122 | } | 122 | } |
| 123 | 123 | ||
| 124 | impl Canvas { | 124 | impl Canvas { |
| 125 | /// The page on `paper` as OneNote 2010 prints it, footers naming `section`. | ||
| 126 | fn pdf(&self, paper: [f32; 2], section: &str) -> Option<Vec<u8>> { | ||
| 127 | let page = self.page.editor.page().ok()?; | ||
| 128 | let mut engine = ENGINES | ||
| 129 | .lock() | ||
| 130 | .ok() | ||
| 131 | .and_then(|mut engines| engines.pop()) | ||
| 132 | .unwrap_or_default(); | ||
| 133 | let setup = canvas::print::Setup { | ||
| 134 | paper, | ||
| 135 | fit_width: true, | ||
| 136 | footer: canvas::print::Footer::SectionAndPage, | ||
| 137 | }; | ||
| 138 | let pdf = canvas::print::pdf(vec![(section.to_owned(), vec![page])], &mut engine, &setup); | ||
| 139 | if let Ok(mut engines) = ENGINES.lock() { | ||
| 140 | engines.push(engine); | ||
| 141 | } | ||
| 142 | pdf.ok() | ||
| 143 | } | ||
| 144 | |||
| 125 | fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> { | 145 | fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> { |
| 126 | let mut engine = ENGINES | 146 | let mut engine = ENGINES |
| 127 | .lock() | 147 | .lock() |
| ... | @@ -139,6 +159,7 @@ impl Canvas { | ... | @@ -139,6 +159,7 @@ impl Canvas { |
| 139 | ); | 159 | ); |
| 140 | page.touch = true; | 160 | page.touch = true; |
| 141 | page.host_viewport = true; | 161 | page.host_viewport = true; |
| 162 | (page.snap_to_grid, page.editor.default_font) = options(); | ||
| 142 | Ok(Self { | 163 | Ok(Self { |
| 143 | page, | 164 | page, |
| 144 | space, | 165 | space, |
| ... | @@ -235,7 +256,7 @@ impl Canvas { | ... | @@ -235,7 +256,7 @@ impl Canvas { |
| 235 | return Target::Grip; | 256 | return Target::Grip; |
| 236 | } | 257 | } |
| 237 | match self.page.hit(device) { | 258 | match self.page.hit(device) { |
| 238 | Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, | 259 | Some(Hit::Handle { .. } | Hit::Resize { .. } | Hit::Column { .. }) => Target::Grip, |
| 239 | Some(Hit::Image { id, .. }) | 260 | Some(Hit::Image { id, .. }) |
| 240 | if self.page.object_focus() == Some(ObjectFocus::Image(id)) => | 261 | if self.page.object_focus() == Some(ObjectFocus::Image(id)) => |
| 241 | { | 262 | { |
| ... | @@ -612,6 +633,40 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> { | ... | @@ -612,6 +633,40 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> { |
| 612 | /// rather than enumerating the system's fonts again. | 633 | /// rather than enumerating the system's fonts again. |
| 613 | static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new()); | 634 | static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new()); |
| 614 | 635 | ||
| 636 | /// The reader's Snap To Grid and Default font, which every page opened from then on takes, | ||
| 637 | /// and new pages' titles the font's face. | ||
| 638 | static OPTIONS: Mutex<Option<(bool, canvas::editor::DefaultFont)>> = Mutex::new(None); | ||
| 639 | |||
| 640 | fn options() -> (bool, canvas::editor::DefaultFont) { | ||
| 641 | OPTIONS | ||
| 642 | .lock() | ||
| 643 | .unwrap_or_else(|error| error.into_inner()) | ||
| 644 | .clone() | ||
| 645 | .unwrap_or((true, canvas::editor::DefaultFont::default())) | ||
| 646 | } | ||
| 647 | |||
| 648 | /// OneNote's Snap To Grid: taps, drags and shapes land on the placement grid. | ||
| 649 | #[unsafe(no_mangle)] | ||
| 650 | pub extern "C" fn sb_set_snap_to_grid(on: bool) { | ||
| 651 | let font = options().1; | ||
| 652 | *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((on, font)); | ||
| 653 | } | ||
| 654 | |||
| 655 | /// OneNote's Default font: new text in `face` at `size` points, new titles in `face`. | ||
| 656 | /// | ||
| 657 | /// # Safety | ||
| 658 | /// `face` is NUL-terminated UTF-8. | ||
| 659 | #[unsafe(no_mangle)] | ||
| 660 | pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { | ||
| 661 | let snap = options().0; | ||
| 662 | let font = canvas::editor::DefaultFont { | ||
| 663 | face: string(face), | ||
| 664 | size, | ||
| 665 | color: None, | ||
| 666 | }; | ||
| 667 | *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((snap, font)); | ||
| 668 | } | ||
| 669 | |||
| 615 | pub struct View { | 670 | pub struct View { |
| 616 | /// Taken apart on drop, which returns its text engine to `ENGINES`. | 671 | /// Taken apart on drop, which returns its text engine to `ENGINES`. |
| 617 | canvas: ManuallyDrop<Canvas>, | 672 | canvas: ManuallyDrop<Canvas>, |
| ... | @@ -668,9 +723,24 @@ impl View { | ... | @@ -668,9 +723,24 @@ impl View { |
| 668 | .get_default_config(&gpu.adapter, pixels[0], pixels[1]) | 723 | .get_default_config(&gpu.adapter, pixels[0], pixels[1]) |
| 669 | .ok_or("No supported surface configuration")?; | 724 | .ok_or("No supported surface configuration")?; |
| 670 | surface.configure(gpu.renderer.device(), &config); | 725 | surface.configure(gpu.renderer.device(), &config); |
| 671 | let (page, read_only) = section.shared.page(space)?; | 726 | let (mut page, read_only) = section.shared.page(space)?; |
| 727 | // The page wears its theme: style objects it gives otherwise are restyled first. | ||
| 728 | let sheet = section | ||
| 729 | .theme(page.identity) | ||
| 730 | .map(|theme| theme.sheet()) | ||
| 731 | .unwrap_or_default(); | ||
| 732 | let restyle = onestore::op::restyle(&page, &sheet)?; | ||
| 733 | if !read_only && !restyle.is_empty() { | ||
| 734 | section.shared.apply(Edit { | ||
| 735 | at: library::filetime(), | ||
| 736 | ops: restyle | ||
| 737 | .into_iter() | ||
| 738 | .map(|op| Op::Page { space, op }) | ||
| 739 | .collect(), | ||
| 740 | })?; | ||
| 741 | page = section.shared.page(space)?.0; | ||
| 742 | } | ||
| 672 | let frame = Arc::<Frame>::default(); | 743 | let frame = Arc::<Frame>::default(); |
| 673 | #[cfg_attr(not(target_os = "ios"), expect(unused_mut))] | ||
| 674 | let mut canvas = Canvas::new(space, page, pixels, scale)?; | 744 | let mut canvas = Canvas::new(space, page, pixels, scale)?; |
| 675 | #[cfg(target_os = "ios")] | 745 | #[cfg(target_os = "ios")] |
| 676 | { | 746 | { |
| ... | @@ -679,6 +749,7 @@ impl View { | ... | @@ -679,6 +749,7 @@ impl View { |
| 679 | Waker::from(frame.clone()), | 749 | Waker::from(frame.clone()), |
| 680 | )); | 750 | )); |
| 681 | } | 751 | } |
| 752 | canvas.page.editor.styles = sheet; | ||
| 682 | Ok(Self { | 753 | Ok(Self { |
| 683 | canvas: ManuallyDrop::new(canvas), | 754 | canvas: ManuallyDrop::new(canvas), |
| 684 | surface, | 755 | surface, |
| ... | @@ -967,6 +1038,37 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { | ... | @@ -967,6 +1038,37 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { |
| 967 | view.stored(result) | 1038 | view.stored(result) |
| 968 | } | 1039 | } |
| 969 | 1040 | ||
| 1041 | /// Gives the selected paragraphs style `place` of OneNote's Styles gallery (Heading 1 to 6, | ||
| 1042 | /// Page Title, Citation, Quote, Code, Normal) in the page's theme, or OneNote 2010's. | ||
| 1043 | #[unsafe(no_mangle)] | ||
| 1044 | pub extern "C" fn sb_view_style(view: &mut View, place: u8) -> bool { | ||
| 1045 | use notebook::sidecar::themes::{STYLES, built_in, definition}; | ||
| 1046 | let Some((name, _)) = STYLES.get(usize::from(place)) else { | ||
| 1047 | return false; | ||
| 1048 | }; | ||
| 1049 | let editor = &view.canvas.page.editor; | ||
| 1050 | let style = editor.styles.get(*name).cloned().unwrap_or_else(|| { | ||
| 1051 | let theme = built_in().swap_remove(0); | ||
| 1052 | definition(name, &theme.styles[*name]) | ||
| 1053 | }); | ||
| 1054 | let result = view | ||
| 1055 | .canvas | ||
| 1056 | .page | ||
| 1057 | .format(canvas::editor::Formatting::Style(style)) | ||
| 1058 | .map(moved); | ||
| 1059 | view.stored(result) | ||
| 1060 | } | ||
| 1061 | |||
| 1062 | /// The place in the Styles gallery of the style every selected paragraph shares, or -1. | ||
| 1063 | #[unsafe(no_mangle)] | ||
| 1064 | pub extern "C" fn sb_view_style_place(view: &View) -> i32 { | ||
| 1065 | let state = view.canvas.page.editor.format_state().unwrap_or_default(); | ||
| 1066 | notebook::sidecar::themes::STYLES | ||
| 1067 | .iter() | ||
| 1068 | .position(|(name, _)| state.style.as_deref() == Some(*name)) | ||
| 1069 | .map_or(-1, |place| place as i32) | ||
| 1070 | } | ||
| 1071 | |||
| 970 | /// The marked word at the caret, or selected, as JSON `{"word", "suggestions", | 1072 | /// The marked word at the caret, or selected, as JSON `{"word", "suggestions", |
| 971 | /// "repeated"}`; null where there is none. | 1073 | /// "repeated"}`; null where there is none. |
| 972 | #[unsafe(no_mangle)] | 1074 | #[unsafe(no_mangle)] |
| ... | @@ -1241,6 +1343,33 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { | ... | @@ -1241,6 +1343,33 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { |
| 1241 | owned(view.canvas.page_text()) | 1343 | owned(view.canvas.page_text()) |
| 1242 | } | 1344 | } |
| 1243 | 1345 | ||
| 1346 | /// The page on paper `width` × `height` points as OneNote 2010 prints it, each sheet's | ||
| 1347 | /// footer naming `section`: `length` bytes of PDF, freed with `sb_bytes_free`, or null. | ||
| 1348 | /// | ||
| 1349 | /// # Safety | ||
| 1350 | /// `section` is NUL-terminated UTF-8. | ||
| 1351 | #[unsafe(no_mangle)] | ||
| 1352 | pub unsafe extern "C" fn sb_view_pdf( | ||
| 1353 | view: &View, | ||
| 1354 | width: f32, | ||
| 1355 | height: f32, | ||
| 1356 | section: *const c_char, | ||
| 1357 | length: &mut usize, | ||
| 1358 | ) -> *mut u8 { | ||
| 1359 | let Some(pdf) = view.canvas.pdf([width, height], &string(section)) else { | ||
| 1360 | return std::ptr::null_mut(); | ||
| 1361 | }; | ||
| 1362 | *length = pdf.len(); | ||
| 1363 | Box::into_raw(pdf.into_boxed_slice()).cast() | ||
| 1364 | } | ||
| 1365 | |||
| 1366 | /// # Safety | ||
| 1367 | /// `bytes` and `length` came from this library and are not used again. | ||
| 1368 | #[unsafe(no_mangle)] | ||
| 1369 | pub unsafe extern "C" fn sb_bytes_free(bytes: *mut u8, length: usize) { | ||
| 1370 | drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, length)) }); | ||
| 1371 | } | ||
| 1372 | |||
| 1244 | /// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret: | 1373 | /// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret: |
| 1245 | /// splitting its paragraph, or on the page at a caret on blank page. | 1374 | /// splitting its paragraph, or on the page at a caret on blank page. |
| 1246 | /// | 1375 | /// |
crates/mobile/src/library.rs+127-4| ... | @@ -706,12 +706,62 @@ pub(crate) fn filetime() -> u64 { | ... | @@ -706,12 +706,62 @@ pub(crate) fn filetime() -> u64 { |
| 706 | } | 706 | } |
| 707 | 707 | ||
| 708 | /// A page OneNote 2010 would create now, titled `date` and `time` as the host formats | 708 | /// A page OneNote 2010 would create now, titled `date` and `time` as the host formats |
| 709 | /// them: the long date and the short time. | 709 | /// them: the long date and the short time; titled in the Default font's face. |
| 710 | fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> { | 710 | fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> { |
| 711 | Ok(PageCreation::new(None, Some(""), author)?.dated(date, time)?) | 711 | let font = crate::options().1; |
| 712 | Ok(PageCreation::new(None, Some(""), author)? | ||
| 713 | .titled_in(&font.face, font.color)? | ||
| 714 | .dated(date, time)?) | ||
| 712 | } | 715 | } |
| 713 | 716 | ||
| 714 | impl Section { | 717 | impl Section { |
| 718 | /// The notebook's themes; none for a lone section. | ||
| 719 | pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes { | ||
| 720 | let notebook = self | ||
| 721 | .library | ||
| 722 | .notebook | ||
| 723 | .lock() | ||
| 724 | .unwrap_or_else(|error| error.into_inner()); | ||
| 725 | notebook | ||
| 726 | .as_ref() | ||
| 727 | .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into))) | ||
| 728 | .unwrap_or_default() | ||
| 729 | } | ||
| 730 | |||
| 731 | /// Merges `change` into the notebook's themes. | ||
| 732 | pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> { | ||
| 733 | let notebook = self | ||
| 734 | .library | ||
| 735 | .notebook | ||
| 736 | .lock() | ||
| 737 | .unwrap_or_else(|error| error.into_inner()); | ||
| 738 | notebook | ||
| 739 | .as_ref() | ||
| 740 | .ok_or("A section on its own keeps no themes")? | ||
| 741 | .save_themes(change)?; | ||
| 742 | Ok(()) | ||
| 743 | } | ||
| 744 | |||
| 745 | /// The theme scopes of page `id`: the page, its section and the notebook, where known. | ||
| 746 | pub(crate) fn scopes(&self, id: &str) -> Result<[Option<notebook::sidecar::themes::Scope>; 3]> { | ||
| 747 | use notebook::sidecar::themes::Scope; | ||
| 748 | let (page, _) = self.shared.page(id.parse()?)?; | ||
| 749 | Ok([ | ||
| 750 | page.identity.map(Scope::page), | ||
| 751 | self.shared.section.identity().ok().map(Scope::section), | ||
| 752 | Some(Scope::Notebook), | ||
| 753 | ]) | ||
| 754 | } | ||
| 755 | |||
| 756 | /// The theme page `identity` of this section wears, if any scope names one. | ||
| 757 | pub(crate) fn theme( | ||
| 758 | &self, | ||
| 759 | identity: Option<[u8; 16]>, | ||
| 760 | ) -> Option<notebook::sidecar::themes::Theme> { | ||
| 761 | let section = self.shared.section.identity().ok(); | ||
| 762 | self.themes().effective(section, identity) | ||
| 763 | } | ||
| 764 | |||
| 715 | /// The section at catalog `path` of `library`, its edits naming `author`. | 765 | /// The section at catalog `path` of `library`, its edits naming `author`. |
| 716 | pub(crate) fn open( | 766 | pub(crate) fn open( |
| 717 | library: Arc<Library>, | 767 | library: Arc<Library>, |
| ... | @@ -862,8 +912,7 @@ impl Section { | ... | @@ -862,8 +912,7 @@ impl Section { |
| 862 | /// nothing waits to be published. | 912 | /// nothing waits to be published. |
| 863 | pub(crate) fn flush(&self, limit: Duration) -> bool { | 913 | pub(crate) fn flush(&self, limit: Duration) -> bool { |
| 864 | let section = &self.shared.section; | 914 | let section = &self.shared.section; |
| 865 | // The section thread answers in order, so an answer means earlier edits are stored. | 915 | if report(section.written().map_err(Into::into)).is_none() { |
| 866 | if report(section.pages().map_err(Into::into)).is_none() { | ||
| 867 | return false; | 916 | return false; |
| 868 | } | 917 | } |
| 869 | let deadline = Instant::now() + limit; | 918 | let deadline = Instant::now() + limit; |
| ... | @@ -1598,6 +1647,80 @@ pub unsafe extern "C" fn sb_section_new_page( | ... | @@ -1598,6 +1647,80 @@ pub unsafe extern "C" fn sb_section_new_page( |
| 1598 | report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string())) | 1647 | report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string())) |
| 1599 | } | 1648 | } |
| 1600 | 1649 | ||
| 1650 | /// The notebook's themes for page `id` as JSON: `themes`, each with `id` and `name`, then | ||
| 1651 | /// the id each of `page`, `section` and `notebook` names itself, or null. | ||
| 1652 | /// | ||
| 1653 | /// # Safety | ||
| 1654 | /// `id` is NUL-terminated UTF-8. | ||
| 1655 | #[unsafe(no_mangle)] | ||
| 1656 | pub unsafe extern "C" fn sb_section_themes(section: &Section, id: *const c_char) -> *mut c_char { | ||
| 1657 | use notebook::sidecar::themes::Scope; | ||
| 1658 | #[derive(serde::Serialize)] | ||
| 1659 | struct Listed { | ||
| 1660 | id: String, | ||
| 1661 | name: String, | ||
| 1662 | } | ||
| 1663 | #[derive(serde::Serialize)] | ||
| 1664 | struct Shown { | ||
| 1665 | themes: Vec<Listed>, | ||
| 1666 | page: Option<String>, | ||
| 1667 | section: Option<String>, | ||
| 1668 | notebook: Option<String>, | ||
| 1669 | } | ||
| 1670 | let shown = (|| { | ||
| 1671 | let scopes = section.scopes(&string(id))?; | ||
| 1672 | let themes = section.themes(); | ||
| 1673 | let named = |scope: &Option<Scope>| themes.assigned(scope.as_ref()?).map(|theme| theme.id); | ||
| 1674 | Ok(Shown { | ||
| 1675 | themes: themes | ||
| 1676 | .all() | ||
| 1677 | .into_iter() | ||
| 1678 | .map(|theme| Listed { | ||
| 1679 | id: theme.id, | ||
| 1680 | name: theme.name, | ||
| 1681 | }) | ||
| 1682 | .collect(), | ||
| 1683 | page: named(&scopes[0]), | ||
| 1684 | section: named(&scopes[1]), | ||
| 1685 | notebook: named(&scopes[2]), | ||
| 1686 | }) | ||
| 1687 | })(); | ||
| 1688 | json(shown) | ||
| 1689 | } | ||
| 1690 | |||
| 1691 | /// Gives theme `theme` (null for none of its own) to page `id` (`scope` 0), its section (1) | ||
| 1692 | /// or its notebook (2); the page wears it when next opened. Writes to the notebook, so call | ||
| 1693 | /// it off the main thread. | ||
| 1694 | /// | ||
| 1695 | /// # Safety | ||
| 1696 | /// Every string is NUL-terminated UTF-8; `theme` may be null. | ||
| 1697 | #[unsafe(no_mangle)] | ||
| 1698 | pub unsafe extern "C" fn sb_section_set_theme( | ||
| 1699 | section: &Section, | ||
| 1700 | id: *const c_char, | ||
| 1701 | scope: u8, | ||
| 1702 | theme: *const c_char, | ||
| 1703 | ) -> bool { | ||
| 1704 | use notebook::sidecar::themes::{Assignment, Themes}; | ||
| 1705 | let set = (|| { | ||
| 1706 | let scope = section | ||
| 1707 | .scopes(&string(id))? | ||
| 1708 | .into_iter() | ||
| 1709 | .nth(usize::from(scope)) | ||
| 1710 | .flatten() | ||
| 1711 | .ok_or("No such scope")?; | ||
| 1712 | section.save_themes(Themes { | ||
| 1713 | assignments: vec![Assignment { | ||
| 1714 | scope, | ||
| 1715 | theme: optional(theme), | ||
| 1716 | assigned: filetime(), | ||
| 1717 | }], | ||
| 1718 | ..Default::default() | ||
| 1719 | }) | ||
| 1720 | })(); | ||
| 1721 | report(set).is_some() | ||
| 1722 | } | ||
| 1723 | |||
| 1601 | /// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section | 1724 | /// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section |
| 1602 | /// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it | 1725 | /// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it |
| 1603 | /// off the main thread. | 1726 | /// off the main thread. |
crates/mobile/src/tests.rs+17| ... | @@ -1088,3 +1088,20 @@ fn closing_refuses_while_an_edit_cannot_publish_then_deletes_nothing_pending() { | ... | @@ -1088,3 +1088,20 @@ fn closing_refuses_while_an_edit_cannot_publish_then_deletes_nothing_pending() { |
| 1088 | std::fs::remove_dir_all(&root).unwrap(); | 1088 | std::fs::remove_dir_all(&root).unwrap(); |
| 1089 | assert!(eventually(|| replicas(&directory) == 0)); | 1089 | assert!(eventually(|| replicas(&directory) == 0)); |
| 1090 | } | 1090 | } |
| 1091 | |||
| 1092 | /// Print and Export as PDF make a PDF of the open page on the paper asked for. | ||
| 1093 | #[test] | ||
| 1094 | fn the_page_prints_as_a_pdf() { | ||
| 1095 | let (_directory, section) = features(); | ||
| 1096 | let title = section.shared.section.pages().unwrap()[0].1.clone(); | ||
| 1097 | let pdf = canvas(&section, &title) | ||
| 1098 | .pdf(canvas::print::A4, "Features") | ||
| 1099 | .unwrap(); | ||
| 1100 | assert!(pdf.starts_with(b"%PDF")); | ||
| 1101 | let text = String::from_utf8_lossy(&pdf); | ||
| 1102 | assert!( | ||
| 1103 | text.contains("/MediaBox [0 0 595.276 841.89]"), | ||
| 1104 | "{}", | ||
| 1105 | &text[..400] | ||
| 1106 | ); | ||
| 1107 | } |
crates/notebook/README.md+26-7| ... | @@ -56,6 +56,9 @@ the files its folders list otherwise than when last opened (`discover::Cache`, k | ... | @@ -56,6 +56,9 @@ the files its folders list otherwise than when last opened (`discover::Cache`, k |
| 56 | a replica named by the file's location and the section's document identity, so the same | 56 | a replica named by the file's location and the section's document identity, so the same |
| 57 | file reopens the same queue after a relaunch and a copy of it elsewhere has its own | 57 | file reopens the same queue after a relaunch and a copy of it elsewhere has its own |
| 58 | (`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves). | 58 | (`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves). |
| 59 | `Notebook::section(path, notify)` opens a mounted notebook's section; a replica already | ||
| 60 | named by the identity its discovery read resumes without reading the file, which the | ||
| 61 | worker checks next, as on a share. | ||
| 59 | A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains | 62 | A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains |
| 60 | remote changes (`Changed(spaces)`), publication outcomes, unreachable files and | 63 | remote changes (`Changed(spaces)`), publication outcomes, unreachable files and |
| 61 | failures; `notify` runs on a background thread whenever an event waits. | 64 | failures; `notify` runs on a background thread whenever an event waits. |
| ... | @@ -294,7 +297,8 @@ marked `copy`, have replicas of their own, and are never placed or listed anew b | ... | @@ -294,7 +297,8 @@ marked `copy`, have replicas of their own, and are never placed or listed anew b |
| 294 | operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups | 297 | operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups |
| 295 | with one TOC identity, the one its parent's TOC names, else the newest, lists; the others | 298 | with one TOC identity, the one its parent's TOC names, else the newest, lists; the others |
| 296 | list in `unavailable` as `Reason::Copy`. iOS's | 299 | list in `unavailable` as `Reason::Copy`. iOS's |
| 297 | `.Name.one.icloud` placeholders list there under their real names as `Reason::Evicted`. | 300 | `.Name.one.icloud` placeholders, and macOS's dataless files, list there under their real names |
| 301 | as `Reason::Evicted`, unless listed unchanged since discovery last read them. | ||
| 298 | Retain the last accepted catalog if discovery fails; a connection failure does not mean | 302 | Retain the last accepted catalog if discovery fails; a connection failure does not mean |
| 299 | files were deleted. | 303 | files were deleted. |
| 300 | 304 | ||
| ... | @@ -307,16 +311,18 @@ directly from the core document model. | ... | @@ -307,16 +311,18 @@ directly from the core document model. |
| 307 | ## Notebook structure | 311 | ## Notebook structure |
| 308 | 312 | ||
| 309 | `Notebook::create`, `create_section`, `create_group`, `rename`, `move_entry`, | 313 | `Notebook::create`, `create_section`, `create_group`, `rename`, `move_entry`, |
| 310 | `set_section_color`, `reorder` and `delete` change a notebook the way OneNote does: the | 314 | `set_section_color`, `set_color`, `reorder` and `delete` change a notebook the way OneNote does: the |
| 311 | table of contents (`Open Notebook.onetoc2`, created when a folder has none) gains, | 315 | table of contents (`Open Notebook.onetoc2`, created when a folder has none) gains, |
| 312 | renames, reorders or loses entries; a new notebook holds "New Section 1", and a new | 316 | renames, reorders or loses entries; a new notebook holds "New Section 1", and a new |
| 313 | section an empty section's file plus the page its `PageCreation` makes, in OneNote's | 317 | section an empty section's file plus the page its `PageCreation` makes, in OneNote's |
| 314 | new-section colour order; a section's colour lives in its own metadata; a deleted | 318 | new-section colour order; a section's colour lives in its own metadata, the notebook's |
| 319 | in its root table of contents (`corpus/section-color`); a deleted | ||
| 315 | section moves into `OneNote_RecycleBin`, a group with its own TOC, and a deleted group's | 320 | section moves into `OneNote_RecycleBin`, a group with its own TOC, and a deleted group's |
| 316 | sections move there too before its folders go. `recycle_pages` keeps copies of pages a | 321 | sections move there too before its folders go. `recycle_pages` keeps copies of pages a |
| 317 | section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their | 322 | section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their |
| 318 | identities, titles, dates and creation times, as OneNote does | 323 | identities, titles, dates and creation times, as OneNote does |
| 319 | (`corpus/notebook-management`). A bin or deleted-pages file its TOC does not list is | 324 | (`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as |
| 325 | OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is | ||
| 320 | placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an | 326 | placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an |
| 321 | unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still | 327 | unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still |
| 322 | lists for a file gone from its folder gives way to the file an edit gives its name. | 328 | lists for a file gone from its folder gives way to the file an edit gives its name. |
| ... | @@ -368,14 +374,16 @@ notebook. | ... | @@ -368,14 +374,16 @@ notebook. |
| 368 | `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which | 374 | `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which |
| 369 | OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it | 375 | OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it |
| 370 | through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a | 376 | through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a |
| 371 | dot name hidden without storing it) or macOS's `UF_HIDDEN`, which an smbfs mount passes | 377 | dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's |
| 372 | to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook | 378 | `UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook |
| 373 | without the folder is whole. | 379 | without the folder is whole. |
| 374 | 380 | ||
| 375 | ```text | 381 | ```text |
| 376 | .snowbound/ | 382 | .snowbound/ |
| 377 | ├ tags.json [{ name, shape, art, mapped }]: a tag's name and symbol → its art | 383 | ├ tags.json [{ name, shape, art, mapped }]: a tag's name and symbol → its art |
| 378 | └ tags/ <SHA-256>.png | .svg, written through a temporary name, never rewritten | 384 | ├ tags/ <SHA-256>.png | .svg, written through a temporary name, never rewritten |
| 385 | └ themes.json { themes: [{ id, name, styles, modified, deleted }], | ||
| 386 | assignments: [{ scope, theme, assigned }] }: style themes and who wears them | ||
| 379 | ``` | 387 | ``` |
| 380 | 388 | ||
| 381 | `Notebook::map_tag_art(name, shape, bytes, extension)` makes the folder and hides it, | 389 | `Notebook::map_tag_art(name, shape, bytes, extension)` makes the folder and hides it, |
| ... | @@ -387,6 +395,17 @@ one side's new tag until that side maps it again. `tag_art` reads the mappings, | ... | @@ -387,6 +395,17 @@ one side's new tag until that side maps it again. `tag_art` reads the mappings, |
| 387 | over entries that name no picture, and `tag_art_file` returns a picture once its bytes | 395 | over entries that name no picture, and `tag_art_file` returns a picture once its bytes |
| 388 | match its name. | 396 | match its name. |
| 389 | 397 | ||
| 398 | `sidecar::themes` keeps style themes: each gives OneNote 2010's eleven gallery styles | ||
| 399 | (`STYLES`: `h1`…`h6`, `PageTitle`, `cite`, `blockquote`, `code`, `p`) a font, size, | ||
| 400 | weight, slant, colour and spacing, and `Theme::sheet` turns them into the paragraph style | ||
| 401 | definitions a page stores. `built_in()` lists the shipped themes, which never change once | ||
| 402 | shipped. A scope is the notebook, a section by file identity or a page by its | ||
| 403 | notebook-management identity; `Themes::effective(section, page)` takes the page's, else the | ||
| 404 | section's, else the notebook's. `Notebook::themes` reads the file and `save_themes(change)` | ||
| 405 | merges a change in as `map_tag_art` does: themes by id and assignments by scope, the later | ||
| 406 | timestamp winning, then the greater entry. `onestore::op::restyle(page, sheet)` gives the | ||
| 407 | `Restyle` ops that put a page in a sheet. | ||
| 408 | |||
| 390 | ## SMB (feature `smb`) | 409 | ## SMB (feature `smb`) |
| 391 | 410 | ||
| 392 | `notebook::smb` provides blocking SMB access for OneNote sections and | 411 | `notebook::smb` provides blocking SMB access for OneNote sections and |
crates/notebook/src/discover.rs+24-14| ... | @@ -93,6 +93,8 @@ pub struct Toc { | ... | @@ -93,6 +93,8 @@ pub struct Toc { |
| 93 | pub file_id: [u8; 16], | 93 | pub file_id: [u8; 16], |
| 94 | /// Stale or unavailable TOC references; these are not inferred active sections. | 94 | /// Stale or unavailable TOC references; these are not inferred active sections. |
| 95 | pub unresolved: Vec<TocReference>, | 95 | pub unresolved: Vec<TocReference>, |
| 96 | /// The notebook's colour as a COLORREF, which only a notebook's own TOC holds. | ||
| 97 | pub color: Option<u32>, | ||
| 96 | } | 98 | } |
| 97 | 99 | ||
| 98 | #[derive(Debug, Clone, Serialize, Deserialize)] | 100 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| ... | @@ -108,8 +110,8 @@ pub enum EntryKind { | ... | @@ -108,8 +110,8 @@ pub enum EntryKind { |
| 108 | File, | 110 | File, |
| 109 | Directory, | 111 | Directory, |
| 110 | Other, | 112 | Other, |
| 111 | /// A notebook file kept elsewhere and not yet on this device, as iOS lists one that | 113 | /// A notebook file kept elsewhere and not yet on this device, as iCloud Drive's evicted |
| 112 | /// iCloud Drive evicted. | 114 | /// files list: a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file. |
| 113 | Evicted, | 115 | Evicted, |
| 114 | } | 116 | } |
| 115 | 117 | ||
| ... | @@ -164,7 +166,10 @@ enum Held { | ... | @@ -164,7 +166,10 @@ enum Held { |
| 164 | document: [u8; 16], | 166 | document: [u8; 16], |
| 165 | }, | 167 | }, |
| 166 | Locked, | 168 | Locked, |
| 167 | Toc(Vec<TocReference>), | 169 | Toc { |
| 170 | unresolved: Vec<TocReference>, | ||
| 171 | color: Option<u32>, | ||
| 172 | }, | ||
| 168 | } | 173 | } |
| 169 | 174 | ||
| 170 | impl Cache { | 175 | impl Cache { |
| ... | @@ -380,9 +385,14 @@ fn scan( | ... | @@ -380,9 +385,14 @@ fn scan( |
| 380 | } else { | 385 | } else { |
| 381 | continue; | 386 | continue; |
| 382 | }; | 387 | }; |
| 383 | // Without an evicted TOC the folder lists in path order; nothing writes a second one, | 388 | let reused = cached |
| 384 | // as its placeholder blocks creating it. | 389 | .get(&child) |
| 385 | if entry.kind == EntryKind::Evicted { | 390 | .filter(|known| known.listed == entry.listed) |
| 391 | .and_then(|known| Some((known.file_id()?, known))); | ||
| 392 | // An evicted file listed as it was last read lists as then. Without an evicted TOC the | ||
| 393 | // folder lists in path order; nothing writes a second one, as its placeholder, or the | ||
| 394 | // dataless file itself, blocks creating it. | ||
| 395 | if entry.kind == EntryKind::Evicted && reused.is_none() { | ||
| 386 | if expected == FileType::Section { | 396 | if expected == FileType::Section { |
| 387 | result.unavailable.push(Unavailable { | 397 | result.unavailable.push(Unavailable { |
| 388 | path: child, | 398 | path: child, |
| ... | @@ -393,15 +403,11 @@ fn scan( | ... | @@ -393,15 +403,11 @@ fn scan( |
| 393 | } | 403 | } |
| 394 | continue; | 404 | continue; |
| 395 | } | 405 | } |
| 396 | if entry.kind != EntryKind::File | 406 | if !matches!(entry.kind, EntryKind::File | EntryKind::Evicted) |
| 397 | || (expected == FileType::TableOfContents && result.toc.is_some()) | 407 | || (expected == FileType::TableOfContents && result.toc.is_some()) |
| 398 | { | 408 | { |
| 399 | return Err(Error::Entry { path: child }); | 409 | return Err(Error::Entry { path: child }); |
| 400 | } | 410 | } |
| 401 | let reused = cached | ||
| 402 | .get(&child) | ||
| 403 | .filter(|known| known.listed == entry.listed) | ||
| 404 | .and_then(|known| Some((known.file_id()?, known))); | ||
| 405 | let (file_id, held) = match reused { | 411 | let (file_id, held) = match reused { |
| 406 | Some((file_id, known)) => { | 412 | Some((file_id, known)) => { |
| 407 | found.read.insert(child.clone(), known.clone()); | 413 | found.read.insert(child.clone(), known.clone()); |
| ... | @@ -522,11 +528,12 @@ fn scan( | ... | @@ -522,11 +528,12 @@ fn scan( |
| 522 | state: SectionState::Locked, | 528 | state: SectionState::Locked, |
| 523 | copy: false, | 529 | copy: false, |
| 524 | }), | 530 | }), |
| 525 | Ok(Held::Toc(unresolved)) => { | 531 | Ok(Held::Toc { unresolved, color }) => { |
| 526 | result.toc = Some(Toc { | 532 | result.toc = Some(Toc { |
| 527 | filename: entry.name.clone(), | 533 | filename: entry.name.clone(), |
| 528 | file_id, | 534 | file_id, |
| 529 | unresolved, | 535 | unresolved, |
| 536 | color, | ||
| 530 | }) | 537 | }) |
| 531 | } | 538 | } |
| 532 | Err(error) if expected == FileType::Section => result.sections.push(Section { | 539 | Err(error) if expected == FileType::Section => result.sections.push(Section { |
| ... | @@ -751,7 +758,7 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> { | ... | @@ -751,7 +758,7 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> { |
| 751 | }); | 758 | }); |
| 752 | } | 759 | } |
| 753 | index.validate_current()?; | 760 | index.validate_current()?; |
| 754 | let Some(Kind::Toc { entries, .. }) = root(1).map(|node| &node.kind) else { | 761 | let Some(Kind::Toc { entries, color, .. }) = root(1).map(|node| &node.kind) else { |
| 755 | return Err(onestore::Error { | 762 | return Err(onestore::Error { |
| 756 | offset: 0, | 763 | offset: 0, |
| 757 | message: "Missing notebook TOC root", | 764 | message: "Missing notebook TOC root", |
| ... | @@ -784,7 +791,10 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> { | ... | @@ -784,7 +791,10 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> { |
| 784 | order: *order, | 791 | order: *order, |
| 785 | }); | 792 | }); |
| 786 | } | 793 | } |
| 787 | Ok(Held::Toc(unresolved)) | 794 | Ok(Held::Toc { |
| 795 | unresolved, | ||
| 796 | color: *color, | ||
| 797 | }) | ||
| 788 | } | 798 | } |
| 789 | 799 | ||
| 790 | /// Why a file or group that failed lists as unavailable; `None` fails the whole discovery, | 800 | /// Why a file or group that failed lists as unavailable; `None` fails the whole discovery, |
crates/notebook/src/discover/source.rs+18| ... | @@ -12,6 +12,22 @@ fn evicted(name: &str) -> Option<&str> { | ... | @@ -12,6 +12,22 @@ fn evicted(name: &str) -> Option<&str> { |
| 12 | (lower.ends_with(".one") || lower.ends_with(".onetoc2")).then_some(real) | 12 | (lower.ends_with(".one") || lower.ends_with(".onetoc2")).then_some(real) |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | /// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and | ||
| 16 | /// later list a file iCloud Drive evicted (`SF_DATALESS`). | ||
| 17 | fn dataless(metadata: &std::fs::Metadata) -> bool { | ||
| 18 | const SF_DATALESS: u32 = 0x4000_0000; | ||
| 19 | #[cfg(target_os = "macos")] | ||
| 20 | let flags = std::os::macos::fs::MetadataExt::st_flags(metadata); | ||
| 21 | #[cfg(target_os = "ios")] | ||
| 22 | let flags = std::os::ios::fs::MetadataExt::st_flags(metadata); | ||
| 23 | #[cfg(not(any(target_os = "macos", target_os = "ios")))] | ||
| 24 | let flags = { | ||
| 25 | let _ = metadata; | ||
| 26 | 0 | ||
| 27 | }; | ||
| 28 | flags & SF_DATALESS != 0 | ||
| 29 | } | ||
| 30 | |||
| 15 | /// Where iOS lists the file at `path` while iCloud Drive keeps it elsewhere. | 31 | /// Where iOS lists the file at `path` while iCloud Drive keeps it elsewhere. |
| 16 | pub(crate) fn placeholder(path: &Path) -> Option<PathBuf> { | 32 | pub(crate) fn placeholder(path: &Path) -> Option<PathBuf> { |
| 17 | let name = path.file_name()?.to_str()?; | 33 | let name = path.file_name()?.to_str()?; |
| ... | @@ -62,6 +78,8 @@ impl Source for Local { | ... | @@ -62,6 +78,8 @@ impl Source for Local { |
| 62 | name, | 78 | name, |
| 63 | kind: if kind.is_dir() { | 79 | kind: if kind.is_dir() { |
| 64 | EntryKind::Directory | 80 | EntryKind::Directory |
| 81 | } else if kind.is_file() && dataless(&metadata) { | ||
| 82 | EntryKind::Evicted | ||
| 65 | } else if kind.is_file() { | 83 | } else if kind.is_file() { |
| 66 | EntryKind::File | 84 | EntryKind::File |
| 67 | } else { | 85 | } else { |
crates/notebook/src/lib.rs+7-1| ... | @@ -313,10 +313,16 @@ impl Replica { | ... | @@ -313,10 +313,16 @@ impl Replica { |
| 313 | /// The section image the queued edits leave, the unsealed ones sealed as one more | 313 | /// The section image the queued edits leave, the unsealed ones sealed as one more |
| 314 | /// revision whose identities differ per call: O(section). | 314 | /// revision whose identities differ per call: O(section). |
| 315 | pub(crate) fn snapshot(&self) -> Result<Vec<u8>> { | 315 | pub(crate) fn snapshot(&self) -> Result<Vec<u8>> { |
| 316 | self.ask(|reply| working::Request::Flush { reply })?; | 316 | self.written()?; |
| 317 | working::image(&*self.lock()?) | 317 | working::image(&*self.lock()?) |
| 318 | } | 318 | } |
| 319 | 319 | ||
| 320 | /// Waits until the edits applied before it are written to the queue, as reads answer | ||
| 321 | /// before an open burst of edits is. | ||
| 322 | pub fn written(&self) -> Result<()> { | ||
| 323 | self.ask(|reply| working::Request::Flush { reply }) | ||
| 324 | } | ||
| 325 | |||
| 320 | /// The section file's identity, which internal links name as `section-id`. | 326 | /// The section file's identity, which internal links name as `section-id`. |
| 321 | pub fn identity(&self) -> Result<[u8; 16]> { | 327 | pub fn identity(&self) -> Result<[u8; 16]> { |
| 322 | let stamp = base::stamp(&*self.lock()?, base::Image::Base)? | 328 | let stamp = base::stamp(&*self.lock()?, base::Image::Base)? |
crates/notebook/src/merge.rs+23-2| ... | @@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> { | ... | @@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> { |
| 153 | | PageOp::Equation { text, .. } | 153 | | PageOp::Equation { text, .. } |
| 154 | | PageOp::Split { text, .. } => vec![*text], | 154 | | PageOp::Split { text, .. } => vec![*text], |
| 155 | PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(), | 155 | PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(), |
| 156 | PageOp::Color(_) | PageOp::RuleLines(_) => Vec::new(), | 156 | PageOp::Color(_) | PageOp::RuleLines(_) | PageOp::Restyle { .. } => Vec::new(), |
| 157 | PageOp::Join { left, right } => vec![*left, *right], | 157 | PageOp::Join { left, right } => vec![*left, *right], |
| 158 | PageOp::Insert { container, .. } => vec![*container], | 158 | PageOp::Insert { container, .. } => vec![*container], |
| 159 | PageOp::Move { object, .. } | 159 | PageOp::Move { object, .. } |
| ... | @@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> { | ... | @@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> { |
| 164 | PageOp::Level { paragraph, .. } | 164 | PageOp::Level { paragraph, .. } |
| 165 | | PageOp::Paragraph { paragraph, .. } | 165 | | PageOp::Paragraph { paragraph, .. } |
| 166 | | PageOp::Style { paragraph, .. } | 166 | | PageOp::Style { paragraph, .. } |
| 167 | | PageOp::Unstyle { paragraph } | ||
| 167 | | PageOp::Media { paragraph, .. } | 168 | | PageOp::Media { paragraph, .. } |
| 168 | | PageOp::List { paragraph, .. } => vec![*paragraph], | 169 | | PageOp::List { paragraph, .. } => vec![*paragraph], |
| 169 | PageOp::Tags { target, .. } => vec![*target], | 170 | PageOp::Tags { target, .. } => vec![*target], |
| ... | @@ -770,11 +771,19 @@ impl Diff { | ... | @@ -770,11 +771,19 @@ impl Diff { |
| 770 | } | 771 | } |
| 771 | PageOp::Paragraph { paragraph, .. } | 772 | PageOp::Paragraph { paragraph, .. } |
| 772 | | PageOp::Style { paragraph, .. } | 773 | | PageOp::Style { paragraph, .. } |
| 774 | | PageOp::Unstyle { paragraph } | ||
| 773 | | PageOp::Media { paragraph, .. } | 775 | | PageOp::Media { paragraph, .. } |
| 774 | | PageOp::List { paragraph, .. } => { | 776 | | PageOp::List { paragraph, .. } => { |
| 775 | require(self.kept(*paragraph) && self.new_has(*paragraph))? | 777 | require(self.kept(*paragraph) && self.new_has(*paragraph))? |
| 776 | } | 778 | } |
| 777 | PageOp::Tags { target, .. } => require(self.same_tags(*target))?, | 779 | PageOp::Tags { target, .. } => require(self.same_tags(*target))?, |
| 780 | // A theme reaches the style's paragraphs as the remote left them; a style the | ||
| 781 | // remote no longer uses is restyled when the page next opens. | ||
| 782 | PageOp::Restyle { style, .. } => { | ||
| 783 | if self.old.styles.contains(style) && !self.new.styles.contains(style) { | ||
| 784 | return Ok(None); | ||
| 785 | } | ||
| 786 | } | ||
| 778 | PageOp::Picture { | 787 | PageOp::Picture { |
| 779 | picture: object, .. | 788 | picture: object, .. |
| 780 | } | 789 | } |
| ... | @@ -1079,6 +1088,8 @@ impl Node { | ... | @@ -1079,6 +1088,8 @@ impl Node { |
| 1079 | struct Index { | 1088 | struct Index { |
| 1080 | nodes: BTreeMap<ExGuid, Node>, | 1089 | nodes: BTreeMap<ExGuid, Node>, |
| 1081 | page: Vec<ExGuid>, | 1090 | page: Vec<ExGuid>, |
| 1091 | /// The paragraph styles the page's paragraphs use. | ||
| 1092 | styles: BTreeSet<ExGuid>, | ||
| 1082 | } | 1093 | } |
| 1083 | 1094 | ||
| 1084 | const PAGE: ExGuid = ExGuid { | 1095 | const PAGE: ExGuid = ExGuid { |
| ... | @@ -1088,7 +1099,17 @@ const PAGE: ExGuid = ExGuid { | ... | @@ -1088,7 +1099,17 @@ const PAGE: ExGuid = ExGuid { |
| 1088 | 1099 | ||
| 1089 | impl Index { | 1100 | impl Index { |
| 1090 | fn of(page: &Page) -> Self { | 1101 | fn of(page: &Page) -> Self { |
| 1091 | let mut index = Self::default(); | 1102 | let mut index = Self { |
| 1103 | styles: page | ||
| 1104 | .definitions | ||
| 1105 | .iter() | ||
| 1106 | .filter(|(_, definition)| { | ||
| 1107 | matches!(definition.kind, onestore::document::Kind::Style { .. }) | ||
| 1108 | }) | ||
| 1109 | .map(|(id, _)| *id) | ||
| 1110 | .collect(), | ||
| 1111 | ..Self::default() | ||
| 1112 | }; | ||
| 1092 | for object in &page.objects { | 1113 | for object in &page.objects { |
| 1093 | index.page.push(object.id()); | 1114 | index.page.push(object.id()); |
| 1094 | match object { | 1115 | match object { |
crates/notebook/src/queue.rs+3| ... | @@ -282,6 +282,9 @@ mod tests { | ... | @@ -282,6 +282,9 @@ mod tests { |
| 282 | alt: None, | 282 | alt: None, |
| 283 | background: false, | 283 | background: false, |
| 284 | printout: None, | 284 | printout: None, |
| 285 | tags: Vec::new(), | ||
| 286 | link: None, | ||
| 287 | text: None, | ||
| 285 | }) | 288 | }) |
| 286 | }; | 289 | }; |
| 287 | let space = ExGuid { | 290 | let space = ExGuid { |
crates/notebook/src/session.rs+134| ... | @@ -332,6 +332,8 @@ const LIMITS: discover::Limits = discover::Limits { | ... | @@ -332,6 +332,8 @@ const LIMITS: discover::Limits = discover::Limits { |
| 332 | 332 | ||
| 333 | const TOC: &str = "Open Notebook.onetoc2"; | 333 | const TOC: &str = "Open Notebook.onetoc2"; |
| 334 | const RECYCLE_BIN: &str = "OneNote_RecycleBin"; | 334 | const RECYCLE_BIN: &str = "OneNote_RecycleBin"; |
| 335 | /// How long OneNote 2010 keeps what its recycle bin holds, its `DaysToKeepRecycledItems`. | ||
| 336 | pub const RECYCLE_DAYS: u32 = 60; | ||
| 335 | 337 | ||
| 336 | /// A notebook's files and the cache directory holding its section replicas. | 338 | /// A notebook's files and the cache directory holding its section replicas. |
| 337 | pub struct Notebook { | 339 | pub struct Notebook { |
| ... | @@ -488,6 +490,20 @@ impl Notebook { | ... | @@ -488,6 +490,20 @@ impl Notebook { |
| 488 | crate::sidecar::map(&*self.storage, name, shape, bytes, extension) | 490 | crate::sidecar::map(&*self.storage, name, shape, bytes, extension) |
| 489 | } | 491 | } |
| 490 | 492 | ||
| 493 | /// The notebook's style themes and which theme each scope takes (`sidecar::themes`). | ||
| 494 | pub fn themes(&self) -> Result<crate::sidecar::themes::Themes> { | ||
| 495 | crate::sidecar::themes::read(&*self.storage) | ||
| 496 | } | ||
| 497 | |||
| 498 | /// Merges `change` into the notebook's themes, making its hidden `.snowbound` folder | ||
| 499 | /// where it has none; returns the themes as they then stand. | ||
| 500 | pub fn save_themes( | ||
| 501 | &self, | ||
| 502 | change: crate::sidecar::themes::Themes, | ||
| 503 | ) -> Result<crate::sidecar::themes::Themes> { | ||
| 504 | crate::sidecar::themes::write(&*self.storage, change) | ||
| 505 | } | ||
| 506 | |||
| 491 | /// Rereads the notebook and reports what changed since the last catalog, keyed by | 507 | /// Rereads the notebook and reports what changed since the last catalog, keyed by |
| 492 | /// file identity so a renamed or moved section stays the same section. A failed read | 508 | /// file identity so a renamed or moved section stays the same section. A failed read |
| 493 | /// keeps the previous catalog: an unreachable notebook is not an empty one. | 509 | /// keeps the previous catalog: an unreachable notebook is not an empty one. |
| ... | @@ -742,6 +758,13 @@ impl Notebook { | ... | @@ -742,6 +758,13 @@ impl Notebook { |
| 742 | Ok(()) | 758 | Ok(()) |
| 743 | } | 759 | } |
| 744 | 760 | ||
| 761 | /// Sets the notebook's colour (COLORREF) in its root table of contents, as OneNote 2010's | ||
| 762 | /// Notebook Properties does (`corpus/section-color`). | ||
| 763 | pub fn set_color(&mut self, color: u32) -> Result<()> { | ||
| 764 | self.edit_toc("", &[onestore::TocEdit::Color(color)])?; | ||
| 765 | self.refresh().map(drop) | ||
| 766 | } | ||
| 767 | |||
| 745 | /// Orders a folder's sections and groups; entries left out follow in their current order. | 768 | /// Orders a folder's sections and groups; entries left out follow in their current order. |
| 746 | pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> { | 769 | pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> { |
| 747 | let mut identities = Vec::new(); | 770 | let mut identities = Vec::new(); |
| ... | @@ -932,6 +955,102 @@ impl Notebook { | ... | @@ -932,6 +955,102 @@ impl Notebook { |
| 932 | self.refresh().map(drop) | 955 | self.refresh().map(drop) |
| 933 | } | 956 | } |
| 934 | 957 | ||
| 958 | /// Takes the pages `identities` names out of the recycle bin's Deleted Pages in one | ||
| 959 | /// revision, as OneNote 2010 does when Undo brings deleted pages back. Pages not there | ||
| 960 | /// are passed over. | ||
| 961 | pub fn unrecycle_pages(&mut self, identities: &[[u8; 16]]) -> Result<()> { | ||
| 962 | let path = catalog_path(RECYCLE_BIN, "OneNote_DeletedPages.one"); | ||
| 963 | if !self.storage.exists(&path) { | ||
| 964 | return Ok(()); | ||
| 965 | } | ||
| 966 | let bytes = self.storage.read(&path)?; | ||
| 967 | let binned: Vec<ExGuid> = stored_pages(&bytes)? | ||
| 968 | .into_iter() | ||
| 969 | .filter(|stored| { | ||
| 970 | stored | ||
| 971 | .page | ||
| 972 | .identity | ||
| 973 | .is_some_and(|id| identities.contains(&id)) | ||
| 974 | }) | ||
| 975 | .map(|stored| stored.space) | ||
| 976 | .collect(); | ||
| 977 | if binned.is_empty() { | ||
| 978 | return Ok(()); | ||
| 979 | } | ||
| 980 | let arena = onestore::Arena::default(); | ||
| 981 | let mut section = onestore::Section::open(&arena, bytes)?; | ||
| 982 | section.apply( | ||
| 983 | "", | ||
| 984 | &Edit { | ||
| 985 | at: crate::now(), | ||
| 986 | ops: vec![Op::Section(SectionOp::Delete(binned))], | ||
| 987 | }, | ||
| 988 | )?; | ||
| 989 | if let Some(transaction) = section.seal()? { | ||
| 990 | self.storage.commit(&path, &transaction)?; | ||
| 991 | } | ||
| 992 | self.refresh().map(drop) | ||
| 993 | } | ||
| 994 | |||
| 995 | /// Deletes for good what OneNote 2010 prunes from the recycle bin at `now`, Time32 seconds | ||
| 996 | /// since 1980: each page of Deleted Pages last changed over `RECYCLE_DAYS` before, in one | ||
| 997 | /// revision, and each binned section none of whose pages changed since | ||
| 998 | /// (`corpus/recycle-purge`). OneNote judges by the pages' own last change, not when they | ||
| 999 | /// were deleted, and leaves a pruned section's entry in the bin's TOC, as this does. | ||
| 1000 | /// Returns how many pages and sections went. | ||
| 1001 | pub fn purge_recycle_bin(&mut self, now: u32) -> Result<usize> { | ||
| 1002 | const DELETED: &str = "OneNote_DeletedPages.one"; | ||
| 1003 | let expired = |modified: Option<u32>| { | ||
| 1004 | modified.is_some_and(|modified| now.saturating_sub(modified) > RECYCLE_DAYS * 86_400) | ||
| 1005 | }; | ||
| 1006 | let Ok(bin) = self.folder(RECYCLE_BIN) else { | ||
| 1007 | return Ok(0); | ||
| 1008 | }; | ||
| 1009 | let sections: Vec<String> = bin | ||
| 1010 | .sections | ||
| 1011 | .iter() | ||
| 1012 | .map(|section| section.path.clone()) | ||
| 1013 | .collect(); | ||
| 1014 | let mut purged = 0; | ||
| 1015 | for path in sections { | ||
| 1016 | let bytes = self.storage.read(&path)?; | ||
| 1017 | // A protected section, or one the model cannot read, stays. | ||
| 1018 | let Ok(pages) = stored_pages(&bytes) else { | ||
| 1019 | continue; | ||
| 1020 | }; | ||
| 1021 | if split(&path).1.eq_ignore_ascii_case(DELETED) { | ||
| 1022 | let old: Vec<ExGuid> = pages | ||
| 1023 | .iter() | ||
| 1024 | .filter(|page| expired(page.modified)) | ||
| 1025 | .map(|page| page.space) | ||
| 1026 | .collect(); | ||
| 1027 | if old.is_empty() { | ||
| 1028 | continue; | ||
| 1029 | } | ||
| 1030 | let arena = onestore::Arena::default(); | ||
| 1031 | let mut section = onestore::Section::open(&arena, bytes)?; | ||
| 1032 | section.apply( | ||
| 1033 | "", | ||
| 1034 | &Edit { | ||
| 1035 | at: crate::now(), | ||
| 1036 | ops: vec![Op::Section(SectionOp::Delete(old.clone()))], | ||
| 1037 | }, | ||
| 1038 | )?; | ||
| 1039 | if let Some(transaction) = section.seal()? { | ||
| 1040 | self.storage.commit(&path, &transaction)?; | ||
| 1041 | } | ||
| 1042 | purged += old.len(); | ||
| 1043 | } else if !pages.is_empty() && pages.iter().all(|page| expired(page.modified)) { | ||
| 1044 | self.storage.delete(&path)?; | ||
| 1045 | purged += 1; | ||
| 1046 | } | ||
| 1047 | } | ||
| 1048 | if purged > 0 { | ||
| 1049 | self.refresh()?; | ||
| 1050 | } | ||
| 1051 | Ok(purged) | ||
| 1052 | } | ||
| 1053 | |||
| 935 | /// Moves a section or section group into the folder at catalog path `folder`, last, as | 1054 | /// Moves a section or section group into the folder at catalog path `folder`, last, as |
| 936 | /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's | 1055 | /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's |
| 937 | /// entry follows it. Returns its new catalog path. | 1056 | /// entry follows it. Returns its new catalog path. |
| ... | @@ -1222,6 +1341,16 @@ impl Notebook { | ... | @@ -1222,6 +1341,16 @@ impl Notebook { |
| 1222 | if !file.starts_with(root) { | 1341 | if !file.starts_with(root) { |
| 1223 | return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); | 1342 | return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); |
| 1224 | } | 1343 | } |
| 1344 | // A replica the catalog's identity names resumes without reading the file, which its | ||
| 1345 | // worker checks next, as a share's does. | ||
| 1346 | if let discover::SectionState::Readable { document, .. } = &section.state { | ||
| 1347 | let cache = replica_file(&replicas, document); | ||
| 1348 | if cache.exists() { | ||
| 1349 | let (replica, remote, mut connect) = | ||
| 1350 | (Replica::open(&cache)?, file.clone(), connect); | ||
| 1351 | return Section::start(file, replica, move || connect(&remote), notify); | ||
| 1352 | } | ||
| 1353 | } | ||
| 1225 | let replica = |identity: &[u8; 16], _: &[u8]| { | 1354 | let replica = |identity: &[u8; 16], _: &[u8]| { |
| 1226 | std::fs::create_dir_all(&replicas)?; | 1355 | std::fs::create_dir_all(&replicas)?; |
| 1227 | Ok(replica_file(&replicas, identity)) | 1356 | Ok(replica_file(&replicas, identity)) |
| ... | @@ -1722,6 +1851,11 @@ impl Section { | ... | @@ -1722,6 +1851,11 @@ impl Section { |
| 1722 | self.replica.pending() | 1851 | self.replica.pending() |
| 1723 | } | 1852 | } |
| 1724 | 1853 | ||
| 1854 | /// See [`Replica::written`]. | ||
| 1855 | pub fn written(&self) -> Result<()> { | ||
| 1856 | self.replica.written() | ||
| 1857 | } | ||
| 1858 | |||
| 1725 | /// The conflict pages of each page that has them, as the local edits leave them | 1859 | /// The conflict pages of each page that has them, as the local edits leave them |
| 1726 | /// (`onestore::Section::conflicts`); `page` reads one, `delete_pages` removes it. | 1860 | /// (`onestore::Section::conflicts`); `page` reads one, `delete_pages` removes it. |
| 1727 | pub fn conflicts(&self) -> Result<Vec<(ExGuid, Vec<onestore::ConflictPage>)>> { | 1861 | pub fn conflicts(&self) -> Result<Vec<(ExGuid, Vec<onestore::ConflictPage>)>> { |
crates/notebook/src/sidecar.rs+2| ... | @@ -162,5 +162,7 @@ fn temporary(path: &str) -> String { | ... | @@ -162,5 +162,7 @@ fn temporary(path: &str) -> String { |
| 162 | format!("{path}.{unique:016x}.tmp") | 162 | format!("{path}.{unique:016x}.tmp") |
| 163 | } | 163 | } |
| 164 | 164 | ||
| 165 | pub mod themes; | ||
| 166 | |||
| 165 | #[cfg(test)] | 167 | #[cfg(test)] |
| 166 | mod tests; | 168 | mod tests; |
crates/notebook/src/sidecar/tests.rs+43-2| ... | @@ -265,8 +265,8 @@ fn unreadable_mappings_and_pictures_are_passed_over() { | ... | @@ -265,8 +265,8 @@ fn unreadable_mappings_and_pictures_are_passed_over() { |
| 265 | assert!(map(&folder, "Tag", 13, b"x", "gif").is_err()); | 265 | assert!(map(&folder, "Tag", 13, b"x", "gif").is_err()); |
| 266 | } | 266 | } |
| 267 | 267 | ||
| 268 | /// On this computer the folder takes the attribute macOS keeps, and the notebook's catalog | 268 | /// On this computer the folder takes the hidden attribute Windows or macOS keeps, and the |
| 269 | /// never lists it. | 269 | /// notebook's catalog never lists it. |
| 270 | #[test] | 270 | #[test] |
| 271 | fn a_notebook_folder_keeps_the_art_hidden() { | 271 | fn a_notebook_folder_keeps_the_art_hidden() { |
| 272 | let temporary = tempfile::tempdir().unwrap(); | 272 | let temporary = tempfile::tempdir().unwrap(); |
| ... | @@ -288,7 +288,48 @@ fn a_notebook_folder_keeps_the_art_hidden() { | ... | @@ -288,7 +288,48 @@ fn a_notebook_folder_keeps_the_art_hidden() { |
| 288 | let flags = FileFlag::from_bits_retain(stat(&folder).unwrap().st_flags); | 288 | let flags = FileFlag::from_bits_retain(stat(&folder).unwrap().st_flags); |
| 289 | assert!(flags.contains(FileFlag::UF_HIDDEN)); | 289 | assert!(flags.contains(FileFlag::UF_HIDDEN)); |
| 290 | } | 290 | } |
| 291 | #[cfg(windows)] | ||
| 292 | { | ||
| 293 | use std::os::windows::fs::MetadataExt; | ||
| 294 | // FILE_ATTRIBUTE_HIDDEN. | ||
| 295 | assert_ne!(folder.metadata().unwrap().file_attributes() & 2, 0); | ||
| 296 | } | ||
| 291 | notebook.refresh().unwrap(); | 297 | notebook.refresh().unwrap(); |
| 292 | assert!(notebook.catalog().groups.is_empty()); | 298 | assert!(notebook.catalog().groups.is_empty()); |
| 293 | assert_eq!(notebook.catalog().sections.len(), 1); | 299 | assert_eq!(notebook.catalog().sections.len(), 1); |
| 294 | } | 300 | } |
| 301 | |||
| 302 | /// Themes go in the hidden folder beside the tag art, and a writer another overtook merges | ||
| 303 | /// again until its change holds; entries it can't read are passed over, built-ins refused. | ||
| 304 | #[test] | ||
| 305 | fn themes_merge_into_the_hidden_folder() { | ||
| 306 | use themes::{Assignment, Scope, Themes}; | ||
| 307 | let folder = Folder::default(); | ||
| 308 | let assign = |scope, theme: &str, assigned| Themes { | ||
| 309 | assignments: vec![Assignment { | ||
| 310 | scope, | ||
| 311 | theme: Some(theme.into()), | ||
| 312 | assigned, | ||
| 313 | }], | ||
| 314 | ..Default::default() | ||
| 315 | }; | ||
| 316 | let theirs = assign(Scope::Notebook, "modern", 1); | ||
| 317 | *folder.race.lock().unwrap() = Some(serde_json::to_vec(&theirs).unwrap()); | ||
| 318 | let kept = themes::write(&folder, assign(Scope::section([7; 16]), "editorial", 2)).unwrap(); | ||
| 319 | assert!(folder.hidden.lock().unwrap().contains(".snowbound")); | ||
| 320 | assert_eq!(kept.assignments.len(), 2); | ||
| 321 | assert_eq!(kept, themes::read(&folder).unwrap()); | ||
| 322 | assert_eq!(kept.effective(Some([7; 16]), None).unwrap().id, "editorial"); | ||
| 323 | folder.files.lock().unwrap().insert( | ||
| 324 | ".snowbound/themes.json".into(), | ||
| 325 | br#"{"themes": [{"id": 3}], "assignments": [{"scope": "notebook", "theme": "manuscript", "assigned": 4}, "junk"]}"#.to_vec(), | ||
| 326 | ); | ||
| 327 | let read = themes::read(&folder).unwrap(); | ||
| 328 | assert!(read.themes.is_empty()); | ||
| 329 | assert_eq!(read.effective(None, None).unwrap().id, "manuscript"); | ||
| 330 | let built_in = Themes { | ||
| 331 | themes: vec![themes::built_in().remove(0)], | ||
| 332 | ..Default::default() | ||
| 333 | }; | ||
| 334 | assert!(themes::write(&folder, built_in).is_err()); | ||
| 335 | } |
crates/notebook/src/sidecar/themes.rs created+560| ... | @@ -0,0 +1,560 @@ | ||
| 1 | //! Style themes: `themes.json` in `.snowbound` keeps a notebook's own themes and which | ||
| 2 | //! theme its notebook, sections and pages take. A theme gives OneNote 2010's eleven gallery | ||
| 3 | //! styles their formatting; a page wears it as its paragraph style objects, so OneNote draws | ||
| 4 | //! the look and keeps the names (`resources/styles.md`). Writers merge as `tags.json`'s do: | ||
| 5 | //! themes by id and assignments by scope, the later timestamp winning, then the greater | ||
| 6 | //! entry, so writers agree whatever order they read in. | ||
| 7 | |||
| 8 | use crate::{Result, session::Storage}; | ||
| 9 | use onestore::{ | ||
| 10 | document::{Format, Kind}, | ||
| 11 | page::Definition, | ||
| 12 | }; | ||
| 13 | use serde::{Deserialize, Serialize}; | ||
| 14 | use std::{collections::BTreeMap, io}; | ||
| 15 | |||
| 16 | const FILE: &str = ".snowbound/themes.json"; | ||
| 17 | |||
| 18 | /// OneNote 2010's gallery, in its order: each style's stored name and the name it shows. | ||
| 19 | pub const STYLES: [(&str, &str); 11] = [ | ||
| 20 | ("h1", "Heading 1"), | ||
| 21 | ("h2", "Heading 2"), | ||
| 22 | ("h3", "Heading 3"), | ||
| 23 | ("h4", "Heading 4"), | ||
| 24 | ("h5", "Heading 5"), | ||
| 25 | ("h6", "Heading 6"), | ||
| 26 | ("PageTitle", "Page Title"), | ||
| 27 | ("cite", "Citation"), | ||
| 28 | ("blockquote", "Quote"), | ||
| 29 | ("code", "Code"), | ||
| 30 | ("p", "Normal"), | ||
| 31 | ]; | ||
| 32 | |||
| 33 | /// A style's look in a theme. | ||
| 34 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | ||
| 35 | pub struct ThemeStyle { | ||
| 36 | pub font: String, | ||
| 37 | /// Points, in half points. | ||
| 38 | pub size: f32, | ||
| 39 | pub bold: bool, | ||
| 40 | pub italic: bool, | ||
| 41 | /// `#rrggbb`, or none for automatic. | ||
| 42 | pub color: Option<String>, | ||
| 43 | /// Space above and below the paragraph, in points. | ||
| 44 | pub before: f32, | ||
| 45 | pub after: f32, | ||
| 46 | } | ||
| 47 | |||
| 48 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | ||
| 49 | pub struct Theme { | ||
| 50 | pub id: String, | ||
| 51 | pub name: String, | ||
| 52 | /// By stored style name; a theme names all eleven. | ||
| 53 | pub styles: BTreeMap<String, ThemeStyle>, | ||
| 54 | /// When it last changed, as a FILETIME; built-in themes have none. | ||
| 55 | #[serde(default)] | ||
| 56 | pub modified: u64, | ||
| 57 | #[serde(default)] | ||
| 58 | pub deleted: bool, | ||
| 59 | } | ||
| 60 | |||
| 61 | /// What a theme is assigned to. | ||
| 62 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] | ||
| 63 | #[serde(rename_all = "lowercase")] | ||
| 64 | pub enum Scope { | ||
| 65 | Notebook, | ||
| 66 | /// A section, by its file identity, which survives renames and moves. | ||
| 67 | Section(String), | ||
| 68 | /// A page, by its notebook-management identity. | ||
| 69 | Page(String), | ||
| 70 | } | ||
| 71 | |||
| 72 | impl Scope { | ||
| 73 | pub fn section(identity: [u8; 16]) -> Self { | ||
| 74 | Self::Section(hex(identity)) | ||
| 75 | } | ||
| 76 | |||
| 77 | pub fn page(identity: [u8; 16]) -> Self { | ||
| 78 | Self::Page(hex(identity)) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | fn hex(identity: [u8; 16]) -> String { | ||
| 83 | identity.iter().map(|byte| format!("{byte:02x}")).collect() | ||
| 84 | } | ||
| 85 | |||
| 86 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | ||
| 87 | pub struct Assignment { | ||
| 88 | pub scope: Scope, | ||
| 89 | /// None takes the scope's override away. | ||
| 90 | pub theme: Option<String>, | ||
| 91 | pub assigned: u64, | ||
| 92 | } | ||
| 93 | |||
| 94 | /// A notebook's themes file. | ||
| 95 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | ||
| 96 | pub struct Themes { | ||
| 97 | #[serde(default)] | ||
| 98 | pub themes: Vec<Theme>, | ||
| 99 | #[serde(default)] | ||
| 100 | pub assignments: Vec<Assignment>, | ||
| 101 | } | ||
| 102 | |||
| 103 | impl Themes { | ||
| 104 | /// The built-in themes, then the notebook's own that aren't deleted. | ||
| 105 | pub fn all(&self) -> Vec<Theme> { | ||
| 106 | let mut all = built_in(); | ||
| 107 | all.extend(self.themes.iter().filter(|theme| !theme.deleted).cloned()); | ||
| 108 | all | ||
| 109 | } | ||
| 110 | |||
| 111 | pub fn theme(&self, id: &str) -> Option<Theme> { | ||
| 112 | self.all().into_iter().find(|theme| theme.id == id) | ||
| 113 | } | ||
| 114 | |||
| 115 | /// The theme `scope` names itself, if one it can find. | ||
| 116 | pub fn assigned(&self, scope: &Scope) -> Option<Theme> { | ||
| 117 | let assignment = self.assignments.iter().find(|kept| kept.scope == *scope)?; | ||
| 118 | self.theme(assignment.theme.as_deref()?) | ||
| 119 | } | ||
| 120 | |||
| 121 | /// The theme a page wears: its own, else its section's, else the notebook's. | ||
| 122 | pub fn effective(&self, section: Option<[u8; 16]>, page: Option<[u8; 16]>) -> Option<Theme> { | ||
| 123 | page.map(Scope::page) | ||
| 124 | .into_iter() | ||
| 125 | .chain(section.map(Scope::section)) | ||
| 126 | .chain([Scope::Notebook]) | ||
| 127 | .find_map(|scope| self.assigned(&scope)) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | /// `into` with `other`'s entries merged by the rule the module describes. | ||
| 132 | pub fn merge(into: &mut Themes, other: Themes) { | ||
| 133 | fn later<T: Serialize>(new: &T, new_at: u64, kept: &T, kept_at: u64) -> bool { | ||
| 134 | let json = |value: &T| serde_json::to_string(value).unwrap_or_default(); | ||
| 135 | (new_at, json(new)) > (kept_at, json(kept)) | ||
| 136 | } | ||
| 137 | for theme in other.themes { | ||
| 138 | match into.themes.iter_mut().find(|kept| kept.id == theme.id) { | ||
| 139 | Some(kept) => { | ||
| 140 | if later(&theme, theme.modified, kept, kept.modified) { | ||
| 141 | *kept = theme; | ||
| 142 | } | ||
| 143 | } | ||
| 144 | None => into.themes.push(theme), | ||
| 145 | } | ||
| 146 | } | ||
| 147 | for assignment in other.assignments { | ||
| 148 | match into | ||
| 149 | .assignments | ||
| 150 | .iter_mut() | ||
| 151 | .find(|kept| kept.scope == assignment.scope) | ||
| 152 | { | ||
| 153 | Some(kept) => { | ||
| 154 | if later(&assignment, assignment.assigned, kept, kept.assigned) { | ||
| 155 | *kept = assignment; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | None => into.assignments.push(assignment), | ||
| 159 | } | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | /// The themes file: empty without one, and without entries it holds unreadably. | ||
| 164 | pub(crate) fn read(storage: &dyn Storage) -> Result<Themes> { | ||
| 165 | let bytes = match storage.read_file(FILE, super::LIMIT) { | ||
| 166 | Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::NotFound => { | ||
| 167 | return Ok(Themes::default()); | ||
| 168 | } | ||
| 169 | bytes => bytes?, | ||
| 170 | }; | ||
| 171 | let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default(); | ||
| 172 | let entries = |key: &str| -> Vec<serde_json::Value> { | ||
| 173 | value | ||
| 174 | .get(key) | ||
| 175 | .and_then(|list| list.as_array().cloned()) | ||
| 176 | .unwrap_or_default() | ||
| 177 | }; | ||
| 178 | let mut themes = Themes::default(); | ||
| 179 | merge( | ||
| 180 | &mut themes, | ||
| 181 | Themes { | ||
| 182 | themes: entries("themes") | ||
| 183 | .into_iter() | ||
| 184 | .filter_map(|entry| serde_json::from_value(entry).ok()) | ||
| 185 | .filter(|theme: &Theme| !is_built_in(&theme.id)) | ||
| 186 | .collect(), | ||
| 187 | assignments: entries("assignments") | ||
| 188 | .into_iter() | ||
| 189 | .filter_map(|entry| serde_json::from_value(entry).ok()) | ||
| 190 | .collect(), | ||
| 191 | }, | ||
| 192 | ); | ||
| 193 | Ok(themes) | ||
| 194 | } | ||
| 195 | |||
| 196 | /// Merges `change` into the notebook's themes file; returns the file as it then stands. | ||
| 197 | pub(crate) fn write(storage: &dyn Storage, change: Themes) -> Result<Themes> { | ||
| 198 | if change.themes.iter().any(|theme| is_built_in(&theme.id)) { | ||
| 199 | return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); | ||
| 200 | } | ||
| 201 | match storage.create_directory(super::FOLDER) { | ||
| 202 | Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} | ||
| 203 | created => created?, | ||
| 204 | } | ||
| 205 | storage.hide(super::FOLDER)?; | ||
| 206 | for _ in 0..3 { | ||
| 207 | let mut merged = read(storage)?; | ||
| 208 | merge(&mut merged, change.clone()); | ||
| 209 | let written = super::temporary(FILE); | ||
| 210 | let json = serde_json::to_vec_pretty(&merged).map_err(io::Error::from)?; | ||
| 211 | storage.create(&written, &json)?; | ||
| 212 | storage.replace(&written, FILE)?; | ||
| 213 | let kept = read(storage)?; | ||
| 214 | let mut held = kept.clone(); | ||
| 215 | merge(&mut held, change.clone()); | ||
| 216 | if held == kept { | ||
| 217 | return Ok(kept); | ||
| 218 | } | ||
| 219 | } | ||
| 220 | Err(io::Error::from(io::ErrorKind::ResourceBusy).into()) | ||
| 221 | } | ||
| 222 | |||
| 223 | fn is_built_in(id: &str) -> bool { | ||
| 224 | built_in().iter().any(|theme| theme.id == id) | ||
| 225 | } | ||
| 226 | |||
| 227 | /// Stored style `name`'s paragraph style as `style` gives it: every character flag set, as | ||
| 228 | /// OneNote 2010 writes its own, headings followed by Normal. | ||
| 229 | pub fn definition(name: &str, style: &ThemeStyle) -> Definition { | ||
| 230 | let color = style | ||
| 231 | .color | ||
| 232 | .as_deref() | ||
| 233 | .and_then(|hex| u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()) | ||
| 234 | .map_or(0xff00_0000, |rgb| { | ||
| 235 | (rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16) | ||
| 236 | }); | ||
| 237 | // Paragraph spacing is stored in half inches. | ||
| 238 | let stored = |points: f32| points / 36.0 * 36.0; | ||
| 239 | Definition { | ||
| 240 | kind: Kind::Style { | ||
| 241 | name: Some(name.into()), | ||
| 242 | next: matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6").then(|| "p".into()), | ||
| 243 | }, | ||
| 244 | format: Format { | ||
| 245 | bold: Some(style.bold), | ||
| 246 | italic: Some(style.italic), | ||
| 247 | underline: Some(false), | ||
| 248 | strike: Some(false), | ||
| 249 | superscript: Some(false), | ||
| 250 | subscript: Some(false), | ||
| 251 | font: Some(style.font.clone()), | ||
| 252 | font_size: Some((style.size * 2.0).round() / 2.0), | ||
| 253 | color: Some(color), | ||
| 254 | highlight: Some(0xff00_0000), | ||
| 255 | space_before: Some(stored(style.before)), | ||
| 256 | space_after: Some(stored(style.after)), | ||
| 257 | line_spacing: Some(0.0), | ||
| 258 | ..Format::default() | ||
| 259 | }, | ||
| 260 | } | ||
| 261 | } | ||
| 262 | |||
| 263 | impl Theme { | ||
| 264 | /// The paragraph style definitions the theme gives, by stored name. | ||
| 265 | pub fn sheet(&self) -> BTreeMap<String, Definition> { | ||
| 266 | self.styles | ||
| 267 | .iter() | ||
| 268 | .map(|(name, style)| (name.clone(), definition(name, style))) | ||
| 269 | .collect() | ||
| 270 | } | ||
| 271 | } | ||
| 272 | |||
| 273 | /// A style in `font` at `size`, `bold` and `italic`, in `color`, with spacing. | ||
| 274 | fn style( | ||
| 275 | font: &str, | ||
| 276 | size: f32, | ||
| 277 | [bold, italic]: [bool; 2], | ||
| 278 | color: Option<&str>, | ||
| 279 | [before, after]: [f32; 2], | ||
| 280 | ) -> ThemeStyle { | ||
| 281 | ThemeStyle { | ||
| 282 | font: font.into(), | ||
| 283 | size, | ||
| 284 | bold, | ||
| 285 | italic, | ||
| 286 | color: color.map(Into::into), | ||
| 287 | before, | ||
| 288 | after, | ||
| 289 | } | ||
| 290 | } | ||
| 291 | |||
| 292 | const PLAIN: [bool; 2] = [false, false]; | ||
| 293 | const BOLD: [bool; 2] = [true, false]; | ||
| 294 | const ITALIC: [bool; 2] = [false, true]; | ||
| 295 | const BOTH: [bool; 2] = [true, true]; | ||
| 296 | const NONE: [f32; 2] = [0.0; 2]; | ||
| 297 | |||
| 298 | /// The themes Snowbound ships. Once shipped a built-in never changes: two versions | ||
| 299 | /// disagreeing would restyle a page back and forth as each opens it. A changed look ships | ||
| 300 | /// under a new id. | ||
| 301 | pub fn built_in() -> Vec<Theme> { | ||
| 302 | let theme = |id: &str, name: &str, styles: [ThemeStyle; 11]| Theme { | ||
| 303 | id: id.into(), | ||
| 304 | name: name.into(), | ||
| 305 | styles: STYLES | ||
| 306 | .iter() | ||
| 307 | .zip(styles) | ||
| 308 | .map(|((stored, _), style)| ((*stored).into(), style)) | ||
| 309 | .collect(), | ||
| 310 | modified: 0, | ||
| 311 | deleted: false, | ||
| 312 | }; | ||
| 313 | let blue = Some("#366092"); | ||
| 314 | vec![ | ||
| 315 | // OneNote 2010's own (lab, 2026-09-30). | ||
| 316 | theme( | ||
| 317 | "onenote", | ||
| 318 | "OneNote", | ||
| 319 | [ | ||
| 320 | style("Calibri", 16.0, BOLD, Some("#17365D"), NONE), | ||
| 321 | style("Calibri", 13.0, BOLD, blue, NONE), | ||
| 322 | style("Calibri", 11.0, BOLD, blue, NONE), | ||
| 323 | style("Calibri", 11.0, BOTH, blue, NONE), | ||
| 324 | style("Calibri", 11.0, PLAIN, blue, NONE), | ||
| 325 | style("Calibri", 11.0, ITALIC, blue, NONE), | ||
| 326 | style("Calibri", 17.0, PLAIN, None, NONE), | ||
| 327 | style("Calibri", 9.0, PLAIN, Some("#595959"), NONE), | ||
| 328 | style("Calibri", 11.0, ITALIC, Some("#595959"), NONE), | ||
| 329 | style("Courier New", 11.0, PLAIN, None, NONE), | ||
| 330 | style("Calibri", 11.0, PLAIN, None, NONE), | ||
| 331 | ], | ||
| 332 | ), | ||
| 333 | theme( | ||
| 334 | "manuscript", | ||
| 335 | "Manuscript", | ||
| 336 | [ | ||
| 337 | style("Georgia", 20.0, PLAIN, Some("#3B2F2A"), [12.0, 4.0]), | ||
| 338 | style("Georgia", 16.0, BOLD, Some("#3B2F2A"), [10.0, 2.0]), | ||
| 339 | style("Georgia", 13.0, BOLD, Some("#3B2F2A"), [8.0, 2.0]), | ||
| 340 | style("Georgia", 12.0, BOTH, Some("#3B2F2A"), [6.0, 0.0]), | ||
| 341 | style("Georgia", 12.0, PLAIN, Some("#3B2F2A"), [6.0, 0.0]), | ||
| 342 | style("Georgia", 12.0, ITALIC, Some("#3B2F2A"), [6.0, 0.0]), | ||
| 343 | style("Georgia", 26.0, PLAIN, Some("#1F1A17"), NONE), | ||
| 344 | style("Georgia", 9.0, PLAIN, Some("#7A6E66"), NONE), | ||
| 345 | style("Georgia", 12.0, ITALIC, Some("#6B5E55"), [0.0, 4.0]), | ||
| 346 | style("Courier New", 10.5, PLAIN, Some("#2B2B2B"), NONE), | ||
| 347 | style("Georgia", 12.0, PLAIN, Some("#2B2B2B"), [0.0, 4.0]), | ||
| 348 | ], | ||
| 349 | ), | ||
| 350 | theme( | ||
| 351 | "editorial", | ||
| 352 | "Editorial", | ||
| 353 | [ | ||
| 354 | style("Georgia", 18.0, BOLD, Some("#9A3B1F"), [12.0, 2.0]), | ||
| 355 | style("Georgia", 14.0, BOLD, Some("#202020"), [10.0, 2.0]), | ||
| 356 | style("Georgia", 12.0, BOLD, Some("#202020"), [8.0, 0.0]), | ||
| 357 | style("Georgia", 11.0, BOTH, Some("#202020"), [6.0, 0.0]), | ||
| 358 | style("Georgia", 11.0, PLAIN, Some("#9A3B1F"), [6.0, 0.0]), | ||
| 359 | style("Georgia", 11.0, ITALIC, Some("#9A3B1F"), [6.0, 0.0]), | ||
| 360 | style("Georgia", 24.0, PLAIN, Some("#202020"), NONE), | ||
| 361 | style("Calibri", 9.0, PLAIN, Some("#707070"), NONE), | ||
| 362 | style("Georgia", 12.0, ITALIC, Some("#555555"), [0.0, 3.0]), | ||
| 363 | style("Courier New", 10.0, PLAIN, Some("#262626"), NONE), | ||
| 364 | style("Calibri", 11.5, PLAIN, Some("#262626"), [0.0, 3.0]), | ||
| 365 | ], | ||
| 366 | ), | ||
| 367 | theme( | ||
| 368 | "modern", | ||
| 369 | "Modern", | ||
| 370 | [ | ||
| 371 | style("Arial", 16.0, BOLD, Some("#0E6E6E"), [10.0, 2.0]), | ||
| 372 | style("Arial", 13.0, BOLD, Some("#1B2631"), [8.0, 2.0]), | ||
| 373 | style("Arial", 11.0, BOLD, Some("#1B2631"), [6.0, 0.0]), | ||
| 374 | style("Arial", 10.5, BOTH, Some("#1B2631"), [6.0, 0.0]), | ||
| 375 | style("Arial", 10.5, BOLD, Some("#0E6E6E"), [6.0, 0.0]), | ||
| 376 | style("Arial", 10.5, ITALIC, Some("#0E6E6E"), [6.0, 0.0]), | ||
| 377 | style("Arial", 22.0, BOLD, Some("#1B2631"), NONE), | ||
| 378 | style("Arial", 8.0, PLAIN, Some("#7B868C"), NONE), | ||
| 379 | style("Arial", 10.5, ITALIC, Some("#5F6B73"), [0.0, 3.0]), | ||
| 380 | style("Courier New", 10.0, PLAIN, Some("#1B2631"), NONE), | ||
| 381 | style("Arial", 10.5, PLAIN, Some("#2E2E2E"), [0.0, 3.0]), | ||
| 382 | ], | ||
| 383 | ), | ||
| 384 | ] | ||
| 385 | } | ||
| 386 | |||
| 387 | #[cfg(test)] | ||
| 388 | mod tests { | ||
| 389 | use super::*; | ||
| 390 | |||
| 391 | /// OneNote 2010's theme is what OneNote writes, as Snowbound reads it back, so a page | ||
| 392 | /// OneNote styled has nothing to restyle under it. | ||
| 393 | #[test] | ||
| 394 | fn the_onenote_theme_is_what_onenote_writes() { | ||
| 395 | let source = include_bytes!("../../../../corpus/styles/onenote/Styles.one"); | ||
| 396 | let store = onestore::Store::parse(source).unwrap(); | ||
| 397 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | ||
| 398 | let document = onestore::document::Document::parse(&index).unwrap(); | ||
| 399 | let (space, _) = document.pages().unwrap()[0]; | ||
| 400 | let page = onestore::page::Page::from_space(&document, space).unwrap(); | ||
| 401 | let sheet = built_in()[0].sheet(); | ||
| 402 | for (name, definition) in &sheet { | ||
| 403 | assert!( | ||
| 404 | page.definitions.values().any(|stored| stored == definition), | ||
| 405 | "{name}" | ||
| 406 | ); | ||
| 407 | } | ||
| 408 | // Only the heading pasted in from a page styled through COM differs. | ||
| 409 | let ops = onestore::op::restyle(&page, &sheet).unwrap(); | ||
| 410 | assert_eq!(ops.len(), 1); | ||
| 411 | let onestore::op::PageOp::Restyle { style, into, .. } = &ops[0] else { | ||
| 412 | unreachable!() | ||
| 413 | }; | ||
| 414 | assert_eq!( | ||
| 415 | page.definitions[style].format.font.as_deref(), | ||
| 416 | Some("Georgia") | ||
| 417 | ); | ||
| 418 | assert_eq!(page.definitions[into], sheet["h1"]); | ||
| 419 | } | ||
| 420 | |||
| 421 | /// OneNote 2010 on a page Snowbound dressed in Manuscript: Ctrl+Alt+2 and Enter add its | ||
| 422 | /// own `h2` and `p` beside the theme's (`corpus/styles/onenote-edit`). Snowbound reads | ||
| 423 | /// them under those names, and restyling heals both: one edit, every style the theme's. | ||
| 424 | #[test] | ||
| 425 | fn styles_onenote_adds_to_a_themed_page_are_healed() { | ||
| 426 | let source = include_bytes!("../../../../corpus/styles/onenote-edit/Manuscript.one"); | ||
| 427 | let arena = onestore::Arena::default(); | ||
| 428 | let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); | ||
| 429 | let (space, _, _) = section.pages().unwrap()[0]; | ||
| 430 | let page = section.page(space).unwrap(); | ||
| 431 | let sheet = built_in() | ||
| 432 | .into_iter() | ||
| 433 | .find(|theme| theme.id == "manuscript") | ||
| 434 | .unwrap() | ||
| 435 | .sheet(); | ||
| 436 | let calibri: Vec<&str> = page | ||
| 437 | .definitions | ||
| 438 | .values() | ||
| 439 | .filter_map(|stored| match &stored.kind { | ||
| 440 | Kind::Style { | ||
| 441 | name: Some(name), .. | ||
| 442 | } if stored.format.font.as_deref() == Some("Calibri") => Some(name.as_str()), | ||
| 443 | _ => None, | ||
| 444 | }) | ||
| 445 | .collect(); | ||
| 446 | assert_eq!(calibri, ["h2", "p"]); | ||
| 447 | let ops = onestore::op::restyle(&page, &sheet).unwrap(); | ||
| 448 | assert_eq!(ops.len(), 2); | ||
| 449 | let edit = onestore::op::Edit { | ||
| 450 | at: 134_000_000_000_000_000, | ||
| 451 | ops: ops | ||
| 452 | .into_iter() | ||
| 453 | .map(|op| onestore::op::Op::Page { space, op }) | ||
| 454 | .collect(), | ||
| 455 | }; | ||
| 456 | section.apply("Author", &edit).unwrap(); | ||
| 457 | section.seal().unwrap(); | ||
| 458 | let healed = onestore::Section::open(&arena, section.image()) | ||
| 459 | .unwrap() | ||
| 460 | .page(space) | ||
| 461 | .unwrap(); | ||
| 462 | for stored in healed.definitions.values() { | ||
| 463 | if let Kind::Style { | ||
| 464 | name: Some(name), .. | ||
| 465 | } = &stored.kind | ||
| 466 | { | ||
| 467 | assert_eq!(Some(stored), sheet.get(name), "{name}"); | ||
| 468 | } | ||
| 469 | } | ||
| 470 | assert!(onestore::op::restyle(&healed, &sheet).unwrap().is_empty()); | ||
| 471 | } | ||
| 472 | |||
| 473 | #[test] | ||
| 474 | fn every_built_in_theme_names_every_gallery_style() { | ||
| 475 | for theme in built_in() { | ||
| 476 | let sheet = theme.sheet(); | ||
| 477 | for (name, _) in STYLES { | ||
| 478 | assert!(sheet.contains_key(name), "{} {name}", theme.id); | ||
| 479 | } | ||
| 480 | } | ||
| 481 | } | ||
| 482 | |||
| 483 | /// A page takes its own theme, else its section's, else the notebook's; a scope naming a | ||
| 484 | /// deleted theme, or none, falls through. | ||
| 485 | #[test] | ||
| 486 | fn a_page_takes_the_nearest_theme() { | ||
| 487 | let (section, page) = ([1; 16], [2; 16]); | ||
| 488 | let assign = |scope, theme: Option<&str>, assigned| Assignment { | ||
| 489 | scope, | ||
| 490 | theme: theme.map(Into::into), | ||
| 491 | assigned, | ||
| 492 | }; | ||
| 493 | let mut custom = built_in().remove(1); | ||
| 494 | custom.id = "essay".into(); | ||
| 495 | custom.deleted = true; | ||
| 496 | let mut themes = Themes { | ||
| 497 | themes: vec![custom], | ||
| 498 | assignments: vec![assign(Scope::Notebook, Some("modern"), 1)], | ||
| 499 | }; | ||
| 500 | let effective = |themes: &Themes| themes.effective(Some(section), Some(page)).unwrap().id; | ||
| 501 | assert_eq!(effective(&themes), "modern"); | ||
| 502 | merge( | ||
| 503 | &mut themes, | ||
| 504 | Themes { | ||
| 505 | assignments: vec![ | ||
| 506 | assign(Scope::section(section), Some("editorial"), 2), | ||
| 507 | assign(Scope::page(page), Some("essay"), 2), | ||
| 508 | ], | ||
| 509 | ..Default::default() | ||
| 510 | }, | ||
| 511 | ); | ||
| 512 | assert_eq!(effective(&themes), "editorial"); | ||
| 513 | themes.themes[0].deleted = false; | ||
| 514 | assert_eq!(effective(&themes), "essay"); | ||
| 515 | merge( | ||
| 516 | &mut themes, | ||
| 517 | Themes { | ||
| 518 | assignments: vec![assign(Scope::page(page), None, 3)], | ||
| 519 | ..Default::default() | ||
| 520 | }, | ||
| 521 | ); | ||
| 522 | assert_eq!(effective(&themes), "editorial"); | ||
| 523 | assert_eq!(themes.effective(None, None).unwrap().id, "modern"); | ||
| 524 | } | ||
| 525 | |||
| 526 | /// Writers merging in either order agree: the later entry wins, then the greater. | ||
| 527 | #[test] | ||
| 528 | fn merges_agree_in_either_order() { | ||
| 529 | let theme = |name: &str, modified| { | ||
| 530 | let mut theme = built_in().remove(2); | ||
| 531 | theme.id = "mine".into(); | ||
| 532 | theme.name = name.into(); | ||
| 533 | theme.modified = modified; | ||
| 534 | theme | ||
| 535 | }; | ||
| 536 | let a = Themes { | ||
| 537 | themes: vec![theme("A", 5)], | ||
| 538 | assignments: vec![Assignment { | ||
| 539 | scope: Scope::Notebook, | ||
| 540 | theme: Some("mine".into()), | ||
| 541 | assigned: 7, | ||
| 542 | }], | ||
| 543 | }; | ||
| 544 | let b = Themes { | ||
| 545 | themes: vec![theme("B", 5)], | ||
| 546 | assignments: vec![Assignment { | ||
| 547 | scope: Scope::Notebook, | ||
| 548 | theme: None, | ||
| 549 | assigned: 9, | ||
| 550 | }], | ||
| 551 | }; | ||
| 552 | let mut ab = a.clone(); | ||
| 553 | merge(&mut ab, b.clone()); | ||
| 554 | let mut ba = b; | ||
| 555 | merge(&mut ba, a); | ||
| 556 | assert_eq!(ab, ba); | ||
| 557 | assert_eq!(ab.themes[0].name, "B"); | ||
| 558 | assert_eq!(ab.assignments[0].theme, None); | ||
| 559 | } | ||
| 560 | } | ||
crates/notebook/tests/discovery.rs+40| ... | @@ -197,6 +197,46 @@ fn evicted_files_list_under_their_names_until_downloaded() { | ... | @@ -197,6 +197,46 @@ fn evicted_files_list_under_their_names_until_downloaded() { |
| 197 | assert!(!root.join("Open Notebook.onetoc2").exists()); | 197 | assert!(!root.join("Open Notebook.onetoc2").exists()); |
| 198 | } | 198 | } |
| 199 | 199 | ||
| 200 | /// A source whose files list as macOS lists those iCloud Drive evicted: dataless, under their | ||
| 201 | /// names and listings, and never read. | ||
| 202 | struct Dataless(Local); | ||
| 203 | |||
| 204 | impl Source for Dataless { | ||
| 205 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> { | ||
| 206 | let mut entries = self.0.entries(path, limit)?; | ||
| 207 | for entry in &mut entries { | ||
| 208 | entry.kind = notebook::discover::EntryKind::Evicted; | ||
| 209 | } | ||
| 210 | Ok(entries) | ||
| 211 | } | ||
| 212 | |||
| 213 | fn read(&mut self, path: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 214 | panic!("read {path}, which is not on this device"); | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | /// A dataless file lists as its last read found it, where it lists unchanged since, and as not | ||
| 219 | /// downloaded otherwise. | ||
| 220 | #[test] | ||
| 221 | fn dataless_files_list_as_last_read_or_wait_for_their_download() { | ||
| 222 | let root = tempfile::tempdir().unwrap(); | ||
| 223 | fixture(root.path()); | ||
| 224 | let mut cache = Cache::default(); | ||
| 225 | cache | ||
| 226 | .discover(&mut Local::open(root.path()).unwrap(), limits()) | ||
| 227 | .unwrap(); | ||
| 228 | let catalog = cache | ||
| 229 | .discover(&mut Dataless(Local::open(root.path()).unwrap()), limits()) | ||
| 230 | .unwrap(); | ||
| 231 | assert_eq!(catalog.sections[0].path, "one.one"); | ||
| 232 | assert!(catalog.toc.is_some() && catalog.unavailable.is_empty()); | ||
| 233 | |||
| 234 | let catalog = discover(&mut Dataless(Local::open(root.path()).unwrap()), limits()).unwrap(); | ||
| 235 | assert!(catalog.sections.is_empty() && catalog.toc.is_none()); | ||
| 236 | assert_eq!(catalog.unavailable[0].path, "one.one"); | ||
| 237 | assert_eq!(catalog.unavailable[0].reason, Reason::Evicted); | ||
| 238 | } | ||
| 239 | |||
| 200 | #[test] | 240 | #[test] |
| 201 | fn locked_and_unreadable_sections_retain_their_storage_identity() { | 241 | fn locked_and_unreadable_sections_retain_their_storage_identity() { |
| 202 | let root = tempfile::tempdir().unwrap(); | 242 | let root = tempfile::tempdir().unwrap(); |
crates/notebook/tests/structure.rs+52| ... | @@ -336,3 +336,55 @@ fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadabl | ... | @@ -336,3 +336,55 @@ fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadabl |
| 336 | assert_eq!(names.len(), 2, "{names:?}"); | 336 | assert_eq!(names.len(), 2, "{names:?}"); |
| 337 | } | 337 | } |
| 338 | } | 338 | } |
| 339 | |||
| 340 | /// What the recycle bin holds goes for good once its pages last changed over 60 days | ||
| 341 | /// before, as OneNote 2010 prunes it; a pruned section's TOC entry stays, as OneNote leaves it | ||
| 342 | /// (`corpus/recycle-purge`). `NOTEBOOK_PURGE_EXPORT` names a new directory receiving the | ||
| 343 | /// purged notebook for a cold reopen. | ||
| 344 | #[test] | ||
| 345 | fn the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days() { | ||
| 346 | let temporary = tempfile::tempdir().unwrap(); | ||
| 347 | let root = temporary.path().join("notebook"); | ||
| 348 | let bin = root.join("OneNote_RecycleBin"); | ||
| 349 | let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap(); | ||
| 350 | let mut notebook = | ||
| 351 | Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap(); | ||
| 352 | let old = onestore::PageCreation::new(None, Some("Old page"), "Author").unwrap(); | ||
| 353 | notebook.create_section("", "Old", &old).unwrap(); | ||
| 354 | let image = notebook.read_section("New Section 1.one").unwrap(); | ||
| 355 | let pages = notebook::session::stored_pages(&image).unwrap(); | ||
| 356 | notebook | ||
| 357 | .recycle_pages(&[pages[0].page.clone()], "Author") | ||
| 358 | .unwrap(); | ||
| 359 | notebook.delete("Old.one").unwrap(); | ||
| 360 | let toc = bin.join("Open Notebook.onetoc2"); | ||
| 361 | let before = listed(&toc); | ||
| 362 | assert!(before.iter().any(|(name, _)| name == "Old.one")); | ||
| 363 | let deleted = bin.join("OneNote_DeletedPages.one"); | ||
| 364 | let recycled = |notebook: &Notebook| { | ||
| 365 | let image = notebook | ||
| 366 | .read_section("OneNote_RecycleBin/OneNote_DeletedPages.one") | ||
| 367 | .unwrap(); | ||
| 368 | notebook::session::stored_pages(&image).unwrap().len() | ||
| 369 | }; | ||
| 370 | assert_eq!(recycled(&notebook), 1); | ||
| 371 | let now = std::time::SystemTime::now() | ||
| 372 | .duration_since(std::time::UNIX_EPOCH) | ||
| 373 | .unwrap() | ||
| 374 | .as_secs() as u32 | ||
| 375 | - 315_532_800; | ||
| 376 | let day = 86_400; | ||
| 377 | assert_eq!(notebook.purge_recycle_bin(now + 59 * day).unwrap(), 0); | ||
| 378 | assert!(bin.join("Old.one").exists()); | ||
| 379 | let length = std::fs::metadata(&deleted).unwrap().len(); | ||
| 380 | assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 2); | ||
| 381 | assert!(!bin.join("Old.one").exists()); | ||
| 382 | assert_eq!(recycled(&notebook), 0); | ||
| 383 | // One appended revision; the bin's TOC untouched. | ||
| 384 | assert!(std::fs::metadata(&deleted).unwrap().len() > length); | ||
| 385 | assert_eq!(listed(&toc), before); | ||
| 386 | assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 0); | ||
| 387 | if let Some(directory) = std::env::var_os("NOTEBOOK_PURGE_EXPORT") { | ||
| 388 | copy_dir(&root, std::path::Path::new(&directory)); | ||
| 389 | } | ||
| 390 | } |
crates/notebook/tests/sync_outline.rs+1| ... | @@ -634,6 +634,7 @@ fn with_file(source: &[u8], space: ExGuid) -> (Vec<u8>, ExGuid) { | ... | @@ -634,6 +634,7 @@ fn with_file(source: &[u8], space: ExGuid) -> (Vec<u8>, ExGuid) { |
| 634 | bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])), | 634 | bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])), |
| 635 | preview: None, | 635 | preview: None, |
| 636 | recording: None, | 636 | recording: None, |
| 637 | tags: Vec::new(), | ||
| 637 | }; | 638 | }; |
| 638 | let image = remote_with(source, space, |page| { | 639 | let image = remote_with(source, space, |page| { |
| 639 | page.objects.push(PageObject::Attachment(file)) | 640 | page.objects.push(PageObject::Attachment(file)) |
crates/onestore/README.md+1| ... | @@ -56,6 +56,7 @@ harness also accepts `--client-profile release`. | ... | @@ -56,6 +56,7 @@ harness also accepts `--client-profile release`. |
| 56 | | `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 | | 56 | | `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 | |
| 57 | | `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 | | 57 | | `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 | |
| 58 | | `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | 58 | | `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | |
| 59 | | `PageOp::Style`, `PageOp::Restyle`, `op::restyle` | Give a paragraph a paragraph style (with its NextStyle, as OneNote 2010 writes its headings), or move every paragraph of a style to a new style object of the same name; style objects are read-only, and run or paragraph values equal to what the old style gave are cleared so they follow the new one. `op::restyle(page, sheet)` brings a page's named styles to a sheet of definitions, joining styles that share a name | | ||
| 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 | | 60 | | `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 | |
| 60 | | `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 | | 61 | | `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 | |
| 61 | | `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 | | 62 | | `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 | |
crates/onestore/examples/opts29_dump.rs deleted-29| ... | @@ -1,29 +0,0 @@ | ||
| 1 | use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value}; | ||
| 2 | use std::{env, fs}; | ||
| 3 | |||
| 4 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 5 | for path in env::args_os().skip(1) { | ||
| 6 | let bytes = fs::read(&path)?; | ||
| 7 | let store = Store::parse(&bytes)?; | ||
| 8 | let index = RevisionIndex::parse(&store)?; | ||
| 9 | println!("== {path:?}"); | ||
| 10 | for space in index.spaces.keys() { | ||
| 11 | let Ok(resolved) = index.resolve_active(*space) else { continue }; | ||
| 12 | println!("-- space {space:?}"); | ||
| 13 | for (id, object) in &resolved.objects { | ||
| 14 | let ObjectData::Properties(data) = object.data else { continue }; | ||
| 15 | let sets = PropertySets::parse(data)?; | ||
| 16 | println!("{id:?} jcid={:#x}", object.jcid); | ||
| 17 | for p in &sets.sets[0] { | ||
| 18 | let v = match &p.value { | ||
| 19 | Value::Bytes(b) if b.len() <= 64 => format!("{:02x?}", b), | ||
| 20 | Value::Bytes(b) => format!("{} bytes", b.len()), | ||
| 21 | other => format!("{other:?}").chars().take(120).collect(), | ||
| 22 | }; | ||
| 23 | println!(" {:#010x} {v}", p.id); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| 28 | Ok(()) | ||
| 29 | } | ||
crates/onestore/src/document.rs+4| ... | @@ -527,6 +527,9 @@ pub enum Kind<'a> { | ... | @@ -527,6 +527,9 @@ pub enum Kind<'a> { |
| 527 | }, | 527 | }, |
| 528 | Style { | 528 | Style { |
| 529 | name: Option<String>, | 529 | name: Option<String>, |
| 530 | /// NextStyle: the style Enter gives the paragraph after this one's end. | ||
| 531 | #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| 532 | next: Option<String>, | ||
| 530 | }, | 533 | }, |
| 531 | List { | 534 | List { |
| 532 | font: Option<String>, | 535 | font: Option<String>, |
| ... | @@ -1279,6 +1282,7 @@ impl<'a> Element<'a> { | ... | @@ -1279,6 +1282,7 @@ impl<'a> Element<'a> { |
| 1279 | } | 1282 | } |
| 1280 | 0x12004d => Kind::Style { | 1283 | 0x12004d => Kind::Style { |
| 1281 | name: f.text(0x1c00345a)?, | 1284 | name: f.text(0x1c00345a)?, |
| 1285 | next: f.text(0x1c00348a)?, | ||
| 1282 | }, | 1286 | }, |
| 1283 | 0x60012 => { | 1287 | 0x60012 => { |
| 1284 | let format = f | 1288 | let format = f |
crates/onestore/src/formatting.rs+25-2| ... | @@ -108,6 +108,28 @@ pub(crate) fn format_changes( | ... | @@ -108,6 +108,28 @@ pub(crate) fn format_changes( |
| 108 | range: Range<u32>, | 108 | range: Range<u32>, |
| 109 | attributes: &[TextAttribute], | 109 | attributes: &[TextAttribute], |
| 110 | cleared: &[crate::op::TextProperty], | 110 | cleared: &[crate::op::TextProperty], |
| 111 | ) -> Result<Changes, Error> { | ||
| 112 | formatted(active, object, range, attributes, cleared, false) | ||
| 113 | } | ||
| 114 | |||
| 115 | /// `format_changes` clearing `cleared` from whole runs, fields and equations among them, | ||
| 116 | /// so they inherit what their paragraph style gives, as a restyle does. | ||
| 117 | pub(crate) fn cleared_changes( | ||
| 118 | active: &ActivePage<'_>, | ||
| 119 | object: ExGuid, | ||
| 120 | range: Range<u32>, | ||
| 121 | cleared: &[crate::op::TextProperty], | ||
| 122 | ) -> Result<Changes, Error> { | ||
| 123 | formatted(active, object, range, &[], cleared, true) | ||
| 124 | } | ||
| 125 | |||
| 126 | fn formatted( | ||
| 127 | active: &ActivePage<'_>, | ||
| 128 | object: ExGuid, | ||
| 129 | range: Range<u32>, | ||
| 130 | attributes: &[TextAttribute], | ||
| 131 | cleared: &[crate::op::TextProperty], | ||
| 132 | fields: bool, | ||
| 111 | ) -> Result<Changes, Error> { | 133 | ) -> Result<Changes, Error> { |
| 112 | let cleared: Vec<u32> = cleared.iter().map(|property| property.id()).collect(); | 134 | let cleared: Vec<u32> = cleared.iter().map(|property| property.id()).collect(); |
| 113 | if (attributes.is_empty() && cleared.is_empty()) || range.start > range.end { | 135 | if (attributes.is_empty() && cleared.is_empty()) || range.start > range.end { |
| ... | @@ -163,8 +185,9 @@ pub(crate) fn format_changes( | ... | @@ -163,8 +185,9 @@ pub(crate) fn format_changes( |
| 163 | }; | 185 | }; |
| 164 | if selected { | 186 | if selected { |
| 165 | let format = &resolved[i].format; | 187 | let format = &resolved[i].format; |
| 166 | if [format.math, format.embedded_object].contains(&Some(true)) | 188 | if !fields |
| 167 | || resolved[i].text.contains('\u{fffc}') | 189 | && ([format.math, format.embedded_object].contains(&Some(true)) |
| 190 | || resolved[i].text.contains('\u{fffc}')) | ||
| 168 | { | 191 | { |
| 169 | return Err(invalid( | 192 | return Err(invalid( |
| 170 | "This format range contains a field or embedded data", | 193 | "This format range contains a field or embedded data", |
crates/onestore/src/op/apply.rs+112-5| ... | @@ -10,8 +10,8 @@ use super::{ | ... | @@ -10,8 +10,8 @@ use super::{ |
| 10 | table::{self, Structure}, | 10 | table::{self, Structure}, |
| 11 | }; | 11 | }; |
| 12 | use crate::{ | 12 | use crate::{ |
| 13 | Error, ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, Section, TextAttribute, | 13 | Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, Section, |
| 14 | TreeEdit, | 14 | TextAttribute, TreeEdit, |
| 15 | active::{ActivePage, Changes}, | 15 | active::{ActivePage, Changes}, |
| 16 | document::Kind, | 16 | document::Kind, |
| 17 | page::{ | 17 | page::{ |
| ... | @@ -517,6 +517,16 @@ impl<'a> Writer<'_, 'a> { | ... | @@ -517,6 +517,16 @@ impl<'a> Writer<'_, 'a> { |
| 517 | } | 517 | } |
| 518 | 518 | ||
| 519 | /// Requires `id` reachable on the page. | 519 | /// Requires `id` reachable on the page. |
| 520 | /// Clears run properties over run ranges, fields and equations included. | ||
| 521 | fn clear_runs(&mut self, clears: &properties::Clears) -> Result<(), Failure> { | ||
| 522 | for (text, range, clear) in clears { | ||
| 523 | self.write(|page| { | ||
| 524 | crate::formatting::cleared_changes(page, *text, range.clone(), clear) | ||
| 525 | })?; | ||
| 526 | } | ||
| 527 | Ok(()) | ||
| 528 | } | ||
| 529 | |||
| 520 | fn target(&mut self, id: ExGuid) -> Result<(), Failure> { | 530 | fn target(&mut self, id: ExGuid) -> Result<(), Failure> { |
| 521 | let page = self.page()?; | 531 | let page = self.page()?; |
| 522 | if page.live.is_reachable(id) && page.view.nodes.contains_key(&id) { | 532 | if page.live.is_reachable(id) && page.view.nodes.contains_key(&id) { |
| ... | @@ -786,7 +796,89 @@ impl<'a> Writer<'_, 'a> { | ... | @@ -786,7 +796,89 @@ impl<'a> Writer<'_, 'a> { |
| 786 | } => { | 796 | } => { |
| 787 | self.target(*paragraph)?; | 797 | self.target(*paragraph)?; |
| 788 | let text = self.text_of(*paragraph)?; | 798 | let text = self.text_of(*paragraph)?; |
| 789 | self.write(|page| properties::style_changes(page, text, *style, Some(definition))) | 799 | // What the old style gave, the new one gives instead. |
| 800 | let page = self.page()?; | ||
| 801 | let spacing = match page.view.nodes[&text].kind { | ||
| 802 | Kind::RichText { | ||
| 803 | paragraph_style: Some(old), | ||
| 804 | .. | ||
| 805 | } if old != *style => { | ||
| 806 | let (clears, spacing) = properties::restyle_plan(page, old, Some(text))?; | ||
| 807 | self.clear_runs(&clears)?; | ||
| 808 | spacing | ||
| 809 | } | ||
| 810 | _ => Vec::new(), | ||
| 811 | }; | ||
| 812 | self.write(|page| { | ||
| 813 | let mut changes = | ||
| 814 | properties::style_changes(page, text, *style, Some(definition))?; | ||
| 815 | if let Some((_, ids)) = spacing.first() { | ||
| 816 | changes | ||
| 817 | .get_mut(&text) | ||
| 818 | .expect("the text is restyled") | ||
| 819 | .remove(ids)?; | ||
| 820 | } | ||
| 821 | Ok(changes) | ||
| 822 | }) | ||
| 823 | } | ||
| 824 | PageOp::Unstyle { paragraph } => { | ||
| 825 | self.target(*paragraph)?; | ||
| 826 | let text = self.text_of(*paragraph)?; | ||
| 827 | let page = self.page()?; | ||
| 828 | let Kind::RichText { | ||
| 829 | paragraph_style: Some(old), | ||
| 830 | .. | ||
| 831 | } = page.view.nodes[&text].kind | ||
| 832 | else { | ||
| 833 | return Ok(()); | ||
| 834 | }; | ||
| 835 | let (clears, spacing) = properties::restyle_plan(page, old, Some(text))?; | ||
| 836 | self.clear_runs(&clears)?; | ||
| 837 | self.write(|page| { | ||
| 838 | let mut target = crate::write::PropertyObject::from_object( | ||
| 839 | &page.live.revision.objects[&text], | ||
| 840 | )?; | ||
| 841 | let mut removed = vec![0x2000342c]; | ||
| 842 | removed.extend(spacing.into_iter().flat_map(|(_, ids)| ids)); | ||
| 843 | target.remove(&removed)?; | ||
| 844 | Ok(std::collections::BTreeMap::from([(text, target)])) | ||
| 845 | }) | ||
| 846 | } | ||
| 847 | PageOp::Restyle { | ||
| 848 | style, | ||
| 849 | into, | ||
| 850 | definition, | ||
| 851 | } => { | ||
| 852 | self.target(*style)?; | ||
| 853 | let page = self.page()?; | ||
| 854 | let (Kind::Style { name, .. }, Kind::Style { name: renamed, .. }) = | ||
| 855 | (&page.view.nodes[style].kind, &definition.kind) | ||
| 856 | else { | ||
| 857 | return Err(OpError::Unsupported("Restyle a paragraph style").into()); | ||
| 858 | }; | ||
| 859 | if name.is_none() || name != renamed { | ||
| 860 | return Err( | ||
| 861 | OpError::Unsupported("A restyled paragraph style keeps its name").into(), | ||
| 862 | ); | ||
| 863 | } | ||
| 864 | if page.live.is_reachable(*into) { | ||
| 865 | let ObjectData::Properties(stored) = page.live.revision.objects[into].data | ||
| 866 | else { | ||
| 867 | return Err(OpError::DuplicateIdentity(*into).into()); | ||
| 868 | }; | ||
| 869 | let given = | ||
| 870 | crate::create::properties(&properties::style_object_values(definition)?)?; | ||
| 871 | if stored != given.as_slice() { | ||
| 872 | return Err(OpError::DuplicateIdentity(*into).into()); | ||
| 873 | } | ||
| 874 | } else if into.guid == [0; 16] { | ||
| 875 | return Err(OpError::DuplicateIdentity(*into).into()); | ||
| 876 | } | ||
| 877 | let (clears, spacing) = properties::restyle_plan(page, *style, None)?; | ||
| 878 | self.clear_runs(&clears)?; | ||
| 879 | self.write(|page| { | ||
| 880 | properties::restyle_changes(page, *style, *into, definition, &spacing) | ||
| 881 | }) | ||
| 790 | } | 882 | } |
| 791 | PageOp::Media { paragraph, media } => { | 883 | PageOp::Media { paragraph, media } => { |
| 792 | self.target(*paragraph)?; | 884 | self.target(*paragraph)?; |
| ... | @@ -1179,10 +1271,22 @@ impl<'a> Writer<'_, 'a> { | ... | @@ -1179,10 +1271,22 @@ impl<'a> Writer<'_, 'a> { |
| 1179 | let Some(bytes) = &image.bytes else { | 1271 | let Some(bytes) = &image.bytes else { |
| 1180 | return Err(OpError::Unsupported("A new picture needs its payload").into()); | 1272 | return Err(OpError::Unsupported("A new picture needs its payload").into()); |
| 1181 | }; | 1273 | }; |
| 1274 | if !image.tags.is_empty() { | ||
| 1275 | return Err(OpError::Unsupported("Set a picture's tags with their own op").into()); | ||
| 1276 | } | ||
| 1182 | let (file, payload) = (fresh()?, crate::write::fresh_guid()?); | 1277 | let (file, payload) = (fresh()?, crate::write::fresh_guid()?); |
| 1183 | let bytes: &[u8] = bytes; | 1278 | let bytes: &[u8] = bytes; |
| 1184 | self.write_with(&[(payload, bytes)], |page| { | 1279 | let mut payloads = vec![(payload, bytes)]; |
| 1185 | content::picture_changes(page, image, image.id, file, payload, holder) | 1280 | let shown = match &image.display { |
| 1281 | Some(raster) => { | ||
| 1282 | let shown = (fresh()?, crate::write::fresh_guid()?); | ||
| 1283 | payloads.push((shown.1, raster)); | ||
| 1284 | Some(shown) | ||
| 1285 | } | ||
| 1286 | None => None, | ||
| 1287 | }; | ||
| 1288 | self.write_with(&payloads, |page| { | ||
| 1289 | content::picture_changes(page, image, (image.id, file, payload), shown, holder) | ||
| 1186 | }) | 1290 | }) |
| 1187 | } | 1291 | } |
| 1188 | 1292 | ||
| ... | @@ -1194,6 +1298,9 @@ impl<'a> Writer<'_, 'a> { | ... | @@ -1194,6 +1298,9 @@ impl<'a> Writer<'_, 'a> { |
| 1194 | let Some(bytes) = &attachment.bytes else { | 1298 | let Some(bytes) = &attachment.bytes else { |
| 1195 | return Err(OpError::Unsupported("A new attachment needs its payload").into()); | 1299 | return Err(OpError::Unsupported("A new attachment needs its payload").into()); |
| 1196 | }; | 1300 | }; |
| 1301 | if !attachment.tags.is_empty() { | ||
| 1302 | return Err(OpError::Unsupported("Set a file's tags with their own op").into()); | ||
| 1303 | } | ||
| 1197 | let ids = AttachmentIds { | 1304 | let ids = AttachmentIds { |
| 1198 | object: attachment.id, | 1305 | object: attachment.id, |
| 1199 | file: fresh()?, | 1306 | file: fresh()?, |
crates/onestore/src/op/content.rs+52-11| ... | @@ -132,9 +132,11 @@ pub(crate) fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), | ... | @@ -132,9 +132,11 @@ pub(crate) fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), |
| 132 | || stored.bytes != image.bytes | 132 | || stored.bytes != image.bytes |
| 133 | || stored.size != image.size | 133 | || stored.size != image.size |
| 134 | || stored.background != image.background | 134 | || stored.background != image.background |
| 135 | || stored.link != image.link | ||
| 136 | || stored.text != image.text | ||
| 135 | { | 137 | { |
| 136 | return Err(invalid( | 138 | return Err(invalid( |
| 137 | "A stored picture keeps its payload, intrinsic size and background state", | 139 | "A stored picture keeps its payload, intrinsic size, background state, link and text", |
| 138 | )); | 140 | )); |
| 139 | } | 141 | } |
| 140 | Ok(()) | 142 | Ok(()) |
| ... | @@ -201,13 +203,14 @@ fn icon_size( | ... | @@ -201,13 +203,14 @@ fn icon_size( |
| 201 | 203 | ||
| 202 | /// What OneNote stores for an inserted picture: a file-data object declaring payload | 204 | /// What OneNote stores for an inserted picture: a file-data object declaring payload |
| 203 | /// `payload` by identity and extension, and picture object `id` that paragraph `holder` | 205 | /// `payload` by identity and extension, and picture object `id` that paragraph `holder` |
| 204 | /// holds as content or the page lists as a child. The caller embeds the payload. | 206 | /// holds as content or the page lists as a child; with `shown`, the file-data object and |
| 207 | /// payload of the raster OneNote shows of it (WebPictureContainer14), as a printout page | ||
| 208 | /// stores beside its XPS package. The caller embeds the payloads. | ||
| 205 | pub(crate) fn picture_changes( | 209 | pub(crate) fn picture_changes( |
| 206 | active: &ActivePage<'_>, | 210 | active: &ActivePage<'_>, |
| 207 | image: &Image, | 211 | image: &Image, |
| 208 | id: ExGuid, | 212 | (id, file, payload): (ExGuid, ExGuid, [u8; 16]), |
| 209 | file: ExGuid, | 213 | shown: Option<(ExGuid, [u8; 16])>, |
| 210 | payload: [u8; 16], | ||
| 211 | holder: Option<ExGuid>, | 214 | holder: Option<ExGuid>, |
| 212 | ) -> Result<Changes, Error> { | 215 | ) -> Result<Changes, Error> { |
| 213 | let Some(bytes) = &image.bytes else { | 216 | let Some(bytes) = &image.bytes else { |
| ... | @@ -220,8 +223,19 @@ pub(crate) fn picture_changes( | ... | @@ -220,8 +223,19 @@ pub(crate) fn picture_changes( |
| 220 | [b'B', b'M', ..] => ".bmp", | 223 | [b'B', b'M', ..] => ".bmp", |
| 221 | // OneNote keeps an imported TIFF as it is (`corpus/m6/native-features-01`). | 224 | // 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", | 225 | [b'I', b'I', 0x2a, 0x00, ..] | [b'M', b'M', 0x00, 0x2a, ..] => ".tif", |
| 226 | // A file printout's page: its XPS package (`corpus/printout`). | ||
| 227 | [b'P', b'K', 3, 4, ..] if image.printout.is_some() && shown.is_some() => ".xps", | ||
| 223 | _ => return Err(invalid("Choose a PNG, JPEG, GIF, BMP or TIFF picture")), | 228 | _ => return Err(invalid("Choose a PNG, JPEG, GIF, BMP or TIFF picture")), |
| 224 | }; | 229 | }; |
| 230 | if image | ||
| 231 | .display | ||
| 232 | .as_ref() | ||
| 233 | .is_some_and(|raster| !raster.starts_with(b"\x89PNG")) | ||
| 234 | { | ||
| 235 | return Err(invalid( | ||
| 236 | "A picture's shown raster is a PNG stored beside it", | ||
| 237 | )); | ||
| 238 | } | ||
| 225 | let modified = crate::create::current_timestamps()?.0.to_le_bytes(); | 239 | let modified = crate::create::current_timestamps()?.0.to_le_bytes(); |
| 226 | let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; | 240 | let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; |
| 227 | if let Some([width, height]) = image.size { | 241 | if let Some([width, height]) = image.size { |
| ... | @@ -235,6 +249,18 @@ pub(crate) fn picture_changes( | ... | @@ -235,6 +249,18 @@ pub(crate) fn picture_changes( |
| 235 | if let Some(alt) = &image.alt { | 249 | if let Some(alt) = &image.alt { |
| 236 | values.push((0x1c001e58, crate::create::string(alt))); | 250 | values.push((0x1c001e58, crate::create::string(alt))); |
| 237 | } | 251 | } |
| 252 | if let Some(link) = &image.link { | ||
| 253 | values.push((0x1c001e20, crate::create::string(link))); | ||
| 254 | } | ||
| 255 | if let Some(recognized) = &image.text { | ||
| 256 | values.push((0x1c001c22, crate::create::string(&recognized.text))); | ||
| 257 | for (id, value) in &recognized.stored { | ||
| 258 | if !crate::page::RECOGNIZED_PROPERTIES.contains(id) { | ||
| 259 | return Err(invalid("A picture's text stores only its known properties")); | ||
| 260 | } | ||
| 261 | values.push((*id, value.clone())); | ||
| 262 | } | ||
| 263 | } | ||
| 238 | if image.background { | 264 | if image.background { |
| 239 | values.push((0x08001d13 | (1 << 31), Vec::new())); | 265 | values.push((0x08001d13 | (1 << 31), Vec::new())); |
| 240 | } | 266 | } |
| ... | @@ -244,14 +270,21 @@ pub(crate) fn picture_changes( | ... | @@ -244,14 +270,21 @@ pub(crate) fn picture_changes( |
| 244 | 0x08001d85 | (u32::from(image.printout.is_some()) << 31), | 270 | 0x08001d85 | (u32::from(image.printout.is_some()) << 31), |
| 245 | Vec::new(), | 271 | Vec::new(), |
| 246 | )); | 272 | )); |
| 247 | if let Some(page) = image.printout { | 273 | if let Some(printout) = &image.printout { |
| 248 | values.push((0x14001df9, page.to_le_bytes().to_vec())); | 274 | values.push((0x14001df9, printout.page.to_le_bytes().to_vec())); |
| 275 | for (id, value) in &printout.stored { | ||
| 276 | if !crate::page::PRINTOUT_PROPERTIES.contains(id) { | ||
| 277 | return Err(invalid("A printout page stores only its known properties")); | ||
| 278 | } | ||
| 279 | values.push((*id, value.clone())); | ||
| 280 | } | ||
| 249 | } | 281 | } |
| 250 | let mut changed = BTreeMap::new(); | 282 | let mut changed = BTreeMap::new(); |
| 251 | changed.insert( | 283 | let mut container = PropertyObject::file(file, &payload_reference(payload), extension)?; |
| 252 | file, | 284 | if extension == ".xps" { |
| 253 | PropertyObject::file(file, &payload_reference(payload), extension)?, | 285 | container.jcid = crate::write::PRINTOUT_FILE_JCID; |
| 254 | ); | 286 | } |
| 287 | changed.insert(file, container); | ||
| 255 | let mut picture = PropertyObject { | 288 | let mut picture = PropertyObject { |
| 256 | jcid: 0x60011, | 289 | jcid: 0x60011, |
| 257 | bytes: crate::create::properties(&values)?, | 290 | bytes: crate::create::properties(&values)?, |
| ... | @@ -260,6 +293,14 @@ pub(crate) fn picture_changes( | ... | @@ -260,6 +293,14 @@ pub(crate) fn picture_changes( |
| 260 | picture.reference(id)?; | 293 | picture.reference(id)?; |
| 261 | let container = picture.reference(file)?; | 294 | let container = picture.reference(file)?; |
| 262 | picture.set(&[(0x20001c3f, &container)])?; | 295 | picture.set(&[(0x20001c3f, &container)])?; |
| 296 | if let Some((raster, payload)) = shown { | ||
| 297 | changed.insert( | ||
| 298 | raster, | ||
| 299 | PropertyObject::file(raster, &payload_reference(payload), ".png")?, | ||
| 300 | ); | ||
| 301 | let web = picture.reference(raster)?; | ||
| 302 | picture.set(&[(0x200034c8, &web)])?; | ||
| 303 | } | ||
| 263 | changed.insert(id, picture); | 304 | changed.insert(id, picture); |
| 264 | hold(active, &mut changed, id, holder, &modified)?; | 305 | hold(active, &mut changed, id, holder, &modified)?; |
| 265 | Ok(changed) | 306 | Ok(changed) |
crates/onestore/src/op/lower.rs+71-30| ... | @@ -316,7 +316,9 @@ pub(crate) fn validate( | ... | @@ -316,7 +316,9 @@ pub(crate) fn validate( |
| 316 | Some(None) => paragraph.style, | 316 | Some(None) => paragraph.style, |
| 317 | None => previous.style, | 317 | None => previous.style, |
| 318 | }; | 318 | }; |
| 319 | if paragraph.style != style || paragraph.format != previous.format { | 319 | // `Style` and `Unstyle` give a paragraph of text a style and take it away. |
| 320 | let restyled = paragraph.style != style && paragraph.text().is_none(); | ||
| 321 | if restyled || paragraph.format != previous.format { | ||
| 320 | return Err(invalid( | 322 | return Err(invalid( |
| 321 | "Paragraph styles and paragraph formatting cannot be edited", | 323 | "Paragraph styles and paragraph formatting cannot be edited", |
| 322 | )); | 324 | )); |
| ... | @@ -743,6 +745,8 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph { | ... | @@ -743,6 +745,8 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph { |
| 743 | stripped.media = Default::default(); | 745 | stripped.media = Default::default(); |
| 744 | match &mut stripped.content { | 746 | match &mut stripped.content { |
| 745 | ParagraphContent::Text(text) => text.tags.clear(), | 747 | ParagraphContent::Text(text) => text.tags.clear(), |
| 748 | ParagraphContent::Image(image) => image.tags.clear(), | ||
| 749 | ParagraphContent::Attachment(file) => file.tags.clear(), | ||
| 746 | ParagraphContent::Table(table) => { | 750 | ParagraphContent::Table(table) => { |
| 747 | table.tags.clear(); | 751 | table.tags.clear(); |
| 748 | for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { | 752 | for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { |
| ... | @@ -991,16 +995,18 @@ impl Lowering { | ... | @@ -991,16 +995,18 @@ impl Lowering { |
| 991 | }; | 995 | }; |
| 992 | let stored = pictures(old.page); | 996 | let stored = pictures(old.page); |
| 993 | for (id, image) in pictures(new.page) { | 997 | for (id, image) in pictures(new.page) { |
| 994 | if let Some(previous) = stored.get(&id) | 998 | let Some(previous) = stored.get(&id) else { |
| 995 | && *previous != image | 999 | continue; |
| 996 | { | 1000 | }; |
| 997 | super::content::picture_fixed_fields(previous, &image)?; | 1001 | super::content::picture_fixed_fields(previous, &image)?; |
| 1002 | if (&previous.layout, &previous.alt) != (&image.layout, &image.alt) { | ||
| 998 | self.emit(PageOp::Picture { | 1003 | self.emit(PageOp::Picture { |
| 999 | picture: id, | 1004 | picture: id, |
| 1000 | layout: image.layout.clone(), | 1005 | layout: image.layout.clone(), |
| 1001 | alt: image.alt.clone(), | 1006 | alt: image.alt.clone(), |
| 1002 | })?; | 1007 | })?; |
| 1003 | } | 1008 | } |
| 1009 | self.set_tags(new.page, id, &image.tags, &previous.tags)?; | ||
| 1004 | } | 1010 | } |
| 1005 | let files = |page: &Page| -> BTreeMap<ExGuid, crate::page::Attachment> { | 1011 | let files = |page: &Page| -> BTreeMap<ExGuid, crate::page::Attachment> { |
| 1006 | page.objects | 1012 | page.objects |
| ... | @@ -1049,6 +1055,7 @@ impl Lowering { | ... | @@ -1049,6 +1055,7 @@ impl Lowering { |
| 1049 | size: file.size, | 1055 | size: file.size, |
| 1050 | })?; | 1056 | })?; |
| 1051 | } | 1057 | } |
| 1058 | self.set_tags(new.page, id, &file.tags, &previous.tags)?; | ||
| 1052 | } | 1059 | } |
| 1053 | let inks = |page: &Page| -> BTreeMap<ExGuid, crate::page::Ink> { | 1060 | let inks = |page: &Page| -> BTreeMap<ExGuid, crate::page::Ink> { |
| 1054 | page.objects | 1061 | page.objects |
| ... | @@ -1098,7 +1105,7 @@ impl Lowering { | ... | @@ -1098,7 +1105,7 @@ impl Lowering { |
| 1098 | if new.title_outlines.contains(&id) { | 1105 | if new.title_outlines.contains(&id) { |
| 1099 | return Err(invalid("Title outlines cannot be added")); | 1106 | return Err(invalid("Title outlines cannot be added")); |
| 1100 | } | 1107 | } |
| 1101 | let object = match object { | 1108 | let added = match object { |
| 1102 | PageObject::Outline(outline) => { | 1109 | PageObject::Outline(outline) => { |
| 1103 | if outline | 1110 | if outline |
| 1104 | .paragraphs | 1111 | .paragraphs |
| ... | @@ -1115,9 +1122,17 @@ impl Lowering { | ... | @@ -1115,9 +1122,17 @@ impl Lowering { |
| 1115 | ..(*outline).clone() | 1122 | ..(*outline).clone() |
| 1116 | }) | 1123 | }) |
| 1117 | } | 1124 | } |
| 1118 | PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Ink(_) => { | 1125 | PageObject::Image(image) => PageObject::Image(crate::page::Image { |
| 1119 | (*object).clone() | 1126 | tags: Vec::new(), |
| 1127 | ..image.clone() | ||
| 1128 | }), | ||
| 1129 | PageObject::Attachment(file) => { | ||
| 1130 | PageObject::Attachment(crate::page::Attachment { | ||
| 1131 | tags: Vec::new(), | ||
| 1132 | ..file.clone() | ||
| 1133 | }) | ||
| 1120 | } | 1134 | } |
| 1135 | PageObject::Ink(_) => (*object).clone(), | ||
| 1121 | PageObject::Title(_) | PageObject::Unsupported(_) => { | 1136 | PageObject::Title(_) | PageObject::Unsupported(_) => { |
| 1122 | return Err(invalid( | 1137 | return Err(invalid( |
| 1123 | "Titles and unsupported objects cannot be edited through the page model", | 1138 | "Titles and unsupported objects cannot be edited through the page model", |
| ... | @@ -1125,9 +1140,15 @@ impl Lowering { | ... | @@ -1125,9 +1140,15 @@ impl Lowering { |
| 1125 | } | 1140 | } |
| 1126 | }; | 1141 | }; |
| 1127 | self.emit(PageOp::Add { | 1142 | self.emit(PageOp::Add { |
| 1128 | object, | 1143 | object: added, |
| 1129 | before: next, | 1144 | before: next, |
| 1130 | })?; | 1145 | })?; |
| 1146 | let tags = match object { | ||
| 1147 | PageObject::Image(image) => image.tags.as_slice(), | ||
| 1148 | PageObject::Attachment(file) => file.tags.as_slice(), | ||
| 1149 | _ => &[], | ||
| 1150 | }; | ||
| 1151 | self.set_tags(new.page, id, tags, &[])?; | ||
| 1131 | } else if !kept.contains(&id) { | 1152 | } else if !kept.contains(&id) { |
| 1132 | self.emit(PageOp::Move { | 1153 | self.emit(PageOp::Move { |
| 1133 | object: id, | 1154 | object: id, |
| ... | @@ -1694,11 +1715,14 @@ impl Lowering { | ... | @@ -1694,11 +1715,14 @@ impl Lowering { |
| 1694 | 1715 | ||
| 1695 | fn styles(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { | 1716 | fn styles(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { |
| 1696 | for (id, paragraph) in &new.paragraphs { | 1717 | for (id, paragraph) in &new.paragraphs { |
| 1718 | let current = model::paragraph(&self.current, *id) | ||
| 1719 | .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; | ||
| 1697 | let Some(style) = paragraph.style else { | 1720 | let Some(style) = paragraph.style else { |
| 1721 | if current.style.is_some() { | ||
| 1722 | self.emit(PageOp::Unstyle { paragraph: *id })?; | ||
| 1723 | } | ||
| 1698 | continue; | 1724 | continue; |
| 1699 | }; | 1725 | }; |
| 1700 | let current = model::paragraph(&self.current, *id) | ||
| 1701 | .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; | ||
| 1702 | if current.style == Some(style) { | 1726 | if current.style == Some(style) { |
| 1703 | continue; | 1727 | continue; |
| 1704 | } | 1728 | } |
| ... | @@ -1771,33 +1795,50 @@ impl Lowering { | ... | @@ -1771,33 +1795,50 @@ impl Lowering { |
| 1771 | (ParagraphContent::Table(table), ParagraphContent::Table(stored)) => { | 1795 | (ParagraphContent::Table(table), ParagraphContent::Table(stored)) => { |
| 1772 | targets.push((table.id, &table.tags, stored.tags.clone())) | 1796 | targets.push((table.id, &table.tags, stored.tags.clone())) |
| 1773 | } | 1797 | } |
| 1798 | (ParagraphContent::Image(image), ParagraphContent::Image(stored)) => { | ||
| 1799 | targets.push((image.id, &image.tags, stored.tags.clone())) | ||
| 1800 | } | ||
| 1801 | (ParagraphContent::Attachment(file), ParagraphContent::Attachment(stored)) => { | ||
| 1802 | targets.push((file.id, &file.tags, stored.tags.clone())) | ||
| 1803 | } | ||
| 1774 | _ => {} | 1804 | _ => {} |
| 1775 | } | 1805 | } |
| 1776 | for (target, tags, stored) in targets { | 1806 | for (target, tags, stored) in targets { |
| 1777 | if same_tags(tags, &stored) { | 1807 | self.set_tags(after, target, tags, &stored)?; |
| 1778 | continue; | ||
| 1779 | } | ||
| 1780 | let mut definitions = Vec::new(); | ||
| 1781 | for tag in tags { | ||
| 1782 | let Some(definition) = tag.definition else { | ||
| 1783 | continue; | ||
| 1784 | }; | ||
| 1785 | if let Some(model) = after.definitions.get(&definition) | ||
| 1786 | && !definitions.iter().any(|(id, _)| *id == definition) | ||
| 1787 | { | ||
| 1788 | definitions.push((definition, model.clone())); | ||
| 1789 | } | ||
| 1790 | } | ||
| 1791 | self.emit(PageOp::Tags { | ||
| 1792 | target, | ||
| 1793 | tags: tags.clone(), | ||
| 1794 | definitions, | ||
| 1795 | })?; | ||
| 1796 | } | 1808 | } |
| 1797 | } | 1809 | } |
| 1798 | Ok(()) | 1810 | Ok(()) |
| 1799 | } | 1811 | } |
| 1800 | 1812 | ||
| 1813 | /// Emits the op giving `target` `tags` where it stores others. | ||
| 1814 | fn set_tags( | ||
| 1815 | &mut self, | ||
| 1816 | after: &Page, | ||
| 1817 | target: ExGuid, | ||
| 1818 | tags: &[Tag], | ||
| 1819 | stored: &[Tag], | ||
| 1820 | ) -> Result<(), Error> { | ||
| 1821 | if same_tags(tags, stored) { | ||
| 1822 | return Ok(()); | ||
| 1823 | } | ||
| 1824 | let mut definitions = Vec::new(); | ||
| 1825 | for tag in tags { | ||
| 1826 | let Some(definition) = tag.definition else { | ||
| 1827 | continue; | ||
| 1828 | }; | ||
| 1829 | if let Some(model) = after.definitions.get(&definition) | ||
| 1830 | && !definitions.iter().any(|(id, _)| *id == definition) | ||
| 1831 | { | ||
| 1832 | definitions.push((definition, model.clone())); | ||
| 1833 | } | ||
| 1834 | } | ||
| 1835 | self.emit(PageOp::Tags { | ||
| 1836 | target, | ||
| 1837 | tags: tags.to_vec(), | ||
| 1838 | definitions, | ||
| 1839 | }) | ||
| 1840 | } | ||
| 1841 | |||
| 1801 | fn media(&mut self, new: &View<'_>) -> Result<(), Error> { | 1842 | fn media(&mut self, new: &View<'_>) -> Result<(), Error> { |
| 1802 | for (id, paragraph) in &new.paragraphs { | 1843 | for (id, paragraph) in &new.paragraphs { |
| 1803 | let current = model::paragraph(&self.current, *id) | 1844 | let current = model::paragraph(&self.current, *id) |
crates/onestore/src/op/mod.rs+52| ... | @@ -151,6 +151,20 @@ pub enum PageOp { | ... | @@ -151,6 +151,20 @@ pub enum PageOp { |
| 151 | style: ExGuid, | 151 | style: ExGuid, |
| 152 | definition: Definition, | 152 | definition: Definition, |
| 153 | }, | 153 | }, |
| 154 | /// Takes a paragraph's style away; values its style gave are cleared from its text, as | ||
| 155 | /// `Restyle` clears them. | ||
| 156 | Unstyle { | ||
| 157 | paragraph: ExGuid, | ||
| 158 | }, | ||
| 159 | /// Moves every paragraph of style `style` to style `into`, created from `definition` | ||
| 160 | /// (the same name) where the page lacks it: style objects are read-only, so a restyled | ||
| 161 | /// style is a new one. Run and paragraph values equal to what `style` gave are cleared, | ||
| 162 | /// so they follow `into`; values set otherwise stay. | ||
| 163 | Restyle { | ||
| 164 | style: ExGuid, | ||
| 165 | into: ExGuid, | ||
| 166 | definition: Definition, | ||
| 167 | }, | ||
| 154 | /// Links a paragraph's text or file to a moment in recordings on the page, or with | 168 | /// Links a paragraph's text or file to a moment in recordings on the page, or with |
| 155 | /// empty `media` unlinks it. | 169 | /// empty `media` unlinks it. |
| 156 | Media { | 170 | Media { |
| ... | @@ -270,6 +284,44 @@ pub enum SectionOp { | ... | @@ -270,6 +284,44 @@ pub enum SectionOp { |
| 270 | }, | 284 | }, |
| 271 | } | 285 | } |
| 272 | 286 | ||
| 287 | /// The `Restyle` ops giving the paragraphs of every style of `page` named in `sheet` | ||
| 288 | /// that definition instead, one per style that differs; styles sharing a name become one. | ||
| 289 | pub fn restyle( | ||
| 290 | page: &Page, | ||
| 291 | sheet: &std::collections::BTreeMap<String, Definition>, | ||
| 292 | ) -> Result<Vec<PageOp>, crate::Error> { | ||
| 293 | let mut into = std::collections::BTreeMap::new(); | ||
| 294 | let mut ops = Vec::new(); | ||
| 295 | for (id, stored) in &page.definitions { | ||
| 296 | let crate::document::Kind::Style { | ||
| 297 | name: Some(name), .. | ||
| 298 | } = &stored.kind | ||
| 299 | else { | ||
| 300 | continue; | ||
| 301 | }; | ||
| 302 | let Some(definition) = sheet.get(name).filter(|definition| *definition != stored) else { | ||
| 303 | continue; | ||
| 304 | }; | ||
| 305 | let target = match into.get(name) { | ||
| 306 | Some(target) => *target, | ||
| 307 | None => { | ||
| 308 | let target = page | ||
| 309 | .definitions | ||
| 310 | .iter() | ||
| 311 | .find(|(_, kept)| *kept == definition) | ||
| 312 | .map_or_else(crate::page::text::new_id, |(id, _)| Ok(*id))?; | ||
| 313 | *into.entry(name.clone()).or_insert(target) | ||
| 314 | } | ||
| 315 | }; | ||
| 316 | ops.push(PageOp::Restyle { | ||
| 317 | style: *id, | ||
| 318 | into: target, | ||
| 319 | definition: definition.clone(), | ||
| 320 | }); | ||
| 321 | } | ||
| 322 | Ok(ops) | ||
| 323 | } | ||
| 324 | |||
| 273 | impl SectionOp { | 325 | impl SectionOp { |
| 274 | /// `RestoreVersion` of `page`'s `version`, its new objects under a fresh identity. | 326 | /// `RestoreVersion` of `page`'s `version`, its new objects under a fresh identity. |
| 275 | pub fn restore(page: ExGuid, version: ExGuid) -> Result<Self, crate::Error> { | 327 | pub fn restore(page: ExGuid, version: ExGuid) -> Result<Self, crate::Error> { |
crates/onestore/src/op/model.rs+113-48| ... | @@ -614,11 +614,21 @@ pub fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { | ... | @@ -614,11 +614,21 @@ pub fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 614 | let tags = paragraph.tags.iter().chain(match &paragraph.content { | 614 | let tags = paragraph.tags.iter().chain(match &paragraph.content { |
| 615 | ParagraphContent::Text(text) => text.tags.as_slice(), | 615 | ParagraphContent::Text(text) => text.tags.as_slice(), |
| 616 | ParagraphContent::Table(table) => table.tags.as_slice(), | 616 | ParagraphContent::Table(table) => table.tags.as_slice(), |
| 617 | ParagraphContent::Image(image) => image.tags.as_slice(), | ||
| 618 | ParagraphContent::Attachment(file) => file.tags.as_slice(), | ||
| 617 | _ => &[], | 619 | _ => &[], |
| 618 | }); | 620 | }); |
| 619 | referenced.extend(tags.filter_map(|tag| tag.definition)); | 621 | referenced.extend(tags.filter_map(|tag| tag.definition)); |
| 620 | } | 622 | } |
| 621 | } | 623 | } |
| 624 | for object in &page.objects { | ||
| 625 | let tags = match object { | ||
| 626 | PageObject::Image(image) => image.tags.as_slice(), | ||
| 627 | PageObject::Attachment(file) => file.tags.as_slice(), | ||
| 628 | _ => &[], | ||
| 629 | }; | ||
| 630 | referenced.extend(tags.iter().filter_map(|tag| tag.definition)); | ||
| 631 | } | ||
| 622 | page.definitions.retain(|id, _| referenced.contains(id)); | 632 | page.definitions.retain(|id, _| referenced.contains(id)); |
| 623 | Ok(()) | 633 | Ok(()) |
| 624 | } | 634 | } |
| ... | @@ -1000,54 +1010,59 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { | ... | @@ -1000,54 +1010,59 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 1000 | let paragraph = paragraph_mut(page, *paragraph)?; | 1010 | let paragraph = paragraph_mut(page, *paragraph)?; |
| 1001 | paragraph.style = Some(style); | 1011 | paragraph.style = Some(style); |
| 1002 | if let Some(text) = paragraph.text_mut() { | 1012 | if let Some(text) = paragraph.text_mut() { |
| 1003 | let mut previous = 0; | 1013 | text.text = restyled(&text.text, &old, &new); |
| 1004 | let runs: Vec<(String, Format)> = text | 1014 | } |
| 1005 | .text | 1015 | } |
| 1006 | .spans() | 1016 | PageOp::Unstyle { paragraph: id } => { |
| 1007 | .iter() | 1017 | let Some(style) = paragraph_mut(page, *id)?.style.take() else { |
| 1008 | .map(|span| { | 1018 | return Ok(()); |
| 1009 | // What the old style gave, the new one gives instead. | 1019 | }; |
| 1010 | let mut format = span.format.clone(); | 1020 | let old = page |
| 1011 | macro_rules! restyle { | 1021 | .definitions |
| 1012 | ($($field:ident),*) => {$( | 1022 | .get(&style) |
| 1013 | if old.$field.is_some() && format.$field == old.$field { | 1023 | .ok_or_else(|| invalid("The page has no such paragraph style"))? |
| 1014 | format.$field = new.$field.clone(); | 1024 | .format |
| 1015 | } else if format.$field.is_none() { | 1025 | .clone(); |
| 1016 | format.$field = new.$field.clone(); | 1026 | if let Some(text) = paragraph_mut(page, *id)?.text_mut() { |
| 1017 | } | 1027 | text.text = restyled(&text.text, &old, &Format::default()); |
| 1018 | )*}; | 1028 | } |
| 1029 | } | ||
| 1030 | PageOp::Restyle { | ||
| 1031 | style, | ||
| 1032 | into, | ||
| 1033 | definition, | ||
| 1034 | } => { | ||
| 1035 | let old = page | ||
| 1036 | .definitions | ||
| 1037 | .get(style) | ||
| 1038 | .ok_or_else(|| invalid("The page has no such paragraph style"))? | ||
| 1039 | .format | ||
| 1040 | .clone(); | ||
| 1041 | let mut definition = definition.clone(); | ||
| 1042 | let format = &mut definition.format; | ||
| 1043 | for points in [ | ||
| 1044 | &mut format.space_before, | ||
| 1045 | &mut format.space_after, | ||
| 1046 | &mut format.line_spacing, | ||
| 1047 | ] | ||
| 1048 | .into_iter() | ||
| 1049 | .flatten() | ||
| 1050 | { | ||
| 1051 | *points = stored(*points); | ||
| 1052 | } | ||
| 1053 | page.definitions | ||
| 1054 | .entry(*into) | ||
| 1055 | .or_insert_with(|| definition.clone()); | ||
| 1056 | let paths: Vec<Path> = lists(page).into_iter().map(|(path, ..)| path).collect(); | ||
| 1057 | for path in paths { | ||
| 1058 | for paragraph in list_at(page, &path) { | ||
| 1059 | if paragraph.style == Some(*style) { | ||
| 1060 | paragraph.style = Some(*into); | ||
| 1061 | if let Some(text) = paragraph.text_mut() { | ||
| 1062 | text.text = restyled(&text.text, &old, &definition.format); | ||
| 1019 | } | 1063 | } |
| 1020 | restyle!( | 1064 | } |
| 1021 | bold, | 1065 | } |
| 1022 | italic, | ||
| 1023 | underline, | ||
| 1024 | strike, | ||
| 1025 | superscript, | ||
| 1026 | subscript, | ||
| 1027 | hidden, | ||
| 1028 | hyperlink, | ||
| 1029 | hyperlink_label, | ||
| 1030 | math, | ||
| 1031 | embedded_object, | ||
| 1032 | font, | ||
| 1033 | font_size, | ||
| 1034 | color, | ||
| 1035 | highlight, | ||
| 1036 | language, | ||
| 1037 | alignment, | ||
| 1038 | rtl, | ||
| 1039 | space_before, | ||
| 1040 | space_after, | ||
| 1041 | line_spacing, | ||
| 1042 | list_spacing, | ||
| 1043 | math_object | ||
| 1044 | ); | ||
| 1045 | let run = (text.text.text()[previous..span.end].to_owned(), format); | ||
| 1046 | previous = span.end; | ||
| 1047 | run | ||
| 1048 | }) | ||
| 1049 | .collect(); | ||
| 1050 | text.text = Paragraph::from_runs(runs); | ||
| 1051 | } | 1066 | } |
| 1052 | } | 1067 | } |
| 1053 | PageOp::Media { paragraph, media } => { | 1068 | PageOp::Media { paragraph, media } => { |
| ... | @@ -1088,8 +1103,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { | ... | @@ -1088,8 +1103,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 1088 | paragraph.tags = tags; | 1103 | paragraph.tags = tags; |
| 1089 | } else if let Ok(text) = text_mut(page, *target) { | 1104 | } else if let Ok(text) = text_mut(page, *target) { |
| 1090 | text.tags = tags; | 1105 | text.tags = tags; |
| 1106 | } else if let Ok(table) = table_mut(page, *target) { | ||
| 1107 | table.tags = tags; | ||
| 1108 | } else if let Ok(image) = image_mut(page, *target) { | ||
| 1109 | image.tags = tags; | ||
| 1091 | } else { | 1110 | } else { |
| 1092 | table_mut(page, *target)?.tags = tags; | 1111 | attachment_mut(page, *target)?.tags = tags; |
| 1093 | } | 1112 | } |
| 1094 | } | 1113 | } |
| 1095 | PageOp::Add { object, before } => { | 1114 | PageOp::Add { object, before } => { |
| ... | @@ -1271,6 +1290,52 @@ fn drop_emptied(page: &mut Page) { | ... | @@ -1271,6 +1290,52 @@ fn drop_emptied(page: &mut Page) { |
| 1271 | 1290 | ||
| 1272 | /// The identity a read-only definition is stored under: an equal one the page holds, as | 1291 | /// The identity a read-only definition is stored under: an equal one the page holds, as |
| 1273 | /// the writers share identical read-only objects, else `id`. | 1292 | /// the writers share identical read-only objects, else `id`. |
| 1293 | /// `text` moved from paragraph style `old` to `new`: what the old style gave, the new one | ||
| 1294 | /// gives instead. | ||
| 1295 | fn restyled(text: &Paragraph, old: &Format, new: &Format) -> Paragraph { | ||
| 1296 | let mut previous = 0; | ||
| 1297 | Paragraph::from_runs(text.spans().iter().map(|span| { | ||
| 1298 | let mut format = span.format.clone(); | ||
| 1299 | macro_rules! restyle { | ||
| 1300 | ($($field:ident),*) => {$( | ||
| 1301 | if old.$field.is_some() && format.$field == old.$field { | ||
| 1302 | format.$field = new.$field.clone(); | ||
| 1303 | } else if format.$field.is_none() { | ||
| 1304 | format.$field = new.$field.clone(); | ||
| 1305 | } | ||
| 1306 | )*}; | ||
| 1307 | } | ||
| 1308 | restyle!( | ||
| 1309 | bold, | ||
| 1310 | italic, | ||
| 1311 | underline, | ||
| 1312 | strike, | ||
| 1313 | superscript, | ||
| 1314 | subscript, | ||
| 1315 | hidden, | ||
| 1316 | hyperlink, | ||
| 1317 | hyperlink_label, | ||
| 1318 | math, | ||
| 1319 | embedded_object, | ||
| 1320 | font, | ||
| 1321 | font_size, | ||
| 1322 | color, | ||
| 1323 | highlight, | ||
| 1324 | language, | ||
| 1325 | alignment, | ||
| 1326 | rtl, | ||
| 1327 | space_before, | ||
| 1328 | space_after, | ||
| 1329 | line_spacing, | ||
| 1330 | list_spacing, | ||
| 1331 | math_object | ||
| 1332 | ); | ||
| 1333 | let run = (text.text()[previous..span.end].to_owned(), format); | ||
| 1334 | previous = span.end; | ||
| 1335 | run | ||
| 1336 | })) | ||
| 1337 | } | ||
| 1338 | |||
| 1274 | fn define(page: &mut Page, id: ExGuid, definition: &crate::page::Definition) -> ExGuid { | 1339 | fn define(page: &mut Page, id: ExGuid, definition: &crate::page::Definition) -> ExGuid { |
| 1275 | if page.definitions.contains_key(&id) { | 1340 | if page.definitions.contains_key(&id) { |
| 1276 | return id; | 1341 | return id; |
crates/onestore/src/op/properties.rs+166-21| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | ||
| 3 | use super::Values; | 3 | use super::Values; |
| 4 | use crate::{ | 4 | use crate::{ |
| 5 | Error, ExGuid, PropertySets, Value, | 5 | Error, ExGuid, ObjectData, PropertySets, Value, |
| 6 | active::{ActivePage, Changes}, | 6 | active::{ActivePage, Changes}, |
| 7 | document::{Kind, Tag}, | 7 | document::{Kind, Tag}, |
| 8 | page::{Definition, MediaIndex}, | 8 | page::{Definition, MediaIndex}, |
| ... | @@ -293,26 +293,7 @@ pub(crate) fn style_changes( | ... | @@ -293,26 +293,7 @@ pub(crate) fn style_changes( |
| 293 | if !raw.objects.contains_key(&style) { | 293 | if !raw.objects.contains_key(&style) { |
| 294 | let definition = definition | 294 | let definition = definition |
| 295 | .ok_or_else(|| invalid("A paragraph references a missing style definition"))?; | 295 | .ok_or_else(|| invalid("A paragraph references a missing style definition"))?; |
| 296 | let Kind::Style { name } = &definition.kind else { | 296 | let values = style_object_values(definition)?; |
| 297 | return Err(invalid("A paragraph style must be a style definition")); | ||
| 298 | }; | ||
| 299 | let format = &definition.format; | ||
| 300 | let mut values = super::content::style_values(format); | ||
| 301 | if let Some(name) = name { | ||
| 302 | values.push((0x1c00345a, crate::create::string(name))); | ||
| 303 | } | ||
| 304 | if let Some(alignment) = format.alignment { | ||
| 305 | values.push((0x0c003477, vec![alignment])); | ||
| 306 | } | ||
| 307 | for (property, value) in [ | ||
| 308 | (0x1400342e, format.space_before), | ||
| 309 | (0x1400342f, format.space_after), | ||
| 310 | (0x14003430, format.line_spacing), | ||
| 311 | ] { | ||
| 312 | if let Some(points) = value { | ||
| 313 | values.push((property, (points / 36.0).to_le_bytes().to_vec())); | ||
| 314 | } | ||
| 315 | } | ||
| 316 | let mut node = PropertyObject { | 297 | let mut node = PropertyObject { |
| 317 | jcid: 0x12004d, | 298 | jcid: 0x12004d, |
| 318 | bytes: crate::create::properties(&values)?, | 299 | bytes: crate::create::properties(&values)?, |
| ... | @@ -328,6 +309,170 @@ pub(crate) fn style_changes( | ... | @@ -328,6 +309,170 @@ pub(crate) fn style_changes( |
| 328 | Ok(changed) | 309 | Ok(changed) |
| 329 | } | 310 | } |
| 330 | 311 | ||
| 312 | /// Run properties to clear, by text and run range. | ||
| 313 | pub(crate) type Clears = Vec<(ExGuid, std::ops::Range<u32>, Vec<super::TextProperty>)>; | ||
| 314 | /// Paragraph properties to remove, by text. | ||
| 315 | pub(crate) type Spacing = Vec<(ExGuid, Vec<u32>)>; | ||
| 316 | |||
| 317 | /// Style-level values the texts of paragraph style `style` (or only text `only`) repeat: | ||
| 318 | /// run properties equal to the style's, by run range, and each text's paragraph properties | ||
| 319 | /// equal to the style's. Texts whose runs carry objects of their own stay as they are. | ||
| 320 | pub(crate) fn restyle_plan( | ||
| 321 | active: &ActivePage<'_>, | ||
| 322 | style: ExGuid, | ||
| 323 | only: Option<ExGuid>, | ||
| 324 | ) -> Result<(Clears, Spacing), Error> { | ||
| 325 | use super::TextProperty::*; | ||
| 326 | const CHARACTER: [super::TextProperty; 10] = [ | ||
| 327 | Bold, | ||
| 328 | Italic, | ||
| 329 | Underline, | ||
| 330 | Strike, | ||
| 331 | Superscript, | ||
| 332 | Subscript, | ||
| 333 | Font, | ||
| 334 | FontSize, | ||
| 335 | Color, | ||
| 336 | Highlight, | ||
| 337 | ]; | ||
| 338 | const PARAGRAPH: [u32; 4] = [0x0c003477, 0x1400342e, 0x1400342f, 0x14003430]; | ||
| 339 | let raw = &active.live.revision; | ||
| 340 | let values = |id: ExGuid| -> Result<BTreeMap<u32, (u32, Vec<u8>)>, Error> { | ||
| 341 | let ObjectData::Properties(bytes) = raw.objects[&id].data else { | ||
| 342 | return Err(invalid("A style holds no properties")); | ||
| 343 | }; | ||
| 344 | Ok(PropertySets::parse(bytes)?.sets[0] | ||
| 345 | .iter() | ||
| 346 | .map(|property| { | ||
| 347 | let value = match property.value { | ||
| 348 | Value::Bytes(bytes) => bytes.to_vec(), | ||
| 349 | _ => Vec::new(), | ||
| 350 | }; | ||
| 351 | (property.id & 0x7fffffff, (property.id, value)) | ||
| 352 | }) | ||
| 353 | .collect()) | ||
| 354 | }; | ||
| 355 | let stored = values(style)?; | ||
| 356 | let repeats = |own: &BTreeMap<u32, (u32, Vec<u8>)>, key: u32| { | ||
| 357 | own.get(&key) | ||
| 358 | .is_some_and(|value| stored.get(&key) == Some(value)) | ||
| 359 | }; | ||
| 360 | let mut clears = Vec::new(); | ||
| 361 | let mut spacing = Vec::new(); | ||
| 362 | for (&text, node) in &active.view.nodes { | ||
| 363 | let Kind::RichText { | ||
| 364 | text: characters, | ||
| 365 | runs, | ||
| 366 | paragraph_style: Some(paragraph_style), | ||
| 367 | boilerplate: false, | ||
| 368 | } = &node.kind | ||
| 369 | else { | ||
| 370 | continue; | ||
| 371 | }; | ||
| 372 | if *paragraph_style != style || only.is_some_and(|only| only != text) { | ||
| 373 | continue; | ||
| 374 | } | ||
| 375 | let own = values(text)?; | ||
| 376 | if own.contains_key(&0x24003458) { | ||
| 377 | continue; | ||
| 378 | } | ||
| 379 | let repeated: Vec<u32> = PARAGRAPH | ||
| 380 | .into_iter() | ||
| 381 | .filter(|key| repeats(&own, *key)) | ||
| 382 | .collect(); | ||
| 383 | if !repeated.is_empty() { | ||
| 384 | spacing.push((text, repeated)); | ||
| 385 | } | ||
| 386 | let mut pending: Option<(std::ops::Range<u32>, Vec<super::TextProperty>)> = None; | ||
| 387 | for run in runs { | ||
| 388 | let cleared: Vec<super::TextProperty> = match run.format { | ||
| 389 | Some(id) => { | ||
| 390 | let own = values(id)?; | ||
| 391 | CHARACTER | ||
| 392 | .into_iter() | ||
| 393 | .filter(|property| repeats(&own, property.id())) | ||
| 394 | .collect() | ||
| 395 | } | ||
| 396 | None => Vec::new(), | ||
| 397 | }; | ||
| 398 | let range = if characters.is_empty() { | ||
| 399 | 0..0 | ||
| 400 | } else { | ||
| 401 | run.start..run.end | ||
| 402 | }; | ||
| 403 | match &mut pending { | ||
| 404 | Some((open, kept)) if *kept == cleared && open.end == range.start => { | ||
| 405 | open.end = range.end; | ||
| 406 | } | ||
| 407 | _ => { | ||
| 408 | if let Some((range, clear)) = pending.take() { | ||
| 409 | clears.push((text, range, clear)); | ||
| 410 | } | ||
| 411 | pending = Some((range, cleared)); | ||
| 412 | } | ||
| 413 | } | ||
| 414 | } | ||
| 415 | clears.extend(pending.map(|(range, clear)| (text, range, clear))); | ||
| 416 | } | ||
| 417 | clears.retain(|(_, _, clear)| !clear.is_empty()); | ||
| 418 | Ok((clears, spacing)) | ||
| 419 | } | ||
| 420 | |||
| 421 | /// The texts of paragraph style `style` moved to style `into`, created from `definition` | ||
| 422 | /// where the page lacks it, each without its `spacing` properties. | ||
| 423 | pub(crate) fn restyle_changes( | ||
| 424 | active: &ActivePage<'_>, | ||
| 425 | style: ExGuid, | ||
| 426 | into: ExGuid, | ||
| 427 | definition: &Definition, | ||
| 428 | spacing: &[(ExGuid, Vec<u32>)], | ||
| 429 | ) -> Result<Changes, Error> { | ||
| 430 | let mut changed = BTreeMap::new(); | ||
| 431 | for (&text, node) in &active.view.nodes { | ||
| 432 | if !matches!(node.kind, Kind::RichText { paragraph_style: Some(id), .. } if id == style) { | ||
| 433 | continue; | ||
| 434 | } | ||
| 435 | let mut moved = style_changes(active, text, into, Some(definition))?; | ||
| 436 | let mut target = moved.remove(&text).expect("the text is restyled"); | ||
| 437 | if let Some((_, ids)) = spacing.iter().find(|(id, _)| *id == text) { | ||
| 438 | target.remove(ids)?; | ||
| 439 | } | ||
| 440 | changed.insert(text, target); | ||
| 441 | changed.extend(moved); | ||
| 442 | } | ||
| 443 | Ok(changed) | ||
| 444 | } | ||
| 445 | |||
| 446 | /// The properties of a paragraph style object holding `definition`: NextStyle first, as | ||
| 447 | /// OneNote 2010 writes it, then the character formatting, the name and paragraph spacing. | ||
| 448 | pub(crate) fn style_object_values(definition: &Definition) -> Result<Values, Error> { | ||
| 449 | let Kind::Style { name, next } = &definition.kind else { | ||
| 450 | return Err(invalid("A paragraph style must be a style definition")); | ||
| 451 | }; | ||
| 452 | let format = &definition.format; | ||
| 453 | let mut values = Values::new(); | ||
| 454 | if let Some(next) = next { | ||
| 455 | values.push((0x1c00348a, crate::create::string(next))); | ||
| 456 | } | ||
| 457 | values.extend(super::content::style_values(format)); | ||
| 458 | if let Some(name) = name { | ||
| 459 | values.push((0x1c00345a, crate::create::string(name))); | ||
| 460 | } | ||
| 461 | if let Some(alignment) = format.alignment { | ||
| 462 | values.push((0x0c003477, vec![alignment])); | ||
| 463 | } | ||
| 464 | for (property, value) in [ | ||
| 465 | (0x1400342e, format.space_before), | ||
| 466 | (0x1400342f, format.space_after), | ||
| 467 | (0x14003430, format.line_spacing), | ||
| 468 | ] { | ||
| 469 | if let Some(points) = value { | ||
| 470 | values.push((property, (points / 36.0).to_le_bytes().to_vec())); | ||
| 471 | } | ||
| 472 | } | ||
| 473 | Ok(values) | ||
| 474 | } | ||
| 475 | |||
| 331 | /// The text-object properties of paragraph formatting, in the order they are written. | 476 | /// The text-object properties of paragraph formatting, in the order they are written. |
| 332 | pub(crate) fn paragraph_values( | 477 | pub(crate) fn paragraph_values( |
| 333 | alignment: Option<u8>, | 478 | alignment: Option<u8>, |
crates/onestore/src/op/tests.rs+199-1| ... | @@ -631,7 +631,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { | ... | @@ -631,7 +631,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 631 | }; | 631 | }; |
| 632 | let style = (family == Family::Style).then(|| { | 632 | let style = (family == Family::Style).then(|| { |
| 633 | let existing = page.definitions.iter().find_map(|(id, d)| { | 633 | let existing = page.definitions.iter().find_map(|(id, d)| { |
| 634 | matches!(&d.kind, Kind::Style { name: Some(name) } if name == "Heading 2") | 634 | matches!(&d.kind, Kind::Style { name: Some(name), .. } if name == "Heading 2") |
| 635 | .then_some(*id) | 635 | .then_some(*id) |
| 636 | }); | 636 | }); |
| 637 | existing.unwrap_or_else(|| { | 637 | existing.unwrap_or_else(|| { |
| ... | @@ -641,6 +641,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { | ... | @@ -641,6 +641,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 641 | Definition { | 641 | Definition { |
| 642 | kind: Kind::Style { | 642 | kind: Kind::Style { |
| 643 | name: Some("Heading 2".into()), | 643 | name: Some("Heading 2".into()), |
| 644 | next: None, | ||
| 644 | }, | 645 | }, |
| 645 | format: Format { | 646 | format: Format { |
| 646 | font: Some("Calibri".into()), | 647 | font: Some("Calibri".into()), |
| ... | @@ -1103,6 +1104,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { | ... | @@ -1103,6 +1104,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 1103 | alt: Some("dot".into()), | 1104 | alt: Some("dot".into()), |
| 1104 | background: false, | 1105 | background: false, |
| 1105 | printout: None, | 1106 | printout: None, |
| 1107 | tags: Vec::new(), | ||
| 1108 | link: None, | ||
| 1109 | text: None, | ||
| 1106 | }), | 1110 | }), |
| 1107 | Family::Attachment => ParagraphContent::Attachment(Attachment { | 1111 | Family::Attachment => ParagraphContent::Attachment(Attachment { |
| 1108 | id: id(), | 1112 | id: id(), |
| ... | @@ -1113,6 +1117,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { | ... | @@ -1113,6 +1117,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 1113 | bytes: Some(b"attached bytes".as_slice().into()), | 1117 | bytes: Some(b"attached bytes".as_slice().into()), |
| 1114 | preview: None, | 1118 | preview: None, |
| 1115 | recording: None, | 1119 | recording: None, |
| 1120 | tags: Vec::new(), | ||
| 1116 | }), | 1121 | }), |
| 1117 | _ => { | 1122 | _ => { |
| 1118 | let Some(equation) = equation else { | 1123 | let Some(equation) = equation else { |
| ... | @@ -1158,6 +1163,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { | ... | @@ -1158,6 +1163,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 1158 | alt: None, | 1163 | alt: None, |
| 1159 | background: false, | 1164 | background: false, |
| 1160 | printout: None, | 1165 | printout: None, |
| 1166 | tags: Vec::new(), | ||
| 1167 | link: None, | ||
| 1168 | text: None, | ||
| 1161 | }) | 1169 | }) |
| 1162 | } else { | 1170 | } else { |
| 1163 | PageObject::Ink(Ink { | 1171 | PageObject::Ink(Ink { |
| ... | @@ -1816,6 +1824,7 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { | ... | @@ -1816,6 +1824,7 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { |
| 1816 | definition: Definition { | 1824 | definition: Definition { |
| 1817 | kind: Kind::Style { | 1825 | kind: Kind::Style { |
| 1818 | name: Some("Heading 3".into()), | 1826 | name: Some("Heading 3".into()), |
| 1827 | next: None, | ||
| 1819 | }, | 1828 | }, |
| 1820 | format: Format { | 1829 | format: Format { |
| 1821 | bold: Some(true), | 1830 | bold: Some(true), |
| ... | @@ -3122,3 +3131,192 @@ fn deleted_objects_added_back_store_as_they_were() { | ... | @@ -3122,3 +3131,192 @@ fn deleted_objects_added_back_store_as_they_were() { |
| 3122 | assert_eq!(shape(&mut section, object), stored, "{title}"); | 3131 | assert_eq!(shape(&mut section, object), stored, "{title}"); |
| 3123 | } | 3132 | } |
| 3124 | } | 3133 | } |
| 3134 | |||
| 3135 | /// A style definition named `name` in random formatting, as a theme gives one. | ||
| 3136 | fn random_style(name: &str, rng: &mut Rng) -> Definition { | ||
| 3137 | let fonts = ["Georgia", "Arial", "Calibri", "Courier New"]; | ||
| 3138 | Definition { | ||
| 3139 | kind: Kind::Style { | ||
| 3140 | name: Some(name.into()), | ||
| 3141 | next: rng.coin().then(|| "p".into()), | ||
| 3142 | }, | ||
| 3143 | format: Format { | ||
| 3144 | bold: Some(rng.coin()), | ||
| 3145 | italic: Some(rng.coin()), | ||
| 3146 | underline: Some(false), | ||
| 3147 | strike: Some(false), | ||
| 3148 | superscript: Some(false), | ||
| 3149 | subscript: Some(false), | ||
| 3150 | font: Some(fonts[rng.next() as usize % fonts.len()].into()), | ||
| 3151 | font_size: Some(8.0 + (rng.next() % 30) as f32 / 2.0), | ||
| 3152 | color: Some(if rng.coin() { | ||
| 3153 | 0xff00_0000 | ||
| 3154 | } else { | ||
| 3155 | rng.next() as u32 & 0xff_ffff | ||
| 3156 | }), | ||
| 3157 | highlight: Some(0xff00_0000), | ||
| 3158 | space_before: Some((rng.next() % 4) as f32 * 3.0), | ||
| 3159 | space_after: Some((rng.next() % 3) as f32 * 2.0), | ||
| 3160 | line_spacing: Some(0.0), | ||
| 3161 | ..Default::default() | ||
| 3162 | }, | ||
| 3163 | } | ||
| 3164 | } | ||
| 3165 | |||
| 3166 | /// Restyling a page's paragraph styles, after runs were given values their style already | ||
| 3167 | /// gives, reads back as the model predicts; reopened, the section holds the same pages, and | ||
| 3168 | /// a second restyle to the same sheet has nothing left to do. | ||
| 3169 | #[test] | ||
| 3170 | fn restyles_read_back_as_the_model_predicts() { | ||
| 3171 | let sources = SOURCES.iter().copied().chain([( | ||
| 3172 | "styles", | ||
| 3173 | &include_bytes!("../../../../corpus/styles/onenote/Styles.one")[..], | ||
| 3174 | )]); | ||
| 3175 | let mut restyled = 0; | ||
| 3176 | for (name, source) in sources { | ||
| 3177 | for space in pages(source) { | ||
| 3178 | let arena = Arena::default(); | ||
| 3179 | let mut section = Section::open(&arena, source.to_vec()).unwrap(); | ||
| 3180 | let mut rng = Rng(space.n as u64 * 7919 + name.len() as u64); | ||
| 3181 | let before = section.page(space).unwrap(); | ||
| 3182 | // Runs stating their style's font and size, which the restyle clears. | ||
| 3183 | let mut ops = Vec::new(); | ||
| 3184 | for (_, _, list) in model::lists(&before) { | ||
| 3185 | for paragraph in list { | ||
| 3186 | let (Some(style), Some(text)) = (paragraph.style, paragraph.text()) else { | ||
| 3187 | continue; | ||
| 3188 | }; | ||
| 3189 | let format = &before.definitions[&style].format; | ||
| 3190 | let length = text.text.utf16_offset(text.text.text().len()).unwrap(); | ||
| 3191 | if length == 0 || text.date_field.is_some() || !rng.coin() { | ||
| 3192 | continue; | ||
| 3193 | } | ||
| 3194 | let set: Vec<TextAttribute> = [ | ||
| 3195 | format.font.clone().map(TextAttribute::Font), | ||
| 3196 | format.font_size.map(TextAttribute::FontSize), | ||
| 3197 | ] | ||
| 3198 | .into_iter() | ||
| 3199 | .flatten() | ||
| 3200 | .collect(); | ||
| 3201 | if !set.is_empty() && !crate::page::Math::is_equation(&text.text) { | ||
| 3202 | ops.push(PageOp::Format { | ||
| 3203 | text: text.id, | ||
| 3204 | range: 0..length, | ||
| 3205 | set, | ||
| 3206 | clear: Vec::new(), | ||
| 3207 | }); | ||
| 3208 | } | ||
| 3209 | } | ||
| 3210 | } | ||
| 3211 | let mut stated = before.clone(); | ||
| 3212 | for op in ops.clone() { | ||
| 3213 | if section | ||
| 3214 | .apply( | ||
| 3215 | "Author", | ||
| 3216 | &Edit { | ||
| 3217 | at: AT, | ||
| 3218 | ops: vec![Op::Page { | ||
| 3219 | space, | ||
| 3220 | op: op.clone(), | ||
| 3221 | }], | ||
| 3222 | }, | ||
| 3223 | ) | ||
| 3224 | .is_ok() | ||
| 3225 | { | ||
| 3226 | model::apply(&mut stated, &op).unwrap(); | ||
| 3227 | } | ||
| 3228 | } | ||
| 3229 | let names: BTreeSet<String> = stated | ||
| 3230 | .definitions | ||
| 3231 | .values() | ||
| 3232 | .filter_map(|definition| match &definition.kind { | ||
| 3233 | Kind::Style { name, .. } => name.clone(), | ||
| 3234 | _ => None, | ||
| 3235 | }) | ||
| 3236 | .collect(); | ||
| 3237 | let sheet: BTreeMap<String, Definition> = names | ||
| 3238 | .iter() | ||
| 3239 | .map(|style| (style.clone(), random_style(style, &mut rng))) | ||
| 3240 | .collect(); | ||
| 3241 | let ops = restyle(&stated, &sheet).unwrap(); | ||
| 3242 | if ops.is_empty() { | ||
| 3243 | continue; | ||
| 3244 | } | ||
| 3245 | restyled += ops.len(); | ||
| 3246 | let edit = Edit { | ||
| 3247 | at: AT + 1, | ||
| 3248 | ops: ops | ||
| 3249 | .iter() | ||
| 3250 | .map(|op| Op::Page { | ||
| 3251 | space, | ||
| 3252 | op: op.clone(), | ||
| 3253 | }) | ||
| 3254 | .collect(), | ||
| 3255 | }; | ||
| 3256 | section | ||
| 3257 | .apply("Author", &edit) | ||
| 3258 | .unwrap_or_else(|error| panic!("{name} {space:?}: {error:?}")); | ||
| 3259 | let mut predicted = section.page(space).unwrap(); | ||
| 3260 | let stored = predicted.clone(); | ||
| 3261 | predicted.clone_from(&stated); | ||
| 3262 | for op in &ops { | ||
| 3263 | model::apply(&mut predicted, op).unwrap(); | ||
| 3264 | } | ||
| 3265 | assert!( | ||
| 3266 | normalize(&predicted) == normalize(&stored), | ||
| 3267 | "{name} {space:?}: {}", | ||
| 3268 | first_difference(&normalize(&stored), &normalize(&predicted)) | ||
| 3269 | ); | ||
| 3270 | assert!( | ||
| 3271 | restyle(&stored, &sheet).unwrap().is_empty(), | ||
| 3272 | "{name}: restyled twice" | ||
| 3273 | ); | ||
| 3274 | section.seal().unwrap(); | ||
| 3275 | let reopened = Section::open(&arena, section.image()).unwrap(); | ||
| 3276 | assert!(reopened.page(space).unwrap() == stored, "{name}: reopened"); | ||
| 3277 | } | ||
| 3278 | } | ||
| 3279 | assert!(restyled > 20, "{restyled} styles restyled"); | ||
| 3280 | } | ||
| 3281 | |||
| 3282 | /// A restyle keeps the style's name and needs the page to hold the style. | ||
| 3283 | #[test] | ||
| 3284 | fn restyle_refuses_renames_and_missing_styles() { | ||
| 3285 | let source = include_bytes!("../../../../corpus/styles/onenote/Styles.one"); | ||
| 3286 | let arena = Arena::default(); | ||
| 3287 | let mut section = Section::open(&arena, source.to_vec()).unwrap(); | ||
| 3288 | let space = pages(source)[0]; | ||
| 3289 | let page = section.page(space).unwrap(); | ||
| 3290 | let (&h1, stored) = page | ||
| 3291 | .definitions | ||
| 3292 | .iter() | ||
| 3293 | .find(|(_, d)| matches!(&d.kind, Kind::Style { name: Some(name), .. } if name == "h1")) | ||
| 3294 | .unwrap(); | ||
| 3295 | assert_eq!( | ||
| 3296 | stored.kind, | ||
| 3297 | Kind::Style { | ||
| 3298 | name: Some("h1".into()), | ||
| 3299 | next: Some("p".into()), | ||
| 3300 | } | ||
| 3301 | ); | ||
| 3302 | let mut renamed = stored.clone(); | ||
| 3303 | renamed.kind = Kind::Style { | ||
| 3304 | name: Some("h2".into()), | ||
| 3305 | next: None, | ||
| 3306 | }; | ||
| 3307 | for (style, definition) in [(h1, renamed), (id(), stored.clone())] { | ||
| 3308 | let edit = Edit { | ||
| 3309 | at: AT, | ||
| 3310 | ops: vec![Op::Page { | ||
| 3311 | space, | ||
| 3312 | op: PageOp::Restyle { | ||
| 3313 | style, | ||
| 3314 | into: id(), | ||
| 3315 | definition, | ||
| 3316 | }, | ||
| 3317 | }], | ||
| 3318 | }; | ||
| 3319 | assert!(section.apply("Author", &edit).is_err()); | ||
| 3320 | assert!(section.page(space).unwrap() == page); | ||
| 3321 | } | ||
| 3322 | } |
crates/onestore/src/page/mod.rs+167-63| ... | @@ -285,6 +285,10 @@ pub struct Attachment { | ... | @@ -285,6 +285,10 @@ pub struct Attachment { |
| 285 | /// Set when the file is audio or video OneNote plays; notes on the page refer to it by | 285 | /// Set when the file is audio or video OneNote plays; notes on the page refer to it by |
| 286 | /// identity (`PageParagraph::media`). | 286 | /// identity (`PageParagraph::media`). |
| 287 | pub recording: Option<Recording>, | 287 | pub recording: Option<Recording>, |
| 288 | /// Note tags on the file itself, where OneNote stores a tagged file's, in a paragraph or | ||
| 289 | /// on the page. | ||
| 290 | #[serde(default)] | ||
| 291 | pub tags: Vec<Tag>, | ||
| 288 | } | 292 | } |
| 289 | 293 | ||
| 290 | /// A recording's identity, whether it is audio (1) or video (2), and its length. | 294 | /// A recording's identity, whether it is audio (1) or video (2), and its length. |
| ... | @@ -324,6 +328,7 @@ impl PartialEq for Attachment { | ... | @@ -324,6 +328,7 @@ impl PartialEq for Attachment { |
| 324 | && self.size == other.size | 328 | && self.size == other.size |
| 325 | && self.layout == other.layout | 329 | && self.layout == other.layout |
| 326 | && self.recording == other.recording | 330 | && self.recording == other.recording |
| 331 | && self.tags == other.tags | ||
| 327 | } | 332 | } |
| 328 | } | 333 | } |
| 329 | 334 | ||
| ... | @@ -376,6 +381,7 @@ impl Attachment { | ... | @@ -376,6 +381,7 @@ impl Attachment { |
| 376 | .unwrap_or(1), | 381 | .unwrap_or(1), |
| 377 | duration_ms: *recording_duration, | 382 | duration_ms: *recording_duration, |
| 378 | }), | 383 | }), |
| 384 | tags: node.tags.clone(), | ||
| 379 | }) | 385 | }) |
| 380 | } | 386 | } |
| 381 | } | 387 | } |
| ... | @@ -435,12 +441,52 @@ pub struct Image { | ... | @@ -435,12 +441,52 @@ pub struct Image { |
| 435 | pub display: Option<Arc<[u8]>>, | 441 | pub display: Option<Arc<[u8]>>, |
| 436 | pub alt: Option<String>, | 442 | pub alt: Option<String>, |
| 437 | pub background: bool, | 443 | pub background: bool, |
| 438 | /// The page of a file printout the picture shows, zero-based (DisplayedPageNumber); | 444 | /// Set when the picture is a page of a file printout, which OneNote frames. |
| 439 | /// OneNote frames printout pages. | 445 | #[serde(default)] |
| 446 | pub printout: Option<Printout>, | ||
| 447 | /// Note tags on the picture itself, where OneNote stores a tagged picture's, in a | ||
| 448 | /// paragraph or on the page. | ||
| 449 | #[serde(default)] | ||
| 450 | pub tags: Vec<Tag>, | ||
| 451 | /// The address the picture links to (WzHyperlinkUrl). | ||
| 452 | #[serde(default)] | ||
| 453 | pub link: Option<String>, | ||
| 454 | /// Text OneNote recognised in the picture, or a printout page's text; OneNote searches | ||
| 455 | /// it. | ||
| 440 | #[serde(default)] | 456 | #[serde(default)] |
| 441 | pub printout: Option<u32>, | 457 | pub text: Option<Recognized>, |
| 442 | } | 458 | } |
| 443 | 459 | ||
| 460 | /// Text a picture holds (RichEditTextUnicode). | ||
| 461 | #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] | ||
| 462 | pub struct Recognized { | ||
| 463 | pub text: String, | ||
| 464 | /// Its language (RichEditTextLangID) and the undocumented layout of its words, | ||
| 465 | /// `RECOGNIZED_PROPERTIES`, written back as read. | ||
| 466 | pub stored: Vec<(u32, Vec<u8>)>, | ||
| 467 | } | ||
| 468 | |||
| 469 | /// RichEditTextLangID, and where OneNote 2010 found each word of a picture's text | ||
| 470 | /// (`corpus/object-tags`), not in MS-ONE. | ||
| 471 | pub(crate) const RECOGNIZED_PROPERTIES: [u32; 2] = [0x10001cfe, 0x1c001d61]; | ||
| 472 | |||
| 473 | /// A page of a file printout. | ||
| 474 | #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] | ||
| 475 | pub struct Printout { | ||
| 476 | /// The page shown, zero-based (DisplayedPageNumber). | ||
| 477 | pub page: u32, | ||
| 478 | /// The undocumented properties tying the page to its XPS package, `PRINTOUT_PROPERTIES`, | ||
| 479 | /// written back as read; without them OneNote does not draw a page it reads back. | ||
| 480 | pub stored: Vec<(u32, Vec<u8>)>, | ||
| 481 | } | ||
| 482 | |||
| 483 | /// The printout's identity, its file's index and the page's place in it, as OneNote 2010 | ||
| 484 | /// stores them on a printout page (`corpus/printout`), not in MS-ONE; with the picture's | ||
| 485 | /// file name (ImageFilename) and the package's description and hash, also undocumented. | ||
| 486 | pub(crate) const PRINTOUT_PROPERTIES: [u32; 6] = [ | ||
| 487 | 0x1c001d84, 0x14003480, 0x14003481, 0x1c001dd7, 0x1c001dfb, 0x1c001dfc, | ||
| 488 | ]; | ||
| 489 | |||
| 444 | impl PartialEq for Image { | 490 | impl PartialEq for Image { |
| 445 | fn eq(&self, other: &Self) -> bool { | 491 | fn eq(&self, other: &Self) -> bool { |
| 446 | self.id == other.id | 492 | self.id == other.id |
| ... | @@ -449,6 +495,9 @@ impl PartialEq for Image { | ... | @@ -449,6 +495,9 @@ impl PartialEq for Image { |
| 449 | && self.alt == other.alt | 495 | && self.alt == other.alt |
| 450 | && self.background == other.background | 496 | && self.background == other.background |
| 451 | && self.printout == other.printout | 497 | && self.printout == other.printout |
| 498 | && self.tags == other.tags | ||
| 499 | && self.link == other.link | ||
| 500 | && self.text == other.text | ||
| 452 | } | 501 | } |
| 453 | } | 502 | } |
| 454 | 503 | ||
| ... | @@ -466,6 +515,8 @@ impl Image { | ... | @@ -466,6 +515,8 @@ impl Image { |
| 466 | printout, | 515 | printout, |
| 467 | picture_width, | 516 | picture_width, |
| 468 | picture_height, | 517 | picture_height, |
| 518 | link, | ||
| 519 | filename, | ||
| 469 | .. | 520 | .. |
| 470 | } = &node.kind | 521 | } = &node.kind |
| 471 | else { | 522 | else { |
| ... | @@ -498,8 +549,8 @@ impl Image { | ... | @@ -498,8 +549,8 @@ impl Image { |
| 498 | display: payload(web)?, | 549 | display: payload(web)?, |
| 499 | alt: alt.clone(), | 550 | alt: alt.clone(), |
| 500 | background: background.unwrap_or(false), | 551 | background: background.unwrap_or(false), |
| 501 | printout: printout.unwrap_or(false).then(|| { | 552 | printout: printout.unwrap_or(false).then(|| Printout { |
| 502 | fields | 553 | page: fields |
| 503 | .iter() | 554 | .iter() |
| 504 | .find_map(|field| match field.value { | 555 | .find_map(|field| match field.value { |
| 505 | FieldValue::Bytes(&[a, b, c, d]) if field.id == 0x14001df9 => { | 556 | FieldValue::Bytes(&[a, b, c, d]) if field.id == 0x14001df9 => { |
| ... | @@ -507,8 +558,50 @@ impl Image { | ... | @@ -507,8 +558,50 @@ impl Image { |
| 507 | } | 558 | } |
| 508 | _ => None, | 559 | _ => None, |
| 509 | }) | 560 | }) |
| 510 | .unwrap_or(0) | 561 | .unwrap_or(0), |
| 562 | stored: filename | ||
| 563 | .iter() | ||
| 564 | .map(|name| (0x1c001dd7, crate::create::string(name))) | ||
| 565 | .chain(fields.iter().filter_map(|field| match field.value { | ||
| 566 | FieldValue::Bytes(bytes) if PRINTOUT_PROPERTIES.contains(&field.id) => { | ||
| 567 | Some((field.id, bytes.to_vec())) | ||
| 568 | } | ||
| 569 | _ => None, | ||
| 570 | })) | ||
| 571 | .collect(), | ||
| 511 | }), | 572 | }), |
| 573 | tags: node.tags.clone(), | ||
| 574 | link: link.clone(), | ||
| 575 | text: fields | ||
| 576 | .iter() | ||
| 577 | .find_map(|field| match field.value { | ||
| 578 | FieldValue::Bytes(bytes) if field.id == 0x1c001c22 => Some(bytes), | ||
| 579 | _ => None, | ||
| 580 | }) | ||
| 581 | .map(|bytes| { | ||
| 582 | let units: Vec<u16> = bytes | ||
| 583 | .chunks_exact(2) | ||
| 584 | .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) | ||
| 585 | .collect(); | ||
| 586 | String::from_utf16_lossy(&units) | ||
| 587 | .trim_end_matches('\0') | ||
| 588 | .to_owned() | ||
| 589 | }) | ||
| 590 | .filter(|text| !text.trim().is_empty()) | ||
| 591 | .map(|text| Recognized { | ||
| 592 | text, | ||
| 593 | stored: fields | ||
| 594 | .iter() | ||
| 595 | .filter_map(|field| match field.value { | ||
| 596 | FieldValue::Bytes(bytes) | ||
| 597 | if RECOGNIZED_PROPERTIES.contains(&field.id) => | ||
| 598 | { | ||
| 599 | Some((field.id, bytes.to_vec())) | ||
| 600 | } | ||
| 601 | _ => None, | ||
| 602 | }) | ||
| 603 | .collect(), | ||
| 604 | }), | ||
| 512 | }) | 605 | }) |
| 513 | } | 606 | } |
| 514 | } | 607 | } |
| ... | @@ -870,14 +963,14 @@ impl Page { | ... | @@ -870,14 +963,14 @@ impl Page { |
| 870 | page.objects.push(PageObject::Outline(outline)); | 963 | page.objects.push(PageObject::Outline(outline)); |
| 871 | } | 964 | } |
| 872 | } | 965 | } |
| 873 | Kind::Image { .. } => { | 966 | Kind::Image { .. } | Kind::Attachment { .. } => { |
| 874 | page.objects | 967 | for tag in node.tags.iter().filter_map(|tag| tag.definition) { |
| 875 | .push(PageObject::Image(Image::read(revision, id, node)?)); | 968 | define(revision, tag, false, &mut page.definitions)?; |
| 876 | } | 969 | } |
| 877 | Kind::Attachment { .. } => { | 970 | page.objects.push(match node.kind { |
| 878 | page.objects.push(PageObject::Attachment(Attachment::read( | 971 | Kind::Image { .. } => PageObject::Image(Image::read(revision, id, node)?), |
| 879 | revision, id, node, | 972 | _ => PageObject::Attachment(Attachment::read(revision, id, node)?), |
| 880 | )?)); | 973 | }); |
| 881 | } | 974 | } |
| 882 | Kind::Ink { .. } => { | 975 | Kind::Ink { .. } => { |
| 883 | page.objects | 976 | page.objects |
| ... | @@ -990,6 +1083,55 @@ pub(crate) fn text_of( | ... | @@ -990,6 +1083,55 @@ pub(crate) fn text_of( |
| 990 | }) | 1083 | }) |
| 991 | } | 1084 | } |
| 992 | 1085 | ||
| 1086 | /// Adds list or tag definition `id` to `definitions`. | ||
| 1087 | fn define( | ||
| 1088 | revision: &Revision<'_>, | ||
| 1089 | id: ExGuid, | ||
| 1090 | is_list: bool, | ||
| 1091 | definitions: &mut BTreeMap<ExGuid, Definition>, | ||
| 1092 | ) -> Result<(), Error> { | ||
| 1093 | let invalid = |message| Error { offset: 0, message }; | ||
| 1094 | let definition = revision | ||
| 1095 | .nodes | ||
| 1096 | .get(&id) | ||
| 1097 | .ok_or_else(|| invalid("Missing canvas list or tag definition"))?; | ||
| 1098 | let kind = match &definition.kind { | ||
| 1099 | Kind::List { | ||
| 1100 | font, | ||
| 1101 | format, | ||
| 1102 | restart, | ||
| 1103 | bullet, | ||
| 1104 | } if is_list => Kind::List { | ||
| 1105 | font: font.clone(), | ||
| 1106 | format: format.clone(), | ||
| 1107 | restart: *restart, | ||
| 1108 | bullet: *bullet, | ||
| 1109 | }, | ||
| 1110 | Kind::TagDefinition { | ||
| 1111 | label, | ||
| 1112 | action_type, | ||
| 1113 | shape, | ||
| 1114 | color, | ||
| 1115 | highlight, | ||
| 1116 | } if !is_list => Kind::TagDefinition { | ||
| 1117 | label: label.clone(), | ||
| 1118 | action_type: *action_type, | ||
| 1119 | shape: *shape, | ||
| 1120 | color: *color, | ||
| 1121 | highlight: *highlight, | ||
| 1122 | }, | ||
| 1123 | _ => return Err(invalid("Canvas list or tag definition has the wrong type")), | ||
| 1124 | }; | ||
| 1125 | definitions.insert( | ||
| 1126 | id, | ||
| 1127 | Definition { | ||
| 1128 | kind, | ||
| 1129 | format: definition.format.clone(), | ||
| 1130 | }, | ||
| 1131 | ); | ||
| 1132 | Ok(()) | ||
| 1133 | } | ||
| 1134 | |||
| 993 | fn read_paragraphs( | 1135 | fn read_paragraphs( |
| 994 | revision: &Revision<'_>, | 1136 | revision: &Revision<'_>, |
| 995 | container: &Element<'_>, | 1137 | container: &Element<'_>, |
| ... | @@ -1137,67 +1279,28 @@ fn read_paragraphs( | ... | @@ -1137,67 +1279,28 @@ fn read_paragraphs( |
| 1137 | .chain(match &content { | 1279 | .chain(match &content { |
| 1138 | ParagraphContent::Text(text) => text.tags.as_slice(), | 1280 | ParagraphContent::Text(text) => text.tags.as_slice(), |
| 1139 | ParagraphContent::Table(table) => table.tags.as_slice(), | 1281 | ParagraphContent::Table(table) => table.tags.as_slice(), |
| 1140 | ParagraphContent::Image(_) | 1282 | ParagraphContent::Image(image) => image.tags.as_slice(), |
| 1141 | | ParagraphContent::Attachment(_) | 1283 | ParagraphContent::Attachment(file) => file.tags.as_slice(), |
| 1142 | | ParagraphContent::Ink(_) | 1284 | ParagraphContent::Ink(_) | ParagraphContent::Unsupported(_) => &[], |
| 1143 | | ParagraphContent::Unsupported(_) => &[], | ||
| 1144 | }) | 1285 | }) |
| 1145 | .filter_map(|tag| tag.definition.as_ref()) | 1286 | .filter_map(|tag| tag.definition.as_ref()) |
| 1146 | .map(|id| (id, false)), | 1287 | .map(|id| (id, false)), |
| 1147 | ) { | 1288 | ) { |
| 1148 | let definition = revision | 1289 | define(revision, *id, is_list, definitions)?; |
| 1149 | .nodes | ||
| 1150 | .get(id) | ||
| 1151 | .ok_or_else(|| invalid("Missing canvas list or tag definition"))?; | ||
| 1152 | let kind = match &definition.kind { | ||
| 1153 | Kind::List { | ||
| 1154 | font, | ||
| 1155 | format, | ||
| 1156 | restart, | ||
| 1157 | bullet, | ||
| 1158 | } if is_list => Kind::List { | ||
| 1159 | font: font.clone(), | ||
| 1160 | format: format.clone(), | ||
| 1161 | restart: *restart, | ||
| 1162 | bullet: *bullet, | ||
| 1163 | }, | ||
| 1164 | Kind::TagDefinition { | ||
| 1165 | label, | ||
| 1166 | action_type, | ||
| 1167 | shape, | ||
| 1168 | color, | ||
| 1169 | highlight, | ||
| 1170 | } if !is_list => Kind::TagDefinition { | ||
| 1171 | label: label.clone(), | ||
| 1172 | action_type: *action_type, | ||
| 1173 | shape: *shape, | ||
| 1174 | color: *color, | ||
| 1175 | highlight: *highlight, | ||
| 1176 | }, | ||
| 1177 | _ => { | ||
| 1178 | return Err(invalid( | ||
| 1179 | "Canvas list or tag definition has the wrong type", | ||
| 1180 | )); | ||
| 1181 | } | ||
| 1182 | }; | ||
| 1183 | definitions.insert( | ||
| 1184 | *id, | ||
| 1185 | Definition { | ||
| 1186 | kind, | ||
| 1187 | format: definition.format.clone(), | ||
| 1188 | }, | ||
| 1189 | ); | ||
| 1190 | } | 1290 | } |
| 1191 | if let Some(id) = base_style { | 1291 | if let Some(id) = base_style { |
| 1192 | let definition = revision | 1292 | let definition = revision |
| 1193 | .nodes | 1293 | .nodes |
| 1194 | .get(&id) | 1294 | .get(&id) |
| 1195 | .ok_or_else(|| invalid("Missing canvas paragraph style"))?; | 1295 | .ok_or_else(|| invalid("Missing canvas paragraph style"))?; |
| 1196 | let Kind::Style { name } = &definition.kind else { | 1296 | let Kind::Style { name, next } = &definition.kind else { |
| 1197 | return Err(invalid("Canvas paragraph style has the wrong type")); | 1297 | return Err(invalid("Canvas paragraph style has the wrong type")); |
| 1198 | }; | 1298 | }; |
| 1199 | definitions.entry(id).or_insert_with(|| Definition { | 1299 | definitions.entry(id).or_insert_with(|| Definition { |
| 1200 | kind: Kind::Style { name: name.clone() }, | 1300 | kind: Kind::Style { |
| 1301 | name: name.clone(), | ||
| 1302 | next: next.clone(), | ||
| 1303 | }, | ||
| 1201 | format: definition.format.clone(), | 1304 | format: definition.format.clone(), |
| 1202 | }); | 1305 | }); |
| 1203 | } | 1306 | } |
| ... | @@ -1445,6 +1548,7 @@ mod tests { | ... | @@ -1445,6 +1548,7 @@ mod tests { |
| 1445 | }); | 1548 | }); |
| 1446 | let mut style = element(Kind::Style { | 1549 | let mut style = element(Kind::Style { |
| 1447 | name: Some("Body".into()), | 1550 | name: Some("Body".into()), |
| 1551 | next: None, | ||
| 1448 | }); | 1552 | }); |
| 1449 | style.format.font_size = Some(12.0); | 1553 | style.format.font_size = Some(12.0); |
| 1450 | let mut metadata = element(Kind::Metadata { | 1554 | let mut metadata = element(Kind::Metadata { |
| ... | @@ -1705,7 +1809,7 @@ mod tests { | ... | @@ -1705,7 +1809,7 @@ mod tests { |
| 1705 | assert_eq!(paragraph.parent, None); | 1809 | assert_eq!(paragraph.parent, None); |
| 1706 | assert_eq!(paragraph.style, Some(id(5))); | 1810 | assert_eq!(paragraph.style, Some(id(5))); |
| 1707 | assert!(matches!(&page.definitions[&id(5)].kind, | 1811 | assert!(matches!(&page.definitions[&id(5)].kind, |
| 1708 | Kind::Style { name: Some(name) } if name == "Body")); | 1812 | Kind::Style { name: Some(name), .. } if name == "Body")); |
| 1709 | assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0)); | 1813 | assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0)); |
| 1710 | let text = &paragraph.text().unwrap().text; | 1814 | let text = &paragraph.text().unwrap().text; |
| 1711 | assert_eq!(text.text(), "ab"); | 1815 | assert_eq!(text.text(), "ab"); |
crates/onestore/src/pages.rs+34-3| ... | @@ -506,6 +506,12 @@ pub struct PageCreation { | ... | @@ -506,6 +506,12 @@ pub struct PageCreation { |
| 506 | /// The page identity and creation time (FILETIME) a page keeps from elsewhere. | 506 | /// The page identity and creation time (FILETIME) a page keeps from elsewhere. |
| 507 | #[serde(default, skip_serializing_if = "Option::is_none")] | 507 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 508 | kept: Option<([u8; 16], u64)>, | 508 | kept: Option<([u8; 16], u64)>, |
| 509 | /// The title's font, OneNote's Default font face; none is Calibri. | ||
| 510 | #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| 511 | title_font: Option<String>, | ||
| 512 | /// The title's colour, OneNote's Default font colour as COLORREF; none is automatic. | ||
| 513 | #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| 514 | title_color: Option<u32>, | ||
| 509 | } | 515 | } |
| 510 | 516 | ||
| 511 | impl PageCreation { | 517 | impl PageCreation { |
| ... | @@ -522,6 +528,8 @@ impl PageCreation { | ... | @@ -522,6 +528,8 @@ impl PageCreation { |
| 522 | created: current_timestamps()?.0, | 528 | created: current_timestamps()?.0, |
| 523 | date: None, | 529 | date: None, |
| 524 | kept: None, | 530 | kept: None, |
| 531 | title_font: None, | ||
| 532 | title_color: None, | ||
| 525 | }; | 533 | }; |
| 526 | page.validate()?; | 534 | page.validate()?; |
| 527 | Ok(page) | 535 | Ok(page) |
| ... | @@ -538,6 +546,16 @@ impl PageCreation { | ... | @@ -538,6 +546,16 @@ impl PageCreation { |
| 538 | Ok(self) | 546 | Ok(self) |
| 539 | } | 547 | } |
| 540 | 548 | ||
| 549 | /// Sets the title in `font` and `color` (COLORREF; none is automatic), as OneNote 2010 | ||
| 550 | /// sets a new page's `PageTitle` quick style in its Default font's face and colour, | ||
| 551 | /// keeping the size (`corpus/default-font`). | ||
| 552 | pub fn titled_in(mut self, font: &str, color: Option<u32>) -> Result<Self, Error> { | ||
| 553 | self.title_font = Some(font.to_owned()); | ||
| 554 | self.title_color = color; | ||
| 555 | self.validate()?; | ||
| 556 | Ok(self) | ||
| 557 | } | ||
| 558 | |||
| 541 | /// Keeps `identity`, the page identity internal links name, and `created`, as OneNote | 559 | /// Keeps `identity`, the page identity internal links name, and `created`, as OneNote |
| 542 | /// 2010 keeps both for a page it moves to the notebook's recycle bin. | 560 | /// 2010 keeps both for a page it moves to the notebook's recycle bin. |
| 543 | pub fn keeping(mut self, identity: [u8; 16], created: u64) -> Result<Self, Error> { | 561 | pub fn keeping(mut self, identity: [u8; 16], created: u64) -> Result<Self, Error> { |
| ... | @@ -626,6 +644,10 @@ impl PageCreation { | ... | @@ -626,6 +644,10 @@ impl PageCreation { |
| 626 | .iter() | 644 | .iter() |
| 627 | .chain(self.date.iter().flatten()) | 645 | .chain(self.date.iter().flatten()) |
| 628 | .any(|text| text.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) | 646 | .any(|text| text.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) |
| 647 | || self | ||
| 648 | .title_font | ||
| 649 | .as_ref() | ||
| 650 | .is_some_and(|font| font.is_empty() || font.contains('\0')) | ||
| 629 | { | 651 | { |
| 630 | return Err(invalid( | 652 | return Err(invalid( |
| 631 | "Use a new page identity, an existing page anchor, and ordinary single-line title text", | 653 | "Use a new page identity, an existing page anchor, and ordinary single-line title text", |
| ... | @@ -924,11 +946,20 @@ impl PageCreation { | ... | @@ -924,11 +946,20 @@ impl PageCreation { |
| 924 | ( | 946 | ( |
| 925 | 18, | 947 | 18, |
| 926 | 0x12004d, | 948 | 0x12004d, |
| 927 | vec![ | 949 | [ |
| 928 | (0x1c00345a, string("PageTitle")), | 950 | (0x1c00345a, string("PageTitle")), |
| 929 | (0x1c001c0a, string("Calibri")), | 951 | ( |
| 952 | 0x1c001c0a, | ||
| 953 | string(self.title_font.as_deref().unwrap_or("Calibri")), | ||
| 954 | ), | ||
| 930 | (0x10001c0b, 34_u16.to_le_bytes().to_vec()), | 955 | (0x10001c0b, 34_u16.to_le_bytes().to_vec()), |
| 931 | ], | 956 | ] |
| 957 | .into_iter() | ||
| 958 | .chain( | ||
| 959 | self.title_color | ||
| 960 | .map(|color| (0x14001c0c, color.to_le_bytes().to_vec())), | ||
| 961 | ) | ||
| 962 | .collect(), | ||
| 932 | ), | 963 | ), |
| 933 | (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]), | 964 | (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]), |
| 934 | ] { | 965 | ] { |
crates/onestore/src/write.rs+7-2| ... | @@ -419,9 +419,11 @@ pub(crate) fn replacement(id: ExGuid, object: &crate::Object<'_>) -> Result<Prop | ... | @@ -419,9 +419,11 @@ pub(crate) fn replacement(id: ExGuid, object: &crate::Object<'_>) -> Result<Prop |
| 419 | } | 419 | } |
| 420 | 420 | ||
| 421 | /// The object type OneNote gives embedded picture payload declarations; embedded files | 421 | /// The object type OneNote gives embedded picture payload declarations; embedded files |
| 422 | /// use `EMBEDDED_FILE_JCID` with the same declaration shape. | 422 | /// use `EMBEDDED_FILE_JCID` with the same declaration shape, and a printout's XPS package |
| 423 | /// `PRINTOUT_FILE_JCID` (`corpus/printout`). | ||
| 423 | pub(crate) const FILE_DATA_JCID: u32 = 0x80039; | 424 | pub(crate) const FILE_DATA_JCID: u32 = 0x80039; |
| 424 | pub(crate) const EMBEDDED_FILE_JCID: u32 = 0x80036; | 425 | pub(crate) const EMBEDDED_FILE_JCID: u32 = 0x80036; |
| 426 | pub(crate) const PRINTOUT_FILE_JCID: u32 = 0x8003a; | ||
| 425 | 427 | ||
| 426 | /// The reference and extension of a file-data declaration built by `PropertyObject::file`. | 428 | /// The reference and extension of a file-data declaration built by `PropertyObject::file`. |
| 427 | fn file_declaration(bytes: &[u8]) -> Result<(&[u8], &[u8])> { | 429 | fn file_declaration(bytes: &[u8]) -> Result<(&[u8], &[u8])> { |
| ... | @@ -470,7 +472,10 @@ pub(crate) fn declared( | ... | @@ -470,7 +472,10 @@ pub(crate) fn declared( |
| 470 | } | 472 | } |
| 471 | 473 | ||
| 472 | fn is_file_declaration(jcid: u32) -> bool { | 474 | fn is_file_declaration(jcid: u32) -> bool { |
| 473 | jcid == FILE_DATA_JCID || jcid == EMBEDDED_FILE_JCID | 475 | matches!( |
| 476 | jcid, | ||
| 477 | FILE_DATA_JCID | EMBEDDED_FILE_JCID | PRINTOUT_FILE_JCID | ||
| 478 | ) | ||
| 474 | } | 479 | } |
| 475 | 480 | ||
| 476 | #[derive(Clone)] | 481 | #[derive(Clone)] |
crates/onestore/tests/page_attachments.rs+3| ... | @@ -116,6 +116,7 @@ fn insert_attachment(section: &str, preview: Option<Arc<[u8]>>) -> Vec<u8> { | ... | @@ -116,6 +116,7 @@ fn insert_attachment(section: &str, preview: Option<Arc<[u8]>>) -> Vec<u8> { |
| 116 | bytes: Some(Arc::from(PAYLOAD)), | 116 | bytes: Some(Arc::from(PAYLOAD)), |
| 117 | preview: preview.clone(), | 117 | preview: preview.clone(), |
| 118 | recording: None, | 118 | recording: None, |
| 119 | tags: Vec::new(), | ||
| 119 | }); | 120 | }); |
| 120 | body_paragraphs(&mut after).push(holder.clone()); | 121 | body_paragraphs(&mut after).push(holder.clone()); |
| 121 | body_paragraphs(&mut after).push(plain_paragraph(&template, "After the file")); | 122 | body_paragraphs(&mut after).push(plain_paragraph(&template, "After the file")); |
| ... | @@ -187,6 +188,7 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { | ... | @@ -187,6 +188,7 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { |
| 187 | bytes: Some(Arc::from(PAYLOAD)), | 188 | bytes: Some(Arc::from(PAYLOAD)), |
| 188 | preview: None, | 189 | preview: None, |
| 189 | recording: None, | 190 | recording: None, |
| 191 | tags: Vec::new(), | ||
| 190 | }); | 192 | }); |
| 191 | body_paragraphs(&mut after).push(holder); | 193 | body_paragraphs(&mut after).push(holder); |
| 192 | assert!(ops::saved(&source, space, &after).is_err()); | 194 | assert!(ops::saved(&source, space, &after).is_err()); |
| ... | @@ -302,6 +304,7 @@ fn a_file_on_the_page_is_attached_moved_renamed_and_removed_as_ops() { | ... | @@ -302,6 +304,7 @@ fn a_file_on_the_page_is_attached_moved_renamed_and_removed_as_ops() { |
| 302 | bytes: Some(Arc::from(PAYLOAD)), | 304 | bytes: Some(Arc::from(PAYLOAD)), |
| 303 | preview: attachments(&native)[0].preview.clone(), | 305 | preview: attachments(&native)[0].preview.clone(), |
| 304 | recording: None, | 306 | recording: None, |
| 307 | tags: Vec::new(), | ||
| 305 | }; | 308 | }; |
| 306 | let steps: [&dyn Fn(&mut Page); 4] = [ | 309 | let steps: [&dyn Fn(&mut Page); 4] = [ |
| 307 | &|page| page.objects.push(PageObject::Attachment(file.clone())), | 310 | &|page| page.objects.push(PageObject::Attachment(file.clone())), |
crates/onestore/tests/page_images.rs+9| ... | @@ -119,6 +119,9 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { | ... | @@ -119,6 +119,9 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { |
| 119 | alt: None, | 119 | alt: None, |
| 120 | background: false, | 120 | background: false, |
| 121 | printout: None, | 121 | printout: None, |
| 122 | tags: Vec::new(), | ||
| 123 | link: None, | ||
| 124 | text: None, | ||
| 122 | }; | 125 | }; |
| 123 | holder.content = ParagraphContent::Image(image.clone()); | 126 | holder.content = ParagraphContent::Image(image.clone()); |
| 124 | body_paragraphs(&mut after).push(holder.clone()); | 127 | body_paragraphs(&mut after).push(holder.clone()); |
| ... | @@ -299,6 +302,9 @@ fn a_page_level_picture_is_inserted_moved_and_removed() { | ... | @@ -299,6 +302,9 @@ fn a_page_level_picture_is_inserted_moved_and_removed() { |
| 299 | alt: Some("Placed in Rust".into()), | 302 | alt: Some("Placed in Rust".into()), |
| 300 | background: false, | 303 | background: false, |
| 301 | printout: None, | 304 | printout: None, |
| 305 | tags: Vec::new(), | ||
| 306 | link: None, | ||
| 307 | text: None, | ||
| 302 | }; | 308 | }; |
| 303 | after.objects.push(PageObject::Image(image.clone())); | 309 | after.objects.push(PageObject::Image(image.clone())); |
| 304 | let written = ops::saved(&source, space, &after).unwrap(); | 310 | let written = ops::saved(&source, space, &after).unwrap(); |
| ... | @@ -366,6 +372,9 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { | ... | @@ -366,6 +372,9 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { |
| 366 | alt: None, | 372 | alt: None, |
| 367 | background: false, | 373 | background: false, |
| 368 | printout: None, | 374 | printout: None, |
| 375 | tags: Vec::new(), | ||
| 376 | link: None, | ||
| 377 | text: None, | ||
| 369 | }); | 378 | }); |
| 370 | body_paragraphs(&mut after).push(holder); | 379 | body_paragraphs(&mut after).push(holder); |
| 371 | assert!(ops::saved(&source, space, &after).is_err()); | 380 | assert!(ops::saved(&source, space, &after).is_err()); |
crates/onestore/tests/protected.rs+1| ... | @@ -179,6 +179,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { | ... | @@ -179,6 +179,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { |
| 179 | bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), | 179 | bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), |
| 180 | preview: None, | 180 | preview: None, |
| 181 | recording: None, | 181 | recording: None, |
| 182 | tags: Vec::new(), | ||
| 182 | }); | 183 | }); |
| 183 | paragraphs.insert(at + 1, file); | 184 | paragraphs.insert(at + 1, file); |
| 184 | let text = paragraphs[at].text_mut().unwrap(); | 185 | let text = paragraphs[at].text_mut().unwrap(); |
crates/snowbound/Cargo.toml+4| ... | @@ -54,8 +54,12 @@ windows-sys = { version = "0.61", features = [ | ... | @@ -54,8 +54,12 @@ windows-sys = { version = "0.61", features = [ |
| 54 | "Win32_Security_Credentials", | 54 | "Win32_Security_Credentials", |
| 55 | "Win32_Storage_FileSystem", | 55 | "Win32_Storage_FileSystem", |
| 56 | "Win32_System_Com", | 56 | "Win32_System_Com", |
| 57 | "Win32_System_Console", | ||
| 58 | "Win32_System_Diagnostics_Debug", | ||
| 57 | "Win32_System_IO", | 59 | "Win32_System_IO", |
| 60 | "Win32_System_Kernel", | ||
| 58 | "Win32_System_LibraryLoader", | 61 | "Win32_System_LibraryLoader", |
| 62 | "Win32_System_Pipes", | ||
| 59 | "Win32_System_Registry", | 63 | "Win32_System_Registry", |
| 60 | "Win32_System_Threading", | 64 | "Win32_System_Threading", |
| 61 | "Win32_System_Time", | 65 | "Win32_System_Time", |
crates/snowbound/assets/icons/styles.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" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient> | ||
| 4 | </defs> | ||
| 5 | <path d="M2.8 2.35H13.2A0.8 0.8 0 0 1 14 3.15V5.15A0.8 0.8 0 0 1 13.2 5.95H2.8A0.8 0.8 0 0 1 2 5.15V3.15A0.8 0.8 0 0 1 2.8 2.35Z" fill="#000000" fill-opacity="0.16"/> | ||
| 6 | <path class="accent" d="M2.8 1.6H13.2A0.8 0.8 0 0 1 14 2.4V4.4A0.8 0.8 0 0 1 13.2 5.2H2.8A0.8 0.8 0 0 1 2 4.4V2.4A0.8 0.8 0 0 1 2.8 1.6Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/> | ||
| 7 | <path d="M2 7.5H11V9H2ZM2 10.5H14V11.5H2ZM2 13H12V14H2Z" fill="currentColor"/> | ||
| 8 | </svg> | ||
crates/snowbound/linux/README.md+3-2| ... | @@ -21,8 +21,9 @@ Edits save to the notebook folder as you type. Settings live in | ... | @@ -21,8 +21,9 @@ Edits save to the notebook folder as you type. Settings live in |
| 21 | puts the latter elsewhere. | 21 | puts the latter elsewhere. |
| 22 | 22 | ||
| 23 | **Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and | 23 | **Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and |
| 24 | copies it to `~/.local/bin/snowbound`. Options, next to the update settings, | 24 | to the apps that open `.one` and `.onetoc2` files, and copies it to |
| 25 | uninstalls it again; your notebooks stay where they are. | 25 | `~/.local/bin/snowbound`. Options, next to the update settings, uninstalls it |
| 26 | again; your notebooks stay where they are. | ||
| 26 | 27 | ||
| 27 | The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and | 28 | The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and |
| 28 | other desktops without server-side decorations an Adwaita-style one drawn by | 29 | other desktops without server-side decorations an Adwaita-style one drawn by |
crates/snowbound/linux/onenote.xml created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info"> | ||
| 3 | <mime-type type="application/onenote"> | ||
| 4 | <comment>OneNote notebook file</comment> | ||
| 5 | <alias type="application/msonenote"/> | ||
| 6 | <glob pattern="*.one"/> | ||
| 7 | <glob pattern="*.onetoc2"/> | ||
| 8 | </mime-type> | ||
| 9 | </mime-info> | ||
crates/snowbound/linux/snowbound.desktop+2-1| ... | @@ -3,8 +3,9 @@ Type=Application | ... | @@ -3,8 +3,9 @@ Type=Application |
| 3 | Name=Snowbound | 3 | Name=Snowbound |
| 4 | GenericName=Note Taking | 4 | GenericName=Note Taking |
| 5 | Comment=Type and draw notes in OneNote notebooks | 5 | Comment=Type and draw notes in OneNote notebooks |
| 6 | Exec=snowbound | 6 | Exec=snowbound %F |
| 7 | Icon=net.paperclover.snowbound | 7 | Icon=net.paperclover.snowbound |
| 8 | Terminal=false | 8 | Terminal=false |
| 9 | Categories=Office; | 9 | Categories=Office; |
| 10 | MimeType=application/onenote; | ||
| 10 | StartupWMClass=net.paperclover.snowbound | 11 | StartupWMClass=net.paperclover.snowbound |
crates/snowbound/src/art.rs+1| ... | @@ -41,6 +41,7 @@ pub const SERVER: &[&str] = art!("icons/server"); | ... | @@ -41,6 +41,7 @@ pub const SERVER: &[&str] = art!("icons/server"); |
| 41 | pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse"); | 41 | pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse"); |
| 42 | pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand"); | 42 | pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand"); |
| 43 | pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough"); | 43 | pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough"); |
| 44 | pub const STYLES: &[&str] = art!("icons/styles"); | ||
| 44 | pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); | 45 | pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); |
| 45 | pub const SUPERSCRIPT: &[&str] = art!("icons/superscript"); | 46 | pub const SUPERSCRIPT: &[&str] = art!("icons/superscript"); |
| 46 | pub const TABLE: &[&str] = art!("icons/table"); | 47 | pub const TABLE: &[&str] = art!("icons/table"); |
crates/snowbound/src/attachment.rs+2-11| ... | @@ -35,19 +35,10 @@ impl State { | ... | @@ -35,19 +35,10 @@ impl State { |
| 35 | preview: platform::file_icon(path).map(Into::into), | 35 | preview: platform::file_icon(path).map(Into::into), |
| 36 | recording: crate::recording::attached(name, &bytes), | 36 | recording: crate::recording::attached(name, &bytes), |
| 37 | bytes: Some(bytes.into()), | 37 | bytes: Some(bytes.into()), |
| 38 | tags: Vec::new(), | ||
| 38 | }; | 39 | }; |
| 39 | let response = match at { | 40 | let response = match at { |
| 40 | Some(point) => { | 41 | Some(point) => self.view.drop_attachment(self.page_point(point), file)?, |
| 41 | let scale = self.ui.scale(); | ||
| 42 | let corner = self.ui.rect(crate::page()).unwrap_or_default(); | ||
| 43 | self.view.drop_attachment( | ||
| 44 | [ | ||
| 45 | (point[0] - corner[0]) * scale, | ||
| 46 | (point[1] - corner[1]) * scale, | ||
| 47 | ], | ||
| 48 | file, | ||
| 49 | )? | ||
| 50 | } | ||
| 51 | None => self.view.insert_attachment(file)?, | 42 | None => self.view.insert_attachment(file)?, |
| 52 | }; | 43 | }; |
| 53 | self.respond(response); | 44 | self.respond(response); |
crates/snowbound/src/commands.rs+448-64| ... | @@ -7,7 +7,11 @@ use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle} | ... | @@ -7,7 +7,11 @@ use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle} |
| 7 | use canvas::interaction::{Request, ink::Tool}; | 7 | use canvas::interaction::{Request, ink::Tool}; |
| 8 | use draw::edit::{Key, Modifiers, NamedKey, Platform}; | 8 | use draw::edit::{Key, Modifiers, NamedKey, Platform}; |
| 9 | use onestore::page::ink::ShapeKind; | 9 | use onestore::page::ink::ShapeKind; |
| 10 | use std::{error::Error, sync::Arc}; | 10 | use std::{ |
| 11 | collections::BTreeMap, | ||
| 12 | error::Error, | ||
| 13 | sync::{Arc, PoisonError, RwLock, RwLockReadGuard}, | ||
| 14 | }; | ||
| 11 | 15 | ||
| 12 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | 16 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 13 | pub enum Id { | 17 | pub enum Id { |
| ... | @@ -24,7 +28,6 @@ pub enum Id { | ... | @@ -24,7 +28,6 @@ pub enum Id { |
| 24 | CopyPageLink, | 28 | CopyPageLink, |
| 25 | ShowNotebook, | 29 | ShowNotebook, |
| 26 | ExportPdf, | 30 | ExportPdf, |
| 27 | ExportSectionPdf, | ||
| 28 | Print, | 31 | Print, |
| 29 | Undo, | 32 | Undo, |
| 30 | Redo, | 33 | Redo, |
| ... | @@ -84,6 +87,12 @@ pub enum Id { | ... | @@ -84,6 +87,12 @@ pub enum Id { |
| 84 | Indent, | 87 | Indent, |
| 85 | Outdent, | 88 | Outdent, |
| 86 | ClearFormatting, | 89 | ClearFormatting, |
| 90 | /// The style at this place in OneNote's Styles gallery (`notebook::sidecar::themes::STYLES`). | ||
| 91 | Style(usize), | ||
| 92 | /// The Styles gallery. | ||
| 93 | Styles, | ||
| 94 | /// The Themes dialog, assigning a theme to the page, its section or its notebook. | ||
| 95 | Theme(crate::themes::Scope), | ||
| 87 | /// The tag at this place in the user's tag list. | 96 | /// The tag at this place in the user's tag list. |
| 88 | Tag(usize), | 97 | Tag(usize), |
| 89 | CustomizeTags, | 98 | CustomizeTags, |
| ... | @@ -240,7 +249,6 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -240,7 +249,6 @@ pub const COMMANDS: &[Command] = &[ |
| 240 | NONE, | 249 | NONE, |
| 241 | ), | 250 | ), |
| 242 | row!(Id::ExportPdf, "Export as PDF…", NONE, NONE), | 251 | row!(Id::ExportPdf, "Export as PDF…", NONE, NONE), |
| 243 | row!(Id::ExportSectionPdf, "Export Section as PDF…", NONE, NONE), | ||
| 244 | // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P. | 252 | // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P. |
| 245 | row!( | 253 | row!( |
| 246 | Id::Print, | 254 | Id::Print, |
| ... | @@ -307,7 +315,8 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -307,7 +315,8 @@ pub const COMMANDS: &[Command] = &[ |
| 307 | &[cmd('0')], | 315 | &[cmd('0')], |
| 308 | &[cmd('0').option()] | 316 | &[cmd('0').option()] |
| 309 | ), | 317 | ), |
| 310 | row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE), | 318 | // Sidebars toggle on Command-Backslash, as in Notion; OneNote 2010 leaves Ctrl+\ free. |
| 319 | row!(Id::Sidebar, "Notebook List", &[cmd('\\')], &[cmd('\\')]), | ||
| 311 | row!(Id::PageList, "Page List", NONE, NONE), | 320 | row!(Id::PageList, "Page List", NONE, NONE), |
| 312 | row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE), | 321 | row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE), |
| 313 | row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), | 322 | row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), |
| ... | @@ -473,6 +482,67 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -473,6 +482,67 @@ pub const COMMANDS: &[Command] = &[ |
| 473 | &[cmd('n').shift()], | 482 | &[cmd('n').shift()], |
| 474 | &[cmd('n').shift()] | 483 | &[cmd('n').shift()] |
| 475 | ), | 484 | ), |
| 485 | row!(Id::Styles, "Styles", NONE, NONE), | ||
| 486 | // OneNote 2010's Ctrl+Alt+1 to 6; OneNote for Mac's Option-Command ones. | ||
| 487 | row!( | ||
| 488 | Id::Style(0), | ||
| 489 | "Heading 1", | ||
| 490 | &[cmd('1').option()], | ||
| 491 | &[cmd('1').option()] | ||
| 492 | ), | ||
| 493 | row!( | ||
| 494 | Id::Style(1), | ||
| 495 | "Heading 2", | ||
| 496 | &[cmd('2').option()], | ||
| 497 | &[cmd('2').option()] | ||
| 498 | ), | ||
| 499 | row!( | ||
| 500 | Id::Style(2), | ||
| 501 | "Heading 3", | ||
| 502 | &[cmd('3').option()], | ||
| 503 | &[cmd('3').option()] | ||
| 504 | ), | ||
| 505 | row!( | ||
| 506 | Id::Style(3), | ||
| 507 | "Heading 4", | ||
| 508 | &[cmd('4').option()], | ||
| 509 | &[cmd('4').option()] | ||
| 510 | ), | ||
| 511 | row!( | ||
| 512 | Id::Style(4), | ||
| 513 | "Heading 5", | ||
| 514 | &[cmd('5').option()], | ||
| 515 | &[cmd('5').option()] | ||
| 516 | ), | ||
| 517 | row!( | ||
| 518 | Id::Style(5), | ||
| 519 | "Heading 6", | ||
| 520 | &[cmd('6').option()], | ||
| 521 | &[cmd('6').option()] | ||
| 522 | ), | ||
| 523 | row!(Id::Style(6), "Page Title", NONE, NONE), | ||
| 524 | row!(Id::Style(7), "Citation", NONE, NONE), | ||
| 525 | row!(Id::Style(8), "Quote", NONE, NONE), | ||
| 526 | row!(Id::Style(9), "Code", NONE, NONE), | ||
| 527 | row!(Id::Style(10), "Normal", NONE, NONE), | ||
| 528 | row!( | ||
| 529 | Id::Theme(crate::themes::Scope::Page), | ||
| 530 | "Page Theme…", | ||
| 531 | NONE, | ||
| 532 | NONE | ||
| 533 | ), | ||
| 534 | row!( | ||
| 535 | Id::Theme(crate::themes::Scope::Section), | ||
| 536 | "Section Theme…", | ||
| 537 | NONE, | ||
| 538 | NONE | ||
| 539 | ), | ||
| 540 | row!( | ||
| 541 | Id::Theme(crate::themes::Scope::Notebook), | ||
| 542 | "Notebook Theme…", | ||
| 543 | NONE, | ||
| 544 | NONE | ||
| 545 | ), | ||
| 476 | row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE), | 546 | row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE), |
| 477 | row!( | 547 | row!( |
| 478 | Id::RemoveTags, | 548 | Id::RemoveTags, |
| ... | @@ -499,6 +569,7 @@ pub fn command(id: Id) -> &'static Command { | ... | @@ -499,6 +569,7 @@ pub fn command(id: Id) -> &'static Command { |
| 499 | } | 569 | } |
| 500 | 570 | ||
| 501 | impl Command { | 571 | impl Command { |
| 572 | /// The table's chords on `platform`, which the user's may replace. | ||
| 502 | pub fn chords(&self, platform: Platform) -> &'static [Chord] { | 573 | pub fn chords(&self, platform: Platform) -> &'static [Chord] { |
| 503 | match platform { | 574 | match platform { |
| 504 | Platform::MacOs => self.mac, | 575 | Platform::MacOs => self.mac, |
| ... | @@ -555,35 +626,118 @@ pub fn index(choice: &Choice) -> usize { | ... | @@ -555,35 +626,118 @@ pub fn index(choice: &Choice) -> usize { |
| 555 | .expect("Menus offer listed choices") | 626 | .expect("Menus offer listed choices") |
| 556 | } | 627 | } |
| 557 | 628 | ||
| 629 | /// The chords set in Options' Keyboard here, each list in place of its command's defaults. | ||
| 630 | #[derive(Clone, Debug, Default, PartialEq)] | ||
| 631 | pub struct Keymap(Vec<(Id, Vec<Chord>)>); | ||
| 632 | |||
| 633 | /// The keymap the keyboard, menus, palette and tooltips follow. | ||
| 634 | static KEYMAP: RwLock<Keymap> = RwLock::new(Keymap(Vec::new())); | ||
| 635 | |||
| 636 | fn keymap() -> RwLockReadGuard<'static, Keymap> { | ||
| 637 | KEYMAP.read().unwrap_or_else(PoisonError::into_inner) | ||
| 638 | } | ||
| 639 | |||
| 640 | impl Keymap { | ||
| 641 | /// The keymap in use. | ||
| 642 | pub fn current() -> Self { | ||
| 643 | keymap().clone() | ||
| 644 | } | ||
| 645 | |||
| 646 | /// Puts `self` in use, reporting whether it differs from the one it replaces. | ||
| 647 | pub fn install(self) -> bool { | ||
| 648 | let mut installed = KEYMAP.write().unwrap_or_else(PoisonError::into_inner); | ||
| 649 | let changed = *installed != self; | ||
| 650 | *installed = self; | ||
| 651 | changed | ||
| 652 | } | ||
| 653 | |||
| 654 | /// Command `id`'s chords on `platform`: the user's, or the table's. | ||
| 655 | pub fn chords(&self, id: Id, platform: Platform) -> &[Chord] { | ||
| 656 | self.0 | ||
| 657 | .iter() | ||
| 658 | .find(|(set, _)| *set == id) | ||
| 659 | .map_or_else(|| command(id).chords(platform), |(_, chords)| chords) | ||
| 660 | } | ||
| 661 | |||
| 662 | /// Gives command `id` `chords` here, forgetting them where they are its defaults. | ||
| 663 | pub fn set(&mut self, id: Id, chords: Vec<Chord>) { | ||
| 664 | self.0.retain(|(set, _)| *set != id); | ||
| 665 | if chords != command(id).chords(Platform::CURRENT) { | ||
| 666 | self.0.push((id, chords)); | ||
| 667 | } | ||
| 668 | } | ||
| 669 | |||
| 670 | pub fn customized(&self, id: Id) -> bool { | ||
| 671 | self.0.iter().any(|(set, _)| *set == id) | ||
| 672 | } | ||
| 673 | |||
| 674 | /// The command `chord` runs on `platform`. | ||
| 675 | pub fn ran(&self, chord: Chord, platform: Platform) -> Option<Id> { | ||
| 676 | COMMANDS | ||
| 677 | .iter() | ||
| 678 | .find(|command| self.chords(command.id, platform).contains(&chord)) | ||
| 679 | .map(|command| command.id) | ||
| 680 | .or_else(|| { | ||
| 681 | (0..9) | ||
| 682 | .find(|&place| self.tag_chord(place, platform) == Some(chord)) | ||
| 683 | .map(Id::Tag) | ||
| 684 | }) | ||
| 685 | } | ||
| 686 | |||
| 687 | /// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to | ||
| 688 | /// Ctrl+9, as OneNote's do, but for one a command was given. | ||
| 689 | fn tag_chord(&self, place: usize, platform: Platform) -> Option<Chord> { | ||
| 690 | let digit = u8::try_from(place).ok().filter(|place| *place < 9)?; | ||
| 691 | let chord = cmd(char::from(b'1' + digit)); | ||
| 692 | let taken = COMMANDS | ||
| 693 | .iter() | ||
| 694 | .any(|command| self.chords(command.id, platform).contains(&chord)); | ||
| 695 | (!taken).then_some(chord) | ||
| 696 | } | ||
| 697 | |||
| 698 | /// As settings keep it: each set command's chords by its name in the table. | ||
| 699 | pub fn saved(&self) -> BTreeMap<String, Vec<String>> { | ||
| 700 | (self.0.iter()) | ||
| 701 | .map(|(id, chords)| (format!("{id:?}"), chords.iter().map(Chord::saved).collect())) | ||
| 702 | .collect() | ||
| 703 | } | ||
| 704 | |||
| 705 | /// The keymap settings kept, without commands or chords this build no longer has. | ||
| 706 | pub fn from_saved(saved: &BTreeMap<String, Vec<String>>) -> Self { | ||
| 707 | Self( | ||
| 708 | COMMANDS | ||
| 709 | .iter() | ||
| 710 | .filter_map(|command| { | ||
| 711 | let chords = saved.get(&format!("{:?}", command.id))?; | ||
| 712 | let chords = chords.iter().filter_map(|chord| Chord::read(chord)); | ||
| 713 | Some((command.id, chords.collect())) | ||
| 714 | }) | ||
| 715 | .collect(), | ||
| 716 | ) | ||
| 717 | } | ||
| 718 | } | ||
| 719 | |||
| 558 | /// The command `key` with `modifiers` runs here. | 720 | /// The command `key` with `modifiers` runs here. |
| 559 | pub fn find(key: &Key, modifiers: Modifiers) -> Option<Id> { | 721 | pub fn find(key: &Key, modifiers: Modifiers) -> Option<Id> { |
| 560 | ran( | 722 | keymap().ran(pressed(key, modifiers)?, Platform::CURRENT) |
| 561 | pressed(key, modifiers, Platform::CURRENT)?, | ||
| 562 | Platform::CURRENT, | ||
| 563 | ) | ||
| 564 | } | 723 | } |
| 565 | 724 | ||
| 566 | /// The command `chord` runs on `platform`. | 725 | /// Command `id`'s chords here. |
| 567 | fn ran(chord: Chord, platform: Platform) -> Option<Id> { | 726 | pub fn chords(id: Id) -> Vec<Chord> { |
| 568 | COMMANDS | 727 | keymap().chords(id, Platform::CURRENT).to_vec() |
| 569 | .iter() | ||
| 570 | .find(|command| command.chords(platform).contains(&chord)) | ||
| 571 | .map(|command| command.id) | ||
| 572 | .or_else(|| { | ||
| 573 | (0..9) | ||
| 574 | .find(|&place| tag_chord(place) == Some(chord)) | ||
| 575 | .map(Id::Tag) | ||
| 576 | }) | ||
| 577 | } | 728 | } |
| 578 | 729 | ||
| 579 | /// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to Ctrl+9, | 730 | /// The chord applying the tag at `place` in the list here. |
| 580 | /// as OneNote's do. | ||
| 581 | pub fn tag_chord(place: usize) -> Option<Chord> { | 731 | pub fn tag_chord(place: usize) -> Option<Chord> { |
| 582 | let digit = u8::try_from(place).ok().filter(|place| *place < 9)?; | 732 | keymap().tag_chord(place, Platform::CURRENT) |
| 583 | Some(cmd(char::from(b'1' + digit))) | 733 | } |
| 734 | |||
| 735 | /// The chord `key` with `modifiers` makes here. | ||
| 736 | pub fn pressed(key: &Key, modifiers: Modifiers) -> Option<Chord> { | ||
| 737 | chord_on(key, modifiers, Platform::CURRENT) | ||
| 584 | } | 738 | } |
| 585 | 739 | ||
| 586 | fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> { | 740 | fn chord_on(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> { |
| 587 | let (key, shifted) = match key { | 741 | let (key, shifted) = match key { |
| 588 | Key::Named(named) => (Press::Named(*named), false), | 742 | Key::Named(named) => (Press::Named(*named), false), |
| 589 | Key::Character(text) => { | 743 | Key::Character(text) => { |
| ... | @@ -630,6 +784,49 @@ const SHIFTED: [(char, char); 21] = [ | ... | @@ -630,6 +784,49 @@ const SHIFTED: [(char, char); 21] = [ |
| 630 | ('(', '9'), | 784 | ('(', '9'), |
| 631 | ]; | 785 | ]; |
| 632 | 786 | ||
| 787 | /// The named keys a chord may take: AppKit's key equivalent for each, and how macOS's and | ||
| 788 | /// other desktops' menus show it. | ||
| 789 | const NAMED: [(NamedKey, char, &str, &str); 15] = [ | ||
| 790 | (NamedKey::Space, ' ', "Space", "Space"), | ||
| 791 | (NamedKey::Tab, '\t', "⇥", "Tab"), | ||
| 792 | (NamedKey::Enter, '\r', "↩", "Enter"), | ||
| 793 | (NamedKey::Backspace, '\u{8}', "⌫", "Backspace"), | ||
| 794 | (NamedKey::Delete, '\u{f728}', "⌦", "Delete"), | ||
| 795 | (NamedKey::ArrowLeft, '\u{f702}', "←", "Left"), | ||
| 796 | (NamedKey::ArrowRight, '\u{f703}', "→", "Right"), | ||
| 797 | (NamedKey::ArrowUp, '\u{f700}', "↑", "Up"), | ||
| 798 | (NamedKey::ArrowDown, '\u{f701}', "↓", "Down"), | ||
| 799 | (NamedKey::Home, '\u{f729}', "↖", "Home"), | ||
| 800 | (NamedKey::End, '\u{f72b}', "↘", "End"), | ||
| 801 | (NamedKey::PageUp, '\u{f72c}', "⇞", "Page Up"), | ||
| 802 | (NamedKey::PageDown, '\u{f72d}', "⇟", "Page Down"), | ||
| 803 | (NamedKey::F7, '\u{f70a}', "F7", "F7"), | ||
| 804 | (NamedKey::F11, '\u{f70e}', "F11", "F11"), | ||
| 805 | ]; | ||
| 806 | |||
| 807 | /// The chords AppKit's own menu items take on macOS, which no command may. | ||
| 808 | const APPKIT: [(Chord, &str); 5] = [ | ||
| 809 | (cmd('q'), "Quit Snowbound"), | ||
| 810 | (cmd('h'), "Hide Snowbound"), | ||
| 811 | (cmd('h').option(), "Hide Others"), | ||
| 812 | (cmd('m'), "Minimize"), | ||
| 813 | ( | ||
| 814 | named(NamedKey::Space).command().control(), | ||
| 815 | "Emoji & Symbols", | ||
| 816 | ), | ||
| 817 | ]; | ||
| 818 | |||
| 819 | /// Why a chord can't run a command. | ||
| 820 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| 821 | pub enum Unusable { | ||
| 822 | /// A key no chord takes, or a character typed without a shortcut modifier. | ||
| 823 | Typing, | ||
| 824 | /// Moves or deletes text, as `draw::edit` reads it, and no command takes it by default. | ||
| 825 | Editing, | ||
| 826 | /// The system's: AppKit's item of this title. | ||
| 827 | System(&'static str), | ||
| 828 | } | ||
| 829 | |||
| 633 | impl Chord { | 830 | impl Chord { |
| 634 | /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key. | 831 | /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key. |
| 635 | pub fn equivalent(self) -> (String, bool) { | 832 | pub fn equivalent(self) -> (String, bool) { |
| ... | @@ -641,26 +838,27 @@ impl Chord { | ... | @@ -641,26 +838,27 @@ impl Chord { |
| 641 | }) | 838 | }) |
| 642 | } | 839 | } |
| 643 | Press::Char(base) => (base.to_string(), self.shift), | 840 | Press::Char(base) => (base.to_string(), self.shift), |
| 644 | Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift), | 841 | Press::Named(named) => ( |
| 645 | Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift), | 842 | NAMED |
| 646 | Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift), | 843 | .iter() |
| 647 | // AppKit's NSF7FunctionKey. | 844 | .find(|(listed, ..)| *listed == named) |
| 648 | Press::Named(NamedKey::F7) => ('\u{f70a}'.to_string(), self.shift), | 845 | .map_or_else(String::new, |(_, key, ..)| key.to_string()), |
| 649 | Press::Named(named) => unreachable!("No command takes {named:?}"), | 846 | self.shift, |
| 847 | ), | ||
| 650 | } | 848 | } |
| 651 | } | 849 | } |
| 652 | 850 | ||
| 653 | /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere. | 851 | /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere. |
| 654 | pub fn label(self, platform: Platform) -> String { | 852 | pub fn label(self, platform: Platform) -> String { |
| 853 | let named = |pick: fn(&(NamedKey, char, &'static str, &'static str)) -> &'static str| { | ||
| 854 | let Press::Named(named) = self.key else { | ||
| 855 | return None; | ||
| 856 | }; | ||
| 857 | NAMED.iter().find(|(listed, ..)| *listed == named).map(pick) | ||
| 858 | }; | ||
| 655 | if platform == Platform::MacOs { | 859 | if platform == Platform::MacOs { |
| 656 | let (key, shift) = self.equivalent(); | 860 | let (key, shift) = self.equivalent(); |
| 657 | let key = match self.key { | 861 | let key = named(|(.., mac, _)| mac).map_or_else(|| key.to_uppercase(), str::to_owned); |
| 658 | Press::Named(NamedKey::Space) => "Space".to_owned(), | ||
| 659 | Press::Named(NamedKey::ArrowLeft) => "←".to_owned(), | ||
| 660 | Press::Named(NamedKey::ArrowRight) => "→".to_owned(), | ||
| 661 | Press::Named(NamedKey::F7) => "F7".to_owned(), | ||
| 662 | _ => key.to_uppercase(), | ||
| 663 | }; | ||
| 664 | [ | 862 | [ |
| 665 | (self.control, "⌃"), | 863 | (self.control, "⌃"), |
| 666 | (self.option, "⌥"), | 864 | (self.option, "⌥"), |
| ... | @@ -675,9 +873,9 @@ impl Chord { | ... | @@ -675,9 +873,9 @@ impl Chord { |
| 675 | } else { | 873 | } else { |
| 676 | let key = match self.key { | 874 | let key = match self.key { |
| 677 | Press::Char(key) => key.to_uppercase().to_string(), | 875 | Press::Char(key) => key.to_uppercase().to_string(), |
| 678 | Press::Named(NamedKey::ArrowLeft) => "Left".to_owned(), | 876 | Press::Named(key) => { |
| 679 | Press::Named(NamedKey::ArrowRight) => "Right".to_owned(), | 877 | named(|(.., pc)| pc).map_or_else(|| format!("{key:?}"), str::to_owned) |
| 680 | Press::Named(named) => format!("{named:?}"), | 878 | } |
| 681 | }; | 879 | }; |
| 682 | [ | 880 | [ |
| 683 | (self.command, "Ctrl+"), | 881 | (self.command, "Ctrl+"), |
| ... | @@ -691,13 +889,92 @@ impl Chord { | ... | @@ -691,13 +889,92 @@ impl Chord { |
| 691 | .collect() | 889 | .collect() |
| 692 | } | 890 | } |
| 693 | } | 891 | } |
| 892 | |||
| 893 | /// Why the chord can't be given a command on `platform`, if it can't. | ||
| 894 | pub fn unusable(self, platform: Platform) -> Option<Unusable> { | ||
| 895 | let modified = self.command || self.option || self.control; | ||
| 896 | let mac = platform == Platform::MacOs; | ||
| 897 | let typed = match self.key { | ||
| 898 | Press::Char(_) => !(self.command || self.control || !mac && self.option), | ||
| 899 | Press::Named(NamedKey::F7 | NamedKey::F11) => false, | ||
| 900 | Press::Named(named) => !modified || !NAMED.iter().any(|(listed, ..)| *listed == named), | ||
| 901 | }; | ||
| 902 | let key = match self.key { | ||
| 903 | Press::Char(key) => Key::Character(key.to_string()), | ||
| 904 | Press::Named(named) => Key::Named(named), | ||
| 905 | }; | ||
| 906 | let modifiers = Modifiers { | ||
| 907 | shift: self.shift, | ||
| 908 | option: self.option, | ||
| 909 | // Off macOS Control is the shortcut modifier, and comes with it. | ||
| 910 | control: self.control || !mac && self.command, | ||
| 911 | command: self.command, | ||
| 912 | }; | ||
| 913 | if typed { | ||
| 914 | Some(Unusable::Typing) | ||
| 915 | } else if platform.command(&key, modifiers).is_some() | ||
| 916 | // The table's own chords already take theirs from text editing. | ||
| 917 | && !(COMMANDS.iter()).any(|command| command.chords(platform).contains(&self)) | ||
| 918 | { | ||
| 919 | Some(Unusable::Editing) | ||
| 920 | } else { | ||
| 921 | (APPKIT.iter()) | ||
| 922 | .find(|(chord, _)| mac && *chord == self) | ||
| 923 | .map(|(_, title)| Unusable::System(title)) | ||
| 924 | } | ||
| 925 | } | ||
| 926 | |||
| 927 | /// As settings keep it, Command+Shift+K: the modifiers and the unshifted key, named. | ||
| 928 | fn saved(&self) -> String { | ||
| 929 | let key = match self.key { | ||
| 930 | Press::Char(key) => key.to_string(), | ||
| 931 | Press::Named(named) => format!("{named:?}"), | ||
| 932 | }; | ||
| 933 | [ | ||
| 934 | (self.control, "Control+"), | ||
| 935 | (self.option, "Option+"), | ||
| 936 | (self.shift, "Shift+"), | ||
| 937 | (self.command, "Command+"), | ||
| 938 | ] | ||
| 939 | .iter() | ||
| 940 | .filter(|(held, _)| *held) | ||
| 941 | .map(|(_, modifier)| *modifier) | ||
| 942 | .chain([key.as_str()]) | ||
| 943 | .collect() | ||
| 944 | } | ||
| 945 | |||
| 946 | /// The chord `saved` wrote as `text`. | ||
| 947 | fn read(text: &str) -> Option<Self> { | ||
| 948 | let (modifiers, key) = text.rsplit_once('+').unwrap_or(("", text)); | ||
| 949 | let mut chars = key.chars(); | ||
| 950 | let key = match (chars.next(), chars.next()) { | ||
| 951 | (Some(key), None) => Press::Char(key), | ||
| 952 | _ => Press::Named( | ||
| 953 | NAMED | ||
| 954 | .iter() | ||
| 955 | .map(|(named, ..)| *named) | ||
| 956 | .find(|named| format!("{named:?}") == key)?, | ||
| 957 | ), | ||
| 958 | }; | ||
| 959 | let mut chord = press(key); | ||
| 960 | for modifier in modifiers.split('+').filter(|modifier| !modifier.is_empty()) { | ||
| 961 | match modifier { | ||
| 962 | "Control" => chord.control = true, | ||
| 963 | "Option" => chord.option = true, | ||
| 964 | "Shift" => chord.shift = true, | ||
| 965 | "Command" => chord.command = true, | ||
| 966 | _ => return None, | ||
| 967 | } | ||
| 968 | } | ||
| 969 | Some(chord) | ||
| 970 | } | ||
| 694 | } | 971 | } |
| 695 | 972 | ||
| 696 | /// The chord a menu or hint shows for `id` here, empty where it has none. | 973 | /// The chord a menu or hint shows for `id` here, empty where it has none. |
| 697 | pub fn shortcut(id: Id) -> String { | 974 | pub fn shortcut(id: Id) -> String { |
| 698 | let chord = match id { | 975 | let chord = match id { |
| 699 | Id::Tag(place) => tag_chord(place), | 976 | Id::Tag(place) => tag_chord(place), |
| 700 | id => command(id).chords(Platform::CURRENT).first().copied(), | 977 | id => chords(id).first().copied(), |
| 701 | }; | 978 | }; |
| 702 | chord.map_or_else(String::new, |chord| chord.label(Platform::CURRENT)) | 979 | chord.map_or_else(String::new, |chord| chord.label(Platform::CURRENT)) |
| 703 | } | 980 | } |
| ... | @@ -722,11 +999,20 @@ impl State { | ... | @@ -722,11 +999,20 @@ impl State { |
| 722 | self.window.request_redraw(); | 999 | self.window.request_redraw(); |
| 723 | } | 1000 | } |
| 724 | 1001 | ||
| 725 | /// Every choice's status, in `choices` order, for the menu bar. | 1002 | /// Every choice's status, in `choices` order, for the menu bar. Link gives up its chord, |
| 1003 | /// ⌘K, to the open palette's actions. | ||
| 726 | pub(crate) fn statuses(&self) -> Vec<Status> { | 1004 | pub(crate) fn statuses(&self) -> Vec<Status> { |
| 1005 | // A chord Options records reaches it past the menu bar's disabled items. | ||
| 1006 | if self.recording_chord() { | ||
| 1007 | return vec![Status::default(); choices().count()]; | ||
| 1008 | } | ||
| 727 | let format = self.format_state(); | 1009 | let format = self.format_state(); |
| 1010 | let palette = self.ui.popup_open(crate::palette::id()); | ||
| 728 | choices() | 1011 | choices() |
| 729 | .map(|choice| self.status(&choice, &format)) | 1012 | .map(|choice| match choice { |
| 1013 | Choice::Command(Id::Link) if palette => Status::default(), | ||
| 1014 | choice => self.status(&choice, &format), | ||
| 1015 | }) | ||
| 730 | .collect() | 1016 | .collect() |
| 731 | } | 1017 | } |
| 732 | 1018 | ||
| ... | @@ -734,6 +1020,8 @@ impl State { | ... | @@ -734,6 +1020,8 @@ impl State { |
| 734 | let session = self.session.as_ref(); | 1020 | let session = self.session.as_ref(); |
| 735 | let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); | 1021 | let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); |
| 736 | let modal = self.options.is_some() | 1022 | let modal = self.options.is_some() |
| 1023 | || self.themes.is_some() | ||
| 1024 | || self.printing.open() | ||
| 737 | || self.link.is_some() | 1025 | || self.link.is_some() |
| 738 | || self.tag_list.is_some() | 1026 | || self.tag_list.is_some() |
| 739 | || self.server.is_some(); | 1027 | || self.server.is_some(); |
| ... | @@ -742,6 +1030,14 @@ impl State { | ... | @@ -742,6 +1030,14 @@ impl State { |
| 742 | // Picked drawings leave the text selection behind them unseen, so it takes no edits. | 1030 | // Picked drawings leave the text selection behind them unseen, so it takes no edits. |
| 743 | let typing = self.view.accepts_text() && self.view.ink_selection().is_empty(); | 1031 | let typing = self.view.accepts_text() && self.view.ink_selection().is_empty(); |
| 744 | let text = writable && typing; | 1032 | let text = writable && typing; |
| 1033 | let tagged = writable | ||
| 1034 | && matches!( | ||
| 1035 | self.view.object_focus(), | ||
| 1036 | Some( | ||
| 1037 | canvas::interaction::ObjectFocus::Image(_) | ||
| 1038 | | canvas::interaction::ObjectFocus::File(_) | ||
| 1039 | ) | ||
| 1040 | ); | ||
| 745 | // Edit commands act on a focused field instead of the page. | 1041 | // Edit commands act on a focused field instead of the page. |
| 746 | let field = self.ui.focused_field().is_some(); | 1042 | let field = self.ui.focused_field().is_some(); |
| 747 | let [anchor, focus] = self.view.editor.selection().positions; | 1043 | let [anchor, focus] = self.view.editor.selection().positions; |
| ... | @@ -793,7 +1089,6 @@ impl State { | ... | @@ -793,7 +1089,6 @@ impl State { |
| 793 | | Id::Find | 1089 | | Id::Find |
| 794 | | Id::Search | 1090 | | Id::Search |
| 795 | | Id::ExportPdf | 1091 | | Id::ExportPdf |
| 796 | | Id::ExportSectionPdf | ||
| 797 | | Id::Print => enabled(!modal && session.is_some()), | 1092 | | Id::Print => enabled(!modal && session.is_some()), |
| 798 | Id::PageVersions => session.filter(|_| !modal).map_or( | 1093 | Id::PageVersions => session.filter(|_| !modal).map_or( |
| 799 | Status { | 1094 | Status { |
| ... | @@ -809,8 +1104,7 @@ impl State { | ... | @@ -809,8 +1104,7 @@ impl State { |
| 809 | enabled: !welcome && !modal, | 1104 | enabled: !welcome && !modal, |
| 810 | checked: Some(self.full_page), | 1105 | checked: Some(self.full_page), |
| 811 | }, | 1106 | }, |
| 812 | Id::Undo => enabled(writable && !field && self.view.editor.can_undo()), | 1107 | Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)), |
| 813 | Id::Redo => enabled(writable && !field && self.view.editor.can_redo()), | ||
| 814 | Id::Cut => enabled(writable && selected), | 1108 | Id::Cut => enabled(writable && selected), |
| 815 | Id::Copy => enabled(selected), | 1109 | Id::Copy => enabled(selected), |
| 816 | Id::Paste => enabled(text && !field), | 1110 | Id::Paste => enabled(text && !field), |
| ... | @@ -903,13 +1197,25 @@ impl State { | ... | @@ -903,13 +1197,25 @@ impl State { |
| 903 | | Id::Indent | 1197 | | Id::Indent |
| 904 | | Id::Outdent | 1198 | | Id::Outdent |
| 905 | | Id::ClearFormatting | 1199 | | Id::ClearFormatting |
| 906 | | Id::RemoveTags => enabled(text), | 1200 | | Id::Styles => enabled(text), |
| 1201 | Id::Style(place) => checked( | ||
| 1202 | format.style.as_deref() | ||
| 1203 | == notebook::sidecar::themes::STYLES | ||
| 1204 | .get(place) | ||
| 1205 | .map(|(name, _)| *name), | ||
| 1206 | ), | ||
| 1207 | Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()), | ||
| 1208 | // A selected picture or file takes tags too, as in OneNote. | ||
| 1209 | Id::RemoveTags => enabled(text || tagged), | ||
| 907 | Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)), | 1210 | Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)), |
| 908 | Id::Bullets => checked(format.bullets), | 1211 | Id::Bullets => checked(format.bullets), |
| 909 | Id::Numbering => checked(format.numbering), | 1212 | Id::Numbering => checked(format.numbering), |
| 910 | Id::Align(alignment) => checked(format.alignment == Some(alignment)), | 1213 | Id::Align(alignment) => checked(format.alignment == Some(alignment)), |
| 911 | Id::Tag(place) => match self.tags.get(place) { | 1214 | Id::Tag(place) => match self.tags.get(place) { |
| 912 | Some(tag) => checked(format.tags.contains(&(tag.stored(), place as u16))), | 1215 | Some(tag) => Status { |
| 1216 | enabled: text || tagged, | ||
| 1217 | checked: Some(format.tags.contains(&(tag.stored(), place as u16))), | ||
| 1218 | }, | ||
| 913 | None => Status::default(), | 1219 | None => Status::default(), |
| 914 | }, | 1220 | }, |
| 915 | } | 1221 | } |
| ... | @@ -1032,19 +1338,11 @@ impl State { | ... | @@ -1032,19 +1338,11 @@ impl State { |
| 1032 | platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); | 1338 | platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); |
| 1033 | return Ok(()); | 1339 | return Ok(()); |
| 1034 | } | 1340 | } |
| 1035 | Id::ExportPdf | Id::ExportSectionPdf | Id::Print => { | 1341 | Id::ExportPdf | Id::Print => { |
| 1036 | let scope = if id == Id::ExportSectionPdf { | 1342 | self.open_print(id == Id::ExportPdf); |
| 1037 | crate::print::Scope::Section | ||
| 1038 | } else { | ||
| 1039 | crate::print::Scope::Page | ||
| 1040 | }; | ||
| 1041 | return self.print(scope, id != Id::Print); | ||
| 1042 | } | ||
| 1043 | Id::Undo | Id::Redo => { | ||
| 1044 | let response = self.view.undo(id == Id::Redo)?; | ||
| 1045 | self.respond(response); | ||
| 1046 | return Ok(()); | 1343 | return Ok(()); |
| 1047 | } | 1344 | } |
| 1345 | Id::Undo | Id::Redo => return self.step(id == Id::Redo), | ||
| 1048 | Id::Cut | Id::Copy => { | 1346 | Id::Cut | Id::Copy => { |
| 1049 | let response = self.view.copy(id == Id::Cut)?; | 1347 | let response = self.view.copy(id == Id::Cut)?; |
| 1050 | self.respond(response); | 1348 | self.respond(response); |
| ... | @@ -1133,7 +1431,7 @@ impl State { | ... | @@ -1133,7 +1431,7 @@ impl State { |
| 1133 | else { | 1431 | else { |
| 1134 | return Ok(()); | 1432 | return Ok(()); |
| 1135 | }; | 1433 | }; |
| 1136 | return self.insert_picture(std::fs::read(path)?); | 1434 | return self.insert_picture(std::fs::read(path)?, None); |
| 1137 | } | 1435 | } |
| 1138 | Id::Attachment => { | 1436 | Id::Attachment => { |
| 1139 | let Some(path) = platform::pick_file("Attach File", &[]) else { | 1437 | let Some(path) = platform::pick_file("Attach File", &[]) else { |
| ... | @@ -1227,7 +1525,27 @@ impl State { | ... | @@ -1227,7 +1525,27 @@ impl State { |
| 1227 | Id::Align(alignment) => return format(self, Formatting::Align(alignment)), | 1525 | Id::Align(alignment) => return format(self, Formatting::Align(alignment)), |
| 1228 | Id::Indent => return format(self, Formatting::Indent), | 1526 | Id::Indent => return format(self, Formatting::Indent), |
| 1229 | Id::Outdent => return format(self, Formatting::Outdent), | 1527 | Id::Outdent => return format(self, Formatting::Outdent), |
| 1230 | Id::ClearFormatting => return format(self, Formatting::Clear), | 1528 | // Clear Formatting at a caret makes the paragraph Normal, as OneNote 2010's |
| 1529 | // Ctrl+Shift+N does; over a selection it clears the characters' formatting. | ||
| 1530 | Id::ClearFormatting => { | ||
| 1531 | let [anchor, focus] = self.view.editor.selection().positions; | ||
| 1532 | if anchor == focus { | ||
| 1533 | return format(self, Formatting::Style(self.gallery_style("p"))); | ||
| 1534 | } | ||
| 1535 | return format(self, Formatting::Clear); | ||
| 1536 | } | ||
| 1537 | Id::Style(place) => { | ||
| 1538 | let (name, _) = notebook::sidecar::themes::STYLES[place]; | ||
| 1539 | return format(self, Formatting::Style(self.gallery_style(name))); | ||
| 1540 | } | ||
| 1541 | Id::Styles => { | ||
| 1542 | self.ui.open_popup(crate::toolbar_popup("styles")); | ||
| 1543 | return Ok(()); | ||
| 1544 | } | ||
| 1545 | Id::Theme(scope) => { | ||
| 1546 | self.open_themes(scope); | ||
| 1547 | return Ok(()); | ||
| 1548 | } | ||
| 1231 | Id::Tag(place) => { | 1549 | Id::Tag(place) => { |
| 1232 | let tag = self.tags[place].clone(); | 1550 | let tag = self.tags[place].clone(); |
| 1233 | format(self, Formatting::Tag(tag, place as u16))?; | 1551 | format(self, Formatting::Tag(tag, place as u16))?; |
| ... | @@ -1315,8 +1633,8 @@ mod tests { | ... | @@ -1315,8 +1633,8 @@ mod tests { |
| 1315 | control, | 1633 | control, |
| 1316 | command, | 1634 | command, |
| 1317 | }; | 1635 | }; |
| 1318 | ran( | 1636 | Keymap::default().ran( |
| 1319 | pressed(&Key::Character(key.into()), modifiers, platform)?, | 1637 | chord_on(&Key::Character(key.into()), modifiers, platform)?, |
| 1320 | platform, | 1638 | platform, |
| 1321 | ) | 1639 | ) |
| 1322 | }; | 1640 | }; |
| ... | @@ -1371,7 +1689,7 @@ mod tests { | ... | @@ -1371,7 +1689,7 @@ mod tests { |
| 1371 | find(gtk, "y", false, false, true, true), | 1689 | find(gtk, "y", false, false, true, true), |
| 1372 | cfg!(windows).then_some(Id::Redo) | 1690 | cfg!(windows).then_some(Id::Redo) |
| 1373 | ); | 1691 | ); |
| 1374 | let indent = pressed( | 1692 | let indent = chord_on( |
| 1375 | &Key::Named(NamedKey::ArrowRight), | 1693 | &Key::Named(NamedKey::ArrowRight), |
| 1376 | Modifiers { | 1694 | Modifiers { |
| 1377 | shift: true, | 1695 | shift: true, |
| ... | @@ -1408,6 +1726,72 @@ mod tests { | ... | @@ -1408,6 +1726,72 @@ mod tests { |
| 1408 | assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D"); | 1726 | assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D"); |
| 1409 | } | 1727 | } |
| 1410 | 1728 | ||
| 1729 | #[test] | ||
| 1730 | fn keymaps_keep_chords_by_command_and_drop_the_unknown() { | ||
| 1731 | let mut keymap = Keymap::default(); | ||
| 1732 | let chord = named(NamedKey::PageDown).command().option().shift(); | ||
| 1733 | keymap.set(Id::Sidebar, vec![chord, cmd('\\').control()]); | ||
| 1734 | keymap.set( | ||
| 1735 | Id::Toggle(Toggle::Bold), | ||
| 1736 | command(Id::Toggle(Toggle::Bold)) | ||
| 1737 | .chords(Platform::CURRENT) | ||
| 1738 | .to_vec(), | ||
| 1739 | ); | ||
| 1740 | let mut saved = keymap.saved(); | ||
| 1741 | assert_eq!( | ||
| 1742 | saved["Sidebar"], | ||
| 1743 | ["Option+Shift+Command+PageDown", "Control+Command+\\"] | ||
| 1744 | ); | ||
| 1745 | assert!(!saved.contains_key("Toggle(Bold)"), "defaults are not kept"); | ||
| 1746 | saved.insert("Retired".into(), vec!["Command+J".into()]); | ||
| 1747 | saved.insert( | ||
| 1748 | "PageList".into(), | ||
| 1749 | vec!["Command+Hyper+J".into(), "Command+j".into()], | ||
| 1750 | ); | ||
| 1751 | let read = Keymap::from_saved(&saved); | ||
| 1752 | assert_eq!( | ||
| 1753 | read.chords(Id::Sidebar, Platform::CURRENT), | ||
| 1754 | [chord, cmd('\\').control()] | ||
| 1755 | ); | ||
| 1756 | assert_eq!(read.chords(Id::PageList, Platform::CURRENT), [cmd('j')]); | ||
| 1757 | } | ||
| 1758 | |||
| 1759 | #[test] | ||
| 1760 | fn every_default_chord_is_one_a_user_could_record() { | ||
| 1761 | for platform in PLATFORMS { | ||
| 1762 | for command in COMMANDS { | ||
| 1763 | for chord in command.chords(platform) { | ||
| 1764 | assert_eq!( | ||
| 1765 | chord.unusable(platform), | ||
| 1766 | None, | ||
| 1767 | "{platform:?}: {} for {}", | ||
| 1768 | chord.label(platform), | ||
| 1769 | command.title | ||
| 1770 | ); | ||
| 1771 | } | ||
| 1772 | } | ||
| 1773 | } | ||
| 1774 | assert_eq!(key('j').unusable(Platform::MacOs), Some(Unusable::Typing)); | ||
| 1775 | assert_eq!( | ||
| 1776 | cmd('q').unusable(Platform::MacOs), | ||
| 1777 | Some(Unusable::System("Quit Snowbound")) | ||
| 1778 | ); | ||
| 1779 | assert_eq!(cmd('q').unusable(Platform::Gtk), None); | ||
| 1780 | assert_eq!( | ||
| 1781 | named(NamedKey::ArrowLeft) | ||
| 1782 | .command() | ||
| 1783 | .unusable(Platform::MacOs), | ||
| 1784 | Some(Unusable::Editing) | ||
| 1785 | ); | ||
| 1786 | assert_eq!( | ||
| 1787 | command(Id::Sidebar).chords(Platform::MacOs)[0].label(Platform::MacOs), | ||
| 1788 | "⌘\\" | ||
| 1789 | ); | ||
| 1790 | assert_eq!( | ||
| 1791 | command(Id::Sidebar).chords(Platform::Windows)[0].label(Platform::Windows), | ||
| 1792 | "Ctrl+\\" | ||
| 1793 | ); | ||
| 1794 | } | ||
| 1411 | /// Each toolbar button chooses a command or a list's entry, so it runs, enables and | 1795 | /// Each toolbar button chooses a command or a list's entry, so it runs, enables and |
| 1412 | /// checks as the menu bar and keyboard do. | 1796 | /// checks as the menu bar and keyboard do. |
| 1413 | #[test] | 1797 | #[test] |
crates/snowbound/src/conflict_render.rs+1-1| ... | @@ -65,7 +65,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> { | ... | @@ -65,7 +65,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> { |
| 65 | &section, | 65 | &section, |
| 66 | session, | 66 | session, |
| 67 | &HashSet::new(), | 67 | &HashSet::new(), |
| 68 | 0.0, | 68 | (0.0, false), |
| 69 | None, | 69 | None, |
| 70 | None, | 70 | None, |
| 71 | ); | 71 | ); |
crates/snowbound/src/desktop_linux.rs+37-14| ... | @@ -36,6 +36,8 @@ use winit::{ | ... | @@ -36,6 +36,8 @@ use winit::{ |
| 36 | /// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon. | 36 | /// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon. |
| 37 | pub const APP_ID: &str = "net.paperclover.snowbound"; | 37 | pub const APP_ID: &str = "net.paperclover.snowbound"; |
| 38 | const TEMPLATE: &str = include_str!("../linux/snowbound.desktop"); | 38 | const TEMPLATE: &str = include_str!("../linux/snowbound.desktop"); |
| 39 | /// The type of `.one` and `.onetoc2` files, which no shared-mime-info release names. | ||
| 40 | const MIME_TYPES: &str = include_str!("../linux/onenote.xml"); | ||
| 39 | /// 512 pixels square; the smaller sizes are scaled from it. | 41 | /// 512 pixels square; the smaller sizes are scaled from it. |
| 40 | const ICON: &[u8] = include_bytes!("../linux/snowbound.png"); | 42 | const ICON: &[u8] = include_bytes!("../linux/snowbound.png"); |
| 41 | const SIZES: [u32; 8] = [16, 24, 32, 48, 64, 128, 256, 512]; | 43 | const SIZES: [u32; 8] = [16, 24, 32, 48, 64, 128, 256, 512]; |
| ... | @@ -75,6 +77,10 @@ fn entry(data: &Path) -> PathBuf { | ... | @@ -75,6 +77,10 @@ fn entry(data: &Path) -> PathBuf { |
| 75 | data.join(format!("applications/{APP_ID}.desktop")) | 77 | data.join(format!("applications/{APP_ID}.desktop")) |
| 76 | } | 78 | } |
| 77 | 79 | ||
| 80 | fn mime_types(data: &Path) -> PathBuf { | ||
| 81 | data.join(format!("mime/packages/{APP_ID}.xml")) | ||
| 82 | } | ||
| 83 | |||
| 78 | fn theme_icon(data: &Path, side: u32) -> PathBuf { | 84 | fn theme_icon(data: &Path, side: u32) -> PathBuf { |
| 79 | data.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png")) | 85 | data.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png")) |
| 80 | } | 86 | } |
| ... | @@ -163,7 +169,12 @@ fn entry_text(exec: &Path, icon: &str) -> String { | ... | @@ -163,7 +169,12 @@ fn entry_text(exec: &Path, icon: &str) -> String { |
| 163 | TEMPLATE | 169 | TEMPLATE |
| 164 | .lines() | 170 | .lines() |
| 165 | .map(|line| match line.split_once('=') { | 171 | .map(|line| match line.split_once('=') { |
| 166 | Some(("Exec", _)) => format!("Exec={}\n", quote(exec)), | 172 | Some(("Exec", command)) => { |
| 173 | let arguments = command | ||
| 174 | .split_once(' ') | ||
| 175 | .map_or("", |(_, arguments)| arguments); | ||
| 176 | format!("Exec={} {arguments}\n", quote(exec)) | ||
| 177 | } | ||
| 167 | Some(("Icon", _)) => format!("Icon={icon}\n"), | 178 | Some(("Icon", _)) => format!("Icon={icon}\n"), |
| 168 | _ => format!("{line}\n"), | 179 | _ => format!("{line}\n"), |
| 169 | }) | 180 | }) |
| ... | @@ -339,7 +350,7 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> { | ... | @@ -339,7 +350,7 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> { |
| 339 | } | 350 | } |
| 340 | 351 | ||
| 341 | /// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the | 352 | /// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the |
| 342 | /// app menu, and says where Uninstall is. | 353 | /// app menu and to the apps opening OneNote's files, and says where Uninstall is. |
| 343 | pub fn install() { | 354 | pub fn install() { |
| 344 | match try_install() { | 355 | match try_install() { |
| 345 | Ok(()) => { | 356 | Ok(()) => { |
| ... | @@ -377,10 +388,13 @@ fn try_install() -> io::Result<()> { | ... | @@ -377,10 +388,13 @@ fn try_install() -> io::Result<()> { |
| 377 | .write_header()? | 388 | .write_header()? |
| 378 | .write_image_data(&scaled(&icon, side))?; | 389 | .write_image_data(&scaled(&icon, side))?; |
| 379 | } | 390 | } |
| 391 | let types = mime_types(&data); | ||
| 392 | fs::create_dir_all(types.parent().ok_or(io::ErrorKind::NotFound)?)?; | ||
| 393 | fs::write(types, MIME_TYPES)?; | ||
| 380 | // Replaces this run's hidden entry, which exiting then leaves alone. | 394 | // Replaces this run's hidden entry, which exiting then leaves alone. |
| 381 | write_entry(&entry(&data), &entry_text(&binary, APP_ID))?; | 395 | write_entry(&entry(&data), &entry_text(&binary, APP_ID))?; |
| 382 | let _ = runtime_icon().map(fs::remove_file); | 396 | let _ = runtime_icon().map(fs::remove_file); |
| 383 | refresh_icon_cache(&data); | 397 | refresh_caches(&data); |
| 384 | Ok(()) | 398 | Ok(()) |
| 385 | } | 399 | } |
| 386 | 400 | ||
| ... | @@ -398,7 +412,10 @@ pub fn uninstall() { | ... | @@ -398,7 +412,10 @@ pub fn uninstall() { |
| 398 | } | 412 | } |
| 399 | let mut failed = None; | 413 | let mut failed = None; |
| 400 | let icons = SIZES.map(|side| theme_icon(&data, side)); | 414 | let icons = SIZES.map(|side| theme_icon(&data, side)); |
| 401 | for path in [entry(&data), binary].iter().chain(&icons) { | 415 | for path in [entry(&data), mime_types(&data), binary] |
| 416 | .iter() | ||
| 417 | .chain(&icons) | ||
| 418 | { | ||
| 402 | if let Err(error) = fs::remove_file(path) | 419 | if let Err(error) = fs::remove_file(path) |
| 403 | && error.kind() != io::ErrorKind::NotFound | 420 | && error.kind() != io::ErrorKind::NotFound |
| 404 | { | 421 | { |
| ... | @@ -411,21 +428,27 @@ pub fn uninstall() { | ... | @@ -411,21 +428,27 @@ pub fn uninstall() { |
| 411 | let _ = fs::remove_dir(folder); | 428 | let _ = fs::remove_dir(folder); |
| 412 | } | 429 | } |
| 413 | } | 430 | } |
| 414 | refresh_icon_cache(&data); | 431 | refresh_caches(&data); |
| 415 | match failed { | 432 | match failed { |
| 416 | Some(error) => crate::platform::alert("Couldn't uninstall Snowbound", &error.to_string()), | 433 | Some(error) => crate::platform::alert("Couldn't uninstall Snowbound", &error.to_string()), |
| 417 | None => *INSTALLED.lock().unwrap() = Installed::No, | 434 | None => *INSTALLED.lock().unwrap() = Installed::No, |
| 418 | } | 435 | } |
| 419 | } | 436 | } |
| 420 | 437 | ||
| 421 | /// Brings the icon theme's cache up to date where the user keeps one. | 438 | /// Brings the icon theme's cache, the file types and the apps opening them up to date, where |
| 422 | fn refresh_icon_cache(data: &Path) { | 439 | /// the desktop has the tools. |
| 423 | let _ = Command::new("gtk-update-icon-cache") | 440 | fn refresh_caches(data: &Path) { |
| 424 | .arg("-q") | 441 | for (tool, folder) in [ |
| 425 | .arg(data.join("icons/hicolor")) | 442 | ("gtk-update-icon-cache", "icons/hicolor"), |
| 426 | .stdout(Stdio::null()) | 443 | ("update-mime-database", "mime"), |
| 427 | .stderr(Stdio::null()) | 444 | ("update-desktop-database", "applications"), |
| 428 | .status(); | 445 | ] { |
| 446 | let _ = Command::new(tool) | ||
| 447 | .arg(data.join(folder)) | ||
| 448 | .stdout(Stdio::null()) | ||
| 449 | .stderr(Stdio::null()) | ||
| 450 | .status(); | ||
| 451 | } | ||
| 429 | } | 452 | } |
| 430 | 453 | ||
| 431 | #[cfg(test)] | 454 | #[cfg(test)] |
| ... | @@ -444,7 +467,7 @@ mod tests { | ... | @@ -444,7 +467,7 @@ mod tests { |
| 444 | #[test] | 467 | #[test] |
| 445 | fn only_the_hidden_entry_names_an_owner() { | 468 | fn only_the_hidden_entry_names_an_owner() { |
| 446 | let installed = entry_text(Path::new("/bin/snowbound"), APP_ID); | 469 | let installed = entry_text(Path::new("/bin/snowbound"), APP_ID); |
| 447 | assert!(installed.contains("\nExec=\"/bin/snowbound\"\n")); | 470 | assert!(installed.contains("\nExec=\"/bin/snowbound\" %F\n")); |
| 448 | assert!(installed.contains(&format!("\nIcon={APP_ID}\n"))); | 471 | assert!(installed.contains(&format!("\nIcon={APP_ID}\n"))); |
| 449 | assert_eq!(portable_owner(&installed), None); | 472 | assert_eq!(portable_owner(&installed), None); |
| 450 | let hidden = installed + &format!("NoDisplay=true\n{PORTABLE}=42\n"); | 473 | let hidden = installed + &format!("NoDisplay=true\n{PORTABLE}=42\n"); |
crates/snowbound/src/icloud_linux.rs+6-1| ... | @@ -11,7 +11,11 @@ pub fn folder() -> Option<PathBuf> { | ... | @@ -11,7 +11,11 @@ pub fn folder() -> Option<PathBuf> { |
| 11 | None | 11 | None |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | pub fn look_up(_: impl Fn() + Send + 'static) {} | 14 | pub fn look_up(_: impl Fn() + Send + Sync + 'static) {} |
| 15 | |||
| 16 | pub fn download(_: &Path) -> usize { | ||
| 17 | 0 | ||
| 18 | } | ||
| 15 | 19 | ||
| 16 | pub fn drive() -> Option<PathBuf> { | 20 | pub fn drive() -> Option<PathBuf> { |
| 17 | None | 21 | None |
| ... | @@ -19,6 +23,7 @@ pub fn drive() -> Option<PathBuf> { | ... | @@ -19,6 +23,7 @@ pub fn drive() -> Option<PathBuf> { |
| 19 | 23 | ||
| 20 | pub fn section( | 24 | pub fn section( |
| 21 | notebook: &Notebook, | 25 | notebook: &Notebook, |
| 26 | _: &Path, | ||
| 22 | path: &str, | 27 | path: &str, |
| 23 | notify: impl Fn() + Send + 'static, | 28 | notify: impl Fn() + Send + 'static, |
| 24 | ) -> Result<Section, notebook::Error> { | 29 | ) -> Result<Section, notebook::Error> { |
crates/snowbound/src/icloud_macos.rs+95-10| ... | @@ -77,29 +77,45 @@ pub fn ubiquitous(path: &Path) -> bool { | ... | @@ -77,29 +77,45 @@ pub fn ubiquitous(path: &Path) -> bool { |
| 77 | }) | 77 | }) |
| 78 | } | 78 | } |
| 79 | 79 | ||
| 80 | /// The app's own folder in iCloud Drive as `look_up` last found it. | 80 | /// The app's own folder in iCloud Drive as `look_up` last found it, and what reports its |
| 81 | static FOLDER: Mutex<Option<PathBuf>> = Mutex::new(None); | 81 | /// notebooks coming and going. |
| 82 | static FOLDER: Mutex<Option<(PathBuf, Option<Presenter>)>> = Mutex::new(None); | ||
| 82 | 83 | ||
| 83 | /// The app's own folder in iCloud Drive (its container's Documents, shown as iCloud Drive's | 84 | /// The app's own folder in iCloud Drive (its container's Documents, shown as iCloud Drive's |
| 84 | /// Snowbound folder), as `look_up` last found it: while the app carries the container's | 85 | /// Snowbound folder), as `look_up` last found it: while the app carries the container's |
| 85 | /// entitlement and iCloud Drive is on. | 86 | /// entitlement and iCloud Drive is on. |
| 86 | pub fn folder() -> Option<PathBuf> { | 87 | pub fn folder() -> Option<PathBuf> { |
| 87 | FOLDER.lock().ok()?.clone() | 88 | Some(FOLDER.lock().ok()?.as_ref()?.0.clone()) |
| 88 | } | 89 | } |
| 89 | 90 | ||
| 90 | /// Looks for the app's own folder in iCloud Drive on a thread of its own, as asking the | 91 | /// Looks for the app's own folder in iCloud Drive on a thread of its own, as asking the |
| 91 | /// iCloud daemon can take a while, then calls `done`. | 92 | /// iCloud daemon can take a while, then calls `changed`, and again whenever a folder at its |
| 92 | pub fn look_up(done: impl Fn() + Send + 'static) { | 93 | /// top comes or goes, as another device adds or removes a notebook. |
| 94 | pub fn look_up(changed: impl Fn() + Send + Sync + 'static) { | ||
| 93 | std::thread::spawn(move || { | 95 | std::thread::spawn(move || { |
| 94 | let found = container(); | 96 | let changed = std::sync::Arc::new(changed); |
| 97 | let found = container().map(|root| { | ||
| 98 | let report = std::sync::Arc::clone(&changed); | ||
| 99 | let presenter = presenter(&root, move |paths| { | ||
| 100 | if paths.iter().any(|path| !path.contains('/')) { | ||
| 101 | report(); | ||
| 102 | } | ||
| 103 | }); | ||
| 104 | (root, presenter) | ||
| 105 | }); | ||
| 95 | if let Ok(mut folder) = FOLDER.lock() { | 106 | if let Ok(mut folder) = FOLDER.lock() { |
| 96 | *folder = found; | 107 | *folder = found; |
| 97 | } | 108 | } |
| 98 | done(); | 109 | changed(); |
| 99 | }); | 110 | }); |
| 100 | } | 111 | } |
| 101 | 112 | ||
| 113 | /// The container's Documents, or for trying the app out without its entitlement, the folder | ||
| 114 | /// `SNOWBOUND_ICLOUD_FOLDER` names in its place. | ||
| 102 | fn container() -> Option<PathBuf> { | 115 | fn container() -> Option<PathBuf> { |
| 116 | if let Some(folder) = std::env::var_os("SNOWBOUND_ICLOUD_FOLDER") { | ||
| 117 | return Some(folder.into()); | ||
| 118 | } | ||
| 103 | if !available() { | 119 | if !available() { |
| 104 | return None; | 120 | return None; |
| 105 | } | 121 | } |
| ... | @@ -183,6 +199,43 @@ fn present(path: &Path) -> io::Result<()> { | ... | @@ -183,6 +199,43 @@ fn present(path: &Path) -> io::Result<()> { |
| 183 | )) | 199 | )) |
| 184 | } | 200 | } |
| 185 | 201 | ||
| 202 | /// Asks iCloud Drive for every file below `root` it keeps elsewhere, dataless or as a | ||
| 203 | /// `.Name.icloud` placeholder: how many are not here yet. | ||
| 204 | pub fn download(root: &Path) -> usize { | ||
| 205 | use std::os::macos::fs::MetadataExt; | ||
| 206 | let manager = unsafe { NSFileManager::defaultManager() }; | ||
| 207 | let mut missing = 0; | ||
| 208 | let mut folders = vec![root.to_owned()]; | ||
| 209 | while let Some(folder) = folders.pop() { | ||
| 210 | for entry in std::fs::read_dir(&folder).into_iter().flatten().flatten() { | ||
| 211 | let (path, name) = (entry.path(), entry.file_name()); | ||
| 212 | let Ok(metadata) = entry.metadata() else { | ||
| 213 | continue; | ||
| 214 | }; | ||
| 215 | if metadata.is_dir() { | ||
| 216 | folders.push(path); | ||
| 217 | continue; | ||
| 218 | } | ||
| 219 | let placeholder = name | ||
| 220 | .to_str() | ||
| 221 | .and_then(|name| name.strip_prefix('.')?.strip_suffix(".icloud")); | ||
| 222 | let file = match placeholder { | ||
| 223 | Some(real) => path.with_file_name(real), | ||
| 224 | None if metadata.st_flags() & SF_DATALESS != 0 => path, | ||
| 225 | None => continue, | ||
| 226 | }; | ||
| 227 | missing += 1; | ||
| 228 | if let Some(url) = url(&file) { | ||
| 229 | let _ = unsafe { manager.startDownloadingUbiquitousItemAtURL_error(&url) }; | ||
| 230 | } | ||
| 231 | } | ||
| 232 | } | ||
| 233 | missing | ||
| 234 | } | ||
| 235 | |||
| 236 | /// `st_flags` of a file whose contents the system keeps elsewhere until read. | ||
| 237 | const SF_DATALESS: u32 = 0x4000_0000; | ||
| 238 | |||
| 186 | /// A section file in iCloud Drive, read and published under file coordination, with the | 239 | /// A section file in iCloud Drive, read and published under file coordination, with the |
| 187 | /// conflict versions iCloud keeps beside it. | 240 | /// conflict versions iCloud keeps beside it. |
| 188 | pub struct Coordinated(pub PathBuf); | 241 | pub struct Coordinated(pub PathBuf); |
| ... | @@ -303,23 +356,39 @@ impl Remote for Coordinated { | ... | @@ -303,23 +356,39 @@ impl Remote for Coordinated { |
| 303 | } | 356 | } |
| 304 | } | 357 | } |
| 305 | 358 | ||
| 306 | /// Opens the section at catalog `path` of a notebook in iCloud Drive. | 359 | /// Opens the section at catalog `path` of the notebook in iCloud Drive's folder `root`. One |
| 360 | /// without a replica here yet waits for iCloud Drive to bring its file down, which the sync | ||
| 361 | /// thread's reads would not. | ||
| 307 | pub fn section( | 362 | pub fn section( |
| 308 | notebook: &Notebook, | 363 | notebook: &Notebook, |
| 364 | root: &Path, | ||
| 309 | path: &str, | 365 | path: &str, |
| 310 | notify: impl Fn() + Send + 'static, | 366 | notify: impl Fn() + Send + 'static, |
| 311 | ) -> Result<Section, notebook::Error> { | 367 | ) -> Result<Section, notebook::Error> { |
| 368 | if !notebook.replica_path(path)?.exists() { | ||
| 369 | fetch(&root.join(path))?; | ||
| 370 | } | ||
| 312 | let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?; | 371 | let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?; |
| 313 | section.set_pause(PAUSE); | 372 | section.set_pause(PAUSE); |
| 314 | Ok(section) | 373 | Ok(section) |
| 315 | } | 374 | } |
| 316 | 375 | ||
| 317 | /// Opens a section file in iCloud Drive on its own. | 376 | /// Waits for iCloud Drive to bring down `file`, where it keeps it elsewhere. |
| 377 | fn fetch(file: &Path) -> io::Result<()> { | ||
| 378 | // Reading any of a dataless file brings all of it down. | ||
| 379 | coordinated(file, false, || { | ||
| 380 | use std::io::Read; | ||
| 381 | std::fs::File::open(file)?.read(&mut [0]).map(drop) | ||
| 382 | })? | ||
| 383 | } | ||
| 384 | |||
| 385 | /// Opens a section file in iCloud Drive on its own, once here. | ||
| 318 | pub fn lone_section( | 386 | pub fn lone_section( |
| 319 | file: &Path, | 387 | file: &Path, |
| 320 | cache: &Path, | 388 | cache: &Path, |
| 321 | notify: impl Fn() + Send + 'static, | 389 | notify: impl Fn() + Send + 'static, |
| 322 | ) -> Result<Section, notebook::Error> { | 390 | ) -> Result<Section, notebook::Error> { |
| 391 | fetch(file)?; | ||
| 323 | let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?; | 392 | let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?; |
| 324 | section.set_pause(PAUSE); | 393 | section.set_pause(PAUSE); |
| 325 | Ok(section) | 394 | Ok(section) |
| ... | @@ -385,6 +454,22 @@ declare_class!( | ... | @@ -385,6 +454,22 @@ declare_class!( |
| 385 | self.report(url); | 454 | self.report(url); |
| 386 | } | 455 | } |
| 387 | 456 | ||
| 457 | #[method(presentedSubitemAtURL:didMoveToURL:)] | ||
| 458 | unsafe fn subitem_did_move(&self, from: &NSURL, to: &NSURL) { | ||
| 459 | self.report(from); | ||
| 460 | self.report(to); | ||
| 461 | } | ||
| 462 | |||
| 463 | #[method(accommodatePresentedSubitemDeletionAtURL:completionHandler:)] | ||
| 464 | unsafe fn accommodate_subitem_deletion( | ||
| 465 | &self, | ||
| 466 | url: &NSURL, | ||
| 467 | done: &block2::Block<dyn Fn(*mut objc2_foundation::NSError)>, | ||
| 468 | ) { | ||
| 469 | self.report(url); | ||
| 470 | done.call((std::ptr::null_mut(),)); | ||
| 471 | } | ||
| 472 | |||
| 388 | #[method(presentedSubitemAtURL:didResolveConflictVersion:)] | 473 | #[method(presentedSubitemAtURL:didResolveConflictVersion:)] |
| 389 | unsafe fn subitem_did_resolve_conflict_version( | 474 | unsafe fn subitem_did_resolve_conflict_version( |
| 390 | &self, | 475 | &self, |
| ... | @@ -491,7 +576,7 @@ mod tests { | ... | @@ -491,7 +576,7 @@ mod tests { |
| 491 | let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap(); | 576 | let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap(); |
| 492 | assert!(ubiquitous(&root)); | 577 | assert!(ubiquitous(&root)); |
| 493 | let path = notebook.catalog().sections[0].path.clone(); | 578 | let path = notebook.catalog().sections[0].path.clone(); |
| 494 | let section = section(&notebook, &path, || {}).unwrap(); | 579 | let section = section(&notebook, &root, &path, || {}).unwrap(); |
| 495 | let (space, ..) = section.pages().unwrap()[0].clone(); | 580 | let (space, ..) = section.pages().unwrap()[0].clone(); |
| 496 | let title = section | 581 | let title = section |
| 497 | .page(space) | 582 | .page(space) |
crates/snowbound/src/instance.rs created+247| ... | @@ -0,0 +1,247 @@ | ||
| 1 | //! One running app per cache folder on Windows and Linux. A second launch hands the files it | ||
| 2 | //! was asked to open to the first and exits, as Launch Services does for the macOS bundle: two | ||
| 3 | //! processes on one cache would wait on each other's section replicas. The first listens on a | ||
| 4 | //! named pipe on Windows and on a Unix socket in the cache folder on Linux. | ||
| 5 | |||
| 6 | use std::path::{Path, PathBuf}; | ||
| 7 | use winit::event_loop::EventLoopProxy; | ||
| 8 | |||
| 9 | /// The files a launch asks to open, separated by NULs, which no path holds. | ||
| 10 | fn encode(paths: &[PathBuf]) -> Vec<u8> { | ||
| 11 | let mut bytes = Vec::new(); | ||
| 12 | for path in paths { | ||
| 13 | bytes.extend(path_bytes(path)); | ||
| 14 | bytes.push(0); | ||
| 15 | } | ||
| 16 | bytes | ||
| 17 | } | ||
| 18 | |||
| 19 | fn decode(bytes: &[u8]) -> Vec<PathBuf> { | ||
| 20 | bytes | ||
| 21 | .split(|&byte| byte == 0) | ||
| 22 | .filter(|path| !path.is_empty()) | ||
| 23 | .map(bytes_path) | ||
| 24 | .collect() | ||
| 25 | } | ||
| 26 | |||
| 27 | /// Opens what each later launch hands over, on a thread of its own. | ||
| 28 | fn serve<C: std::io::Read + Send + 'static>( | ||
| 29 | proxy: EventLoopProxy<crate::UserEvent>, | ||
| 30 | mut accept: impl FnMut() -> Option<C> + Send + 'static, | ||
| 31 | ) { | ||
| 32 | std::thread::spawn(move || { | ||
| 33 | while let Some(mut connection) = accept() { | ||
| 34 | let mut bytes = Vec::new(); | ||
| 35 | if connection.read_to_end(&mut bytes).is_ok() { | ||
| 36 | let _ = proxy.send_event(crate::UserEvent::Open(decode(&bytes))); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | }); | ||
| 40 | } | ||
| 41 | |||
| 42 | #[cfg(unix)] | ||
| 43 | fn path_bytes(path: &Path) -> Vec<u8> { | ||
| 44 | use std::os::unix::ffi::OsStrExt; | ||
| 45 | path.as_os_str().as_bytes().to_vec() | ||
| 46 | } | ||
| 47 | |||
| 48 | #[cfg(unix)] | ||
| 49 | fn bytes_path(bytes: &[u8]) -> PathBuf { | ||
| 50 | use std::os::unix::ffi::OsStrExt; | ||
| 51 | std::ffi::OsStr::from_bytes(bytes).into() | ||
| 52 | } | ||
| 53 | |||
| 54 | #[cfg(windows)] | ||
| 55 | fn path_bytes(path: &Path) -> Vec<u8> { | ||
| 56 | path.to_string_lossy().into_owned().into_bytes() | ||
| 57 | } | ||
| 58 | |||
| 59 | #[cfg(windows)] | ||
| 60 | fn bytes_path(bytes: &[u8]) -> PathBuf { | ||
| 61 | String::from_utf8_lossy(bytes).into_owned().into() | ||
| 62 | } | ||
| 63 | |||
| 64 | #[cfg(target_os = "linux")] | ||
| 65 | pub use self::linux::*; | ||
| 66 | |||
| 67 | #[cfg(target_os = "linux")] | ||
| 68 | mod linux { | ||
| 69 | use super::*; | ||
| 70 | use std::{ | ||
| 71 | io::{ErrorKind, Write}, | ||
| 72 | os::unix::net::{UnixListener, UnixStream}, | ||
| 73 | }; | ||
| 74 | |||
| 75 | /// Listening, unless the socket couldn't be made. | ||
| 76 | pub struct Instance(Option<UnixListener>); | ||
| 77 | |||
| 78 | /// This launch as the running app for `cache`, or None once the running one took `paths`. | ||
| 79 | pub fn claim(cache: &Path, paths: &[PathBuf]) -> Option<Instance> { | ||
| 80 | let socket = cache.join("instance.sock"); | ||
| 81 | for _ in 0..3 { | ||
| 82 | match UnixStream::connect(&socket) { | ||
| 83 | Ok(mut stream) => { | ||
| 84 | if stream.write_all(&encode(paths)).is_ok() { | ||
| 85 | return None; | ||
| 86 | } | ||
| 87 | } | ||
| 88 | // The socket of an app that ended without removing it. | ||
| 89 | Err(error) if error.kind() == ErrorKind::ConnectionRefused => { | ||
| 90 | let _ = std::fs::remove_file(&socket); | ||
| 91 | } | ||
| 92 | Err(_) => {} | ||
| 93 | } | ||
| 94 | let _ = std::fs::create_dir_all(cache); | ||
| 95 | match UnixListener::bind(&socket) { | ||
| 96 | Ok(listener) => return Some(Instance(Some(listener))), | ||
| 97 | // Another launch bound it first. | ||
| 98 | Err(error) if error.kind() == ErrorKind::AddrInUse => {} | ||
| 99 | Err(error) => { | ||
| 100 | eprintln!("Cannot listen at {}: {error}", socket.display()); | ||
| 101 | break; | ||
| 102 | } | ||
| 103 | } | ||
| 104 | } | ||
| 105 | Some(Instance(None)) | ||
| 106 | } | ||
| 107 | |||
| 108 | impl Instance { | ||
| 109 | pub fn serve(self, proxy: EventLoopProxy<crate::UserEvent>) { | ||
| 110 | if let Some(listener) = self.0 { | ||
| 111 | super::serve(proxy, move || listener.incoming().find_map(Result::ok)); | ||
| 112 | } | ||
| 113 | } | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | #[cfg(windows)] | ||
| 118 | pub use self::windows::*; | ||
| 119 | |||
| 120 | #[cfg(windows)] | ||
| 121 | mod windows { | ||
| 122 | use super::*; | ||
| 123 | use std::{fs::File, io::Write, os::windows::io::FromRawHandle}; | ||
| 124 | use windows_sys::Win32::{ | ||
| 125 | Foundation::{ | ||
| 126 | ERROR_PIPE_BUSY, ERROR_PIPE_CONNECTED, GENERIC_WRITE, GetLastError, | ||
| 127 | INVALID_HANDLE_VALUE, | ||
| 128 | }, | ||
| 129 | Storage::FileSystem::{ | ||
| 130 | CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, OPEN_EXISTING, PIPE_ACCESS_INBOUND, | ||
| 131 | }, | ||
| 132 | System::Pipes::{ | ||
| 133 | ConnectNamedPipe, CreateNamedPipeW, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, | ||
| 134 | PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, WaitNamedPipeW, | ||
| 135 | }, | ||
| 136 | UI::WindowsAndMessaging::{ASFW_ANY, AllowSetForegroundWindow}, | ||
| 137 | }; | ||
| 138 | |||
| 139 | pub struct Instance { | ||
| 140 | name: Vec<u16>, | ||
| 141 | /// The pipe's first end, unless the name couldn't be claimed. | ||
| 142 | first: Option<File>, | ||
| 143 | } | ||
| 144 | |||
| 145 | /// The pipe named for `cache`, which a pipe's name may hold but for its backslashes. | ||
| 146 | fn pipe_name(cache: &Path) -> Vec<u16> { | ||
| 147 | let cache = cache.to_string_lossy().replace('\\', "/").to_lowercase(); | ||
| 148 | format!(r"\\.\pipe\Snowbound\{cache}") | ||
| 149 | .encode_utf16() | ||
| 150 | .chain([0]) | ||
| 151 | .collect() | ||
| 152 | } | ||
| 153 | |||
| 154 | /// A new end of the pipe for the next launch to write to; the first claims the name. | ||
| 155 | fn listen(name: &[u16], first: bool) -> Option<File> { | ||
| 156 | let flags = if first { | ||
| 157 | FILE_FLAG_FIRST_PIPE_INSTANCE | ||
| 158 | } else { | ||
| 159 | 0 | ||
| 160 | }; | ||
| 161 | let handle = unsafe { | ||
| 162 | CreateNamedPipeW( | ||
| 163 | name.as_ptr(), | ||
| 164 | PIPE_ACCESS_INBOUND | flags, | ||
| 165 | PIPE_TYPE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, | ||
| 166 | PIPE_UNLIMITED_INSTANCES, | ||
| 167 | 0, | ||
| 168 | 64 * 1024, | ||
| 169 | 0, | ||
| 170 | std::ptr::null(), | ||
| 171 | ) | ||
| 172 | }; | ||
| 173 | (handle != INVALID_HANDLE_VALUE).then(|| unsafe { File::from_raw_handle(handle) }) | ||
| 174 | } | ||
| 175 | |||
| 176 | /// This launch as the running app for `cache`, or None once the running one took `paths`. | ||
| 177 | pub fn claim(cache: &Path, paths: &[PathBuf]) -> Option<Instance> { | ||
| 178 | let name = pipe_name(cache); | ||
| 179 | for _ in 0..3 { | ||
| 180 | let handle = unsafe { | ||
| 181 | CreateFileW( | ||
| 182 | name.as_ptr(), | ||
| 183 | GENERIC_WRITE, | ||
| 184 | 0, | ||
| 185 | std::ptr::null(), | ||
| 186 | OPEN_EXISTING, | ||
| 187 | 0, | ||
| 188 | std::ptr::null_mut(), | ||
| 189 | ) | ||
| 190 | }; | ||
| 191 | if handle != INVALID_HANDLE_VALUE { | ||
| 192 | // The running app may then bring its window to the front. | ||
| 193 | unsafe { AllowSetForegroundWindow(ASFW_ANY) }; | ||
| 194 | let mut pipe = unsafe { File::from_raw_handle(handle) }; | ||
| 195 | if pipe.write_all(&encode(paths)).is_ok() { | ||
| 196 | return None; | ||
| 197 | } | ||
| 198 | } else if unsafe { GetLastError() } == ERROR_PIPE_BUSY { | ||
| 199 | unsafe { WaitNamedPipeW(name.as_ptr(), 2000) }; | ||
| 200 | continue; | ||
| 201 | } | ||
| 202 | if let Some(first) = listen(&name, true) { | ||
| 203 | return Some(Instance { | ||
| 204 | name, | ||
| 205 | first: Some(first), | ||
| 206 | }); | ||
| 207 | } | ||
| 208 | } | ||
| 209 | eprintln!("Cannot reach the running Snowbound; opening another."); | ||
| 210 | Some(Instance { name, first: None }) | ||
| 211 | } | ||
| 212 | |||
| 213 | impl Instance { | ||
| 214 | pub fn serve(self, proxy: EventLoopProxy<crate::UserEvent>) { | ||
| 215 | let Instance { name, first } = self; | ||
| 216 | let mut waiting = first; | ||
| 217 | super::serve(proxy, move || { | ||
| 218 | loop { | ||
| 219 | let pipe = waiting.take()?; | ||
| 220 | let handle = std::os::windows::io::AsRawHandle::as_raw_handle(&pipe); | ||
| 221 | let connected = unsafe { ConnectNamedPipe(handle, std::ptr::null_mut()) } != 0 | ||
| 222 | || unsafe { GetLastError() } == ERROR_PIPE_CONNECTED; | ||
| 223 | // The next end opens before this one closes, so the name never lapses. | ||
| 224 | waiting = listen(&name, false); | ||
| 225 | if connected { | ||
| 226 | return Some(pipe); | ||
| 227 | } | ||
| 228 | } | ||
| 229 | }); | ||
| 230 | } | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | #[cfg(test)] | ||
| 235 | mod tests { | ||
| 236 | use super::*; | ||
| 237 | |||
| 238 | #[test] | ||
| 239 | fn handed_paths_read_back() { | ||
| 240 | let paths = [ | ||
| 241 | PathBuf::from("/notes/Personal/Garden.one"), | ||
| 242 | PathBuf::from("/notes/a b/Open Notebook.onetoc2"), | ||
| 243 | ]; | ||
| 244 | assert_eq!(decode(&encode(&paths)), paths); | ||
| 245 | assert!(decode(&encode(&[])).is_empty()); | ||
| 246 | } | ||
| 247 | } | ||
crates/snowbound/src/keys.rs created+534| ... | @@ -0,0 +1,534 @@ | ||
| 1 | //! Options' Keyboard: every command in the table under its menu with its chords, which a | ||
| 2 | //! click records anew, and which OK puts in use. | ||
| 3 | |||
| 4 | use crate::commands::{self, COMMANDS, Chord, Id as Command, Keymap, Unusable}; | ||
| 5 | use crate::options::found; | ||
| 6 | use accesskit::Role; | ||
| 7 | use canvas::editor::Toggle; | ||
| 8 | use draw::edit::{Key, NamedKey, Platform}; | ||
| 9 | use ui::{Axis, Flags, Spec, Ui, children, fill, fit, px}; | ||
| 10 | |||
| 11 | /// The menus the table's commands fall under, each from its first command on. | ||
| 12 | const GROUPS: [(&str, Command); 9] = [ | ||
| 13 | ("Snowbound", Command::Settings), | ||
| 14 | ("File", Command::NewNotebook), | ||
| 15 | ("Edit", Command::Undo), | ||
| 16 | ("View", Command::GoTo), | ||
| 17 | ("Insert", Command::Table), | ||
| 18 | ("Draw", Command::SelectType), | ||
| 19 | ("Format", Command::Toggle(Toggle::Bold)), | ||
| 20 | ("Tags", Command::CustomizeTags), | ||
| 21 | ("Help", Command::Help), | ||
| 22 | ]; | ||
| 23 | |||
| 24 | /// The Keyboard section's keymap until OK keeps it, and the chord being recorded. | ||
| 25 | pub struct Keyboard { | ||
| 26 | pub keymap: Keymap, | ||
| 27 | recording: Option<Recording>, | ||
| 28 | } | ||
| 29 | |||
| 30 | struct Recording { | ||
| 31 | command: Command, | ||
| 32 | /// The place of the chord the new one replaces; `None` adds one. | ||
| 33 | replacing: Option<usize>, | ||
| 34 | refused: Option<Refusal>, | ||
| 35 | } | ||
| 36 | |||
| 37 | enum Refusal { | ||
| 38 | Unusable(Chord, Unusable), | ||
| 39 | /// Run by another command, or a tag. | ||
| 40 | Taken(Chord, Command), | ||
| 41 | } | ||
| 42 | |||
| 43 | /// Whether every word of `query` is in command `id`'s title, menu or `chords`. | ||
| 44 | fn hit(query: &[String], id: Command, chords: &[Chord]) -> bool { | ||
| 45 | let labels: Vec<String> = chords.iter().map(|chord| label(*chord)).collect(); | ||
| 46 | let mut texts = vec![commands::command(id).title, group(id)]; | ||
| 47 | texts.extend(labels.iter().map(String::as_str)); | ||
| 48 | found(query, &texts) | ||
| 49 | } | ||
| 50 | |||
| 51 | /// Whether the Options search finds a command by `query`, with the chords in use. | ||
| 52 | pub fn searched(query: &[String]) -> bool { | ||
| 53 | let keymap = Keymap::current(); | ||
| 54 | (COMMANDS.iter()).any(|command| { | ||
| 55 | hit( | ||
| 56 | query, | ||
| 57 | command.id, | ||
| 58 | keymap.chords(command.id, Platform::CURRENT), | ||
| 59 | ) | ||
| 60 | }) | ||
| 61 | } | ||
| 62 | |||
| 63 | /// The heading of the menu `id` falls under. | ||
| 64 | fn group(id: Command) -> &'static str { | ||
| 65 | let at = |id| COMMANDS.iter().position(|command| command.id == id); | ||
| 66 | let place = at(id).unwrap_or_default(); | ||
| 67 | GROUPS | ||
| 68 | .iter() | ||
| 69 | .rev() | ||
| 70 | .find(|(_, first)| at(*first).is_some_and(|first| first <= place)) | ||
| 71 | .map_or("", |(name, _)| name) | ||
| 72 | } | ||
| 73 | |||
| 74 | fn label(chord: Chord) -> String { | ||
| 75 | chord.label(Platform::CURRENT) | ||
| 76 | } | ||
| 77 | |||
| 78 | fn title(id: Command) -> String { | ||
| 79 | match id { | ||
| 80 | Command::Tag(place) => format!("Tag {}", place + 1), | ||
| 81 | id => commands::command(id).title.trim_end_matches('…').to_owned(), | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | impl Keyboard { | ||
| 86 | pub fn new() -> Self { | ||
| 87 | Self { | ||
| 88 | keymap: Keymap::current(), | ||
| 89 | recording: None, | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | pub fn recording(&self) -> bool { | ||
| 94 | self.recording.is_some() | ||
| 95 | } | ||
| 96 | |||
| 97 | /// Takes a key pressed while recording: Escape stops, and a chord is given the command | ||
| 98 | /// unless it is unusable or another's. Reports whether the key was taken. | ||
| 99 | pub fn key(&mut self, key: &Key, modifiers: draw::edit::Modifiers) -> bool { | ||
| 100 | let Some(recording) = &mut self.recording else { | ||
| 101 | return false; | ||
| 102 | }; | ||
| 103 | let chord = match key { | ||
| 104 | Key::Named(NamedKey::Escape) => { | ||
| 105 | self.recording = None; | ||
| 106 | return true; | ||
| 107 | } | ||
| 108 | Key::Named(NamedKey::Modifier | NamedKey::Other) => return true, | ||
| 109 | key => commands::pressed(key, modifiers), | ||
| 110 | }; | ||
| 111 | let Some(chord) = chord else { | ||
| 112 | return true; | ||
| 113 | }; | ||
| 114 | let platform = Platform::CURRENT; | ||
| 115 | if let Some(why) = chord.unusable(platform) { | ||
| 116 | recording.refused = Some(Refusal::Unusable(chord, why)); | ||
| 117 | return true; | ||
| 118 | } | ||
| 119 | match self.keymap.ran(chord, platform) { | ||
| 120 | Some(owner) if owner == recording.command => self.recording = None, | ||
| 121 | Some(owner) => recording.refused = Some(Refusal::Taken(chord, owner)), | ||
| 122 | None => self.bind(chord), | ||
| 123 | } | ||
| 124 | true | ||
| 125 | } | ||
| 126 | |||
| 127 | /// Gives the recorded command `chord` in place of the one it replaces. | ||
| 128 | fn bind(&mut self, chord: Chord) { | ||
| 129 | let Some(recording) = self.recording.take() else { | ||
| 130 | return; | ||
| 131 | }; | ||
| 132 | let mut chords = self.chords(recording.command); | ||
| 133 | match recording.replacing { | ||
| 134 | Some(place) => chords[place] = chord, | ||
| 135 | None => chords.push(chord), | ||
| 136 | } | ||
| 137 | self.keymap.set(recording.command, chords); | ||
| 138 | } | ||
| 139 | |||
| 140 | fn chords(&self, id: Command) -> Vec<Chord> { | ||
| 141 | self.keymap.chords(id, Platform::CURRENT).to_vec() | ||
| 142 | } | ||
| 143 | |||
| 144 | /// Takes `chord` from its owner, or with `swap` gives the owner the chord replaced, then | ||
| 145 | /// binds it. | ||
| 146 | fn take(&mut self, chord: Chord, owner: Command, swap: bool) { | ||
| 147 | let replaced = (self.recording.as_ref()) | ||
| 148 | .and_then(|recording| Some(self.chords(recording.command)[recording.replacing?])); | ||
| 149 | // A tag yields its chord to the command given it by itself. | ||
| 150 | if !matches!(owner, Command::Tag(_)) { | ||
| 151 | let chords = self | ||
| 152 | .chords(owner) | ||
| 153 | .into_iter() | ||
| 154 | .filter_map(|held| match (held == chord, replaced) { | ||
| 155 | (false, _) => Some(held), | ||
| 156 | (true, Some(replaced)) if swap => Some(replaced), | ||
| 157 | (true, _) => None, | ||
| 158 | }) | ||
| 159 | .collect(); | ||
| 160 | self.keymap.set(owner, chords); | ||
| 161 | } | ||
| 162 | self.bind(chord); | ||
| 163 | } | ||
| 164 | |||
| 165 | /// Builds the section in the box open in `ui`: the commands matching every word of | ||
| 166 | /// `query` in their title, menu or chords, or all where none match. | ||
| 167 | pub fn build(&mut self, ui: &mut Ui, query: &[String]) { | ||
| 168 | let theme = ui.theme.clone(); | ||
| 169 | let row = theme.font_size * 2.0; | ||
| 170 | ui.open( | ||
| 171 | "keyboard", | ||
| 172 | Spec { | ||
| 173 | axis: Axis::Y, | ||
| 174 | size: [fill(), children()], | ||
| 175 | gap: 2.0, | ||
| 176 | ..Spec::default() | ||
| 177 | }, | ||
| 178 | ); | ||
| 179 | ui.open( | ||
| 180 | "bar", | ||
| 181 | Spec { | ||
| 182 | size: [fill(), px(row)], | ||
| 183 | gap: 8.0, | ||
| 184 | ..Spec::default() | ||
| 185 | }, | ||
| 186 | ); | ||
| 187 | ui.leaf( | ||
| 188 | "hint", | ||
| 189 | Spec { | ||
| 190 | flags: Flags::CLIP, | ||
| 191 | size: [fill(), px(row)], | ||
| 192 | text: Some("Click a shortcut to change it"), | ||
| 193 | color: Some(theme.text_dim), | ||
| 194 | ..Spec::default() | ||
| 195 | }, | ||
| 196 | ); | ||
| 197 | if ui::button(ui, "reset-all", "Reset All").clicked { | ||
| 198 | self.keymap = Keymap::default(); | ||
| 199 | self.recording = None; | ||
| 200 | } | ||
| 201 | ui.close(); | ||
| 202 | let listed: Vec<(Command, Vec<Chord>)> = COMMANDS | ||
| 203 | .iter() | ||
| 204 | .filter(|command| commands::offered(command.id)) | ||
| 205 | .map(|command| (command.id, self.chords(command.id))) | ||
| 206 | .collect(); | ||
| 207 | let matching = |(id, chords): &&(Command, Vec<Chord>)| hit(query, *id, chords); | ||
| 208 | let some = listed.iter().any(|command| matching(&command)); | ||
| 209 | let mut heading = ""; | ||
| 210 | for (id, chords) in listed.iter().filter(|command| !some || matching(command)) { | ||
| 211 | let group = group(*id); | ||
| 212 | if group != heading { | ||
| 213 | heading = group; | ||
| 214 | ui.leaf( | ||
| 215 | ("group", group), | ||
| 216 | Spec { | ||
| 217 | size: [fill(), px(row)], | ||
| 218 | text: Some(group), | ||
| 219 | bold: true, | ||
| 220 | color: Some(theme.text_dim), | ||
| 221 | role: Some(Role::Heading), | ||
| 222 | ..Spec::default() | ||
| 223 | }, | ||
| 224 | ); | ||
| 225 | } | ||
| 226 | self.command_row(ui, *id, chords); | ||
| 227 | if self | ||
| 228 | .recording | ||
| 229 | .as_ref() | ||
| 230 | .is_some_and(|recording| recording.command == *id) | ||
| 231 | { | ||
| 232 | self.status(ui); | ||
| 233 | } | ||
| 234 | } | ||
| 235 | ui.close(); | ||
| 236 | } | ||
| 237 | |||
| 238 | /// A command's row: its title, a chip per chord to record anew, one to add a chord, and | ||
| 239 | /// Reset where the user changed its chords. | ||
| 240 | fn command_row(&mut self, ui: &mut Ui, id: Command, chords: &[Chord]) { | ||
| 241 | let theme = ui.theme.clone(); | ||
| 242 | let height = theme.font_size * 1.9; | ||
| 243 | let row = ui.open( | ||
| 244 | ("command", format!("{id:?}")), | ||
| 245 | Spec { | ||
| 246 | size: [fill(), px(height)], | ||
| 247 | gap: 4.0, | ||
| 248 | pad: [6.0, 2.0], | ||
| 249 | radius: 4.0, | ||
| 250 | hover_fill: Some(theme.hover()), | ||
| 251 | role: Some(Role::ListItem), | ||
| 252 | ..Spec::default() | ||
| 253 | }, | ||
| 254 | ); | ||
| 255 | let title = title(id); | ||
| 256 | if let Some(node) = ui.access(row) { | ||
| 257 | node.set_label(title.as_str()); | ||
| 258 | } | ||
| 259 | ui.leaf( | ||
| 260 | "title", | ||
| 261 | Spec { | ||
| 262 | flags: Flags::CLIP, | ||
| 263 | size: [fill(), px(height - 4.0)], | ||
| 264 | text: Some(&title), | ||
| 265 | bold: self.keymap.customized(id), | ||
| 266 | ..Spec::default() | ||
| 267 | }, | ||
| 268 | ); | ||
| 269 | let recording = self | ||
| 270 | .recording | ||
| 271 | .as_ref() | ||
| 272 | .filter(|recording| recording.command == id) | ||
| 273 | .map(|recording| recording.replacing); | ||
| 274 | let chip = |ui: &mut Ui, part: (usize, bool), text: &str, live: bool| { | ||
| 275 | ui.leaf( | ||
| 276 | part, | ||
| 277 | Spec { | ||
| 278 | flags: Flags::CLICKABLE, | ||
| 279 | size: [fit(), px(height - 4.0)], | ||
| 280 | text: Some(text), | ||
| 281 | color: Some(if live { theme.accent } else { theme.text }), | ||
| 282 | fill: Some(theme.chip), | ||
| 283 | hover_border: Some(theme.accent), | ||
| 284 | border: live.then_some(theme.accent), | ||
| 285 | radius: 4.0, | ||
| 286 | pad: [8.0, 0.0], | ||
| 287 | center: true, | ||
| 288 | role: Some(Role::Button), | ||
| 289 | ..Spec::default() | ||
| 290 | }, | ||
| 291 | ) | ||
| 292 | .clicked | ||
| 293 | }; | ||
| 294 | for (place, chord) in chords.iter().enumerate() { | ||
| 295 | let live = recording == Some(Some(place)); | ||
| 296 | let text = if live { | ||
| 297 | "Type shortcut…".to_owned() | ||
| 298 | } else { | ||
| 299 | label(*chord) | ||
| 300 | }; | ||
| 301 | if chip(ui, (place, false), &text, live) { | ||
| 302 | self.record(ui, id, Some(place)); | ||
| 303 | } | ||
| 304 | } | ||
| 305 | let adding = recording == Some(None); | ||
| 306 | let text = if adding { "Type shortcut…" } else { "+" }; | ||
| 307 | if chip(ui, (chords.len(), true), text, adding) { | ||
| 308 | self.record(ui, id, None); | ||
| 309 | } | ||
| 310 | if let Some(node) = ui.access(ui.id((chords.len(), true))) { | ||
| 311 | node.set_label(format!("Add shortcut for {title}")); | ||
| 312 | } | ||
| 313 | if self.keymap.customized(id) && ui::button(ui, "reset", "Reset").clicked { | ||
| 314 | self.keymap | ||
| 315 | .set(id, commands::command(id).chords(Platform::CURRENT).to_vec()); | ||
| 316 | self.recording = None; | ||
| 317 | } | ||
| 318 | ui.close(); | ||
| 319 | } | ||
| 320 | |||
| 321 | /// Records a chord for `id`, replacing its chord at `replacing` or adding one; the | ||
| 322 | /// keys go to the recording, not the search. | ||
| 323 | fn record(&mut self, ui: &mut Ui, id: Command, replacing: Option<usize>) { | ||
| 324 | self.recording = Some(Recording { | ||
| 325 | command: id, | ||
| 326 | replacing, | ||
| 327 | refused: None, | ||
| 328 | }); | ||
| 329 | ui.set_focus(None); | ||
| 330 | } | ||
| 331 | |||
| 332 | /// The line under the list: what the recording waits for, or why it refused a chord, | ||
| 333 | /// with what can be done about it. | ||
| 334 | fn status(&mut self, ui: &mut Ui) { | ||
| 335 | let theme = ui.theme.clone(); | ||
| 336 | let row = theme.font_size * 2.0; | ||
| 337 | let mac = Platform::CURRENT == Platform::MacOs; | ||
| 338 | let text = match &self.recording { | ||
| 339 | None => "Click a shortcut to change it".to_owned(), | ||
| 340 | Some(recording) => match recording.refused { | ||
| 341 | None => format!("Type a shortcut for {}", title(recording.command)), | ||
| 342 | Some(Refusal::Unusable(chord, Unusable::Typing)) => format!( | ||
| 343 | "{} types text. Include {}", | ||
| 344 | label(chord), | ||
| 345 | if mac { "⌘ or ⌃" } else { "Ctrl or Alt" } | ||
| 346 | ), | ||
| 347 | Some(Refusal::Unusable(chord, Unusable::Editing)) => { | ||
| 348 | format!("{} moves through text. Type another", label(chord)) | ||
| 349 | } | ||
| 350 | Some(Refusal::Unusable(chord, Unusable::System(item))) => { | ||
| 351 | format!("{} is macOS's {item}. Type another", label(chord)) | ||
| 352 | } | ||
| 353 | Some(Refusal::Taken(chord, owner)) => { | ||
| 354 | format!("{} already runs {}", label(chord), title(owner)) | ||
| 355 | } | ||
| 356 | }, | ||
| 357 | }; | ||
| 358 | let status = ui.open( | ||
| 359 | "status", | ||
| 360 | Spec { | ||
| 361 | size: [fill(), px(row)], | ||
| 362 | gap: 8.0, | ||
| 363 | ..Spec::default() | ||
| 364 | }, | ||
| 365 | ); | ||
| 366 | if let Some(node) = ui.access(status) { | ||
| 367 | node.set_live(accesskit::Live::Polite); | ||
| 368 | } | ||
| 369 | ui.leaf( | ||
| 370 | "text", | ||
| 371 | Spec { | ||
| 372 | flags: Flags::CLIP, | ||
| 373 | size: [fill(), px(row)], | ||
| 374 | text: Some(&text), | ||
| 375 | color: Some(theme.text_dim), | ||
| 376 | pad: [6.0, 0.0], | ||
| 377 | role: Some(Role::Label), | ||
| 378 | ..Spec::default() | ||
| 379 | }, | ||
| 380 | ); | ||
| 381 | if let Some(recording) = &self.recording { | ||
| 382 | let taken = match recording.refused { | ||
| 383 | Some(Refusal::Taken(chord, owner)) => Some((chord, owner)), | ||
| 384 | _ => None, | ||
| 385 | }; | ||
| 386 | let (command, replacing) = (recording.command, recording.replacing); | ||
| 387 | if let Some((chord, owner)) = taken { | ||
| 388 | // Only a command, not a tag, can take the chord replaced in exchange. | ||
| 389 | if replacing.is_some() | ||
| 390 | && !matches!(owner, Command::Tag(_)) | ||
| 391 | && ui::button(ui, "swap", "Swap").clicked | ||
| 392 | { | ||
| 393 | self.take(chord, owner, true); | ||
| 394 | } | ||
| 395 | if ui::button(ui, "replace", "Replace").clicked { | ||
| 396 | self.take(chord, owner, false); | ||
| 397 | } | ||
| 398 | } else if let Some(place) = replacing | ||
| 399 | && ui::button(ui, "remove", "Remove").clicked | ||
| 400 | { | ||
| 401 | let mut chords = self.chords(command); | ||
| 402 | chords.remove(place); | ||
| 403 | self.keymap.set(command, chords); | ||
| 404 | self.recording = None; | ||
| 405 | } | ||
| 406 | if self.recording.is_some() && ui::button(ui, "cancel", "Cancel").clicked { | ||
| 407 | self.recording = None; | ||
| 408 | } | ||
| 409 | } | ||
| 410 | ui.close(); | ||
| 411 | } | ||
| 412 | } | ||
| 413 | |||
| 414 | impl crate::State { | ||
| 415 | /// Gives a key pressed while Options records a chord to the recording. | ||
| 416 | pub(crate) fn record_chord(&mut self, event: &ui::Event) -> bool { | ||
| 417 | let (Some(options), ui::Event::Key { key, .. }) = (&mut self.options, event) else { | ||
| 418 | return false; | ||
| 419 | }; | ||
| 420 | // A field focused since recording began takes the keys instead. | ||
| 421 | if self.ui.focused().is_some() { | ||
| 422 | options.keyboard.recording = None; | ||
| 423 | return false; | ||
| 424 | } | ||
| 425 | let modifiers = ui::edit_modifiers(self.ui.modifiers()); | ||
| 426 | let taken = options.keyboard.key(&ui::edit_key(key), modifiers); | ||
| 427 | if taken { | ||
| 428 | self.window.request_redraw(); | ||
| 429 | } | ||
| 430 | taken | ||
| 431 | } | ||
| 432 | |||
| 433 | /// Whether Options is recording a chord, which the menu bar must then leave to it. | ||
| 434 | pub(crate) fn recording_chord(&self) -> bool { | ||
| 435 | self.options | ||
| 436 | .as_ref() | ||
| 437 | .is_some_and(|options| options.keyboard.recording()) | ||
| 438 | } | ||
| 439 | |||
| 440 | /// Puts `keymap` in use, and in the menu bar where it changes. | ||
| 441 | pub(crate) fn install_keymap(&self, keymap: Keymap) { | ||
| 442 | if keymap.install() { | ||
| 443 | crate::platform::install_menu(); | ||
| 444 | crate::platform::update_tag_menu(&self.tags); | ||
| 445 | } | ||
| 446 | } | ||
| 447 | } | ||
| 448 | |||
| 449 | #[cfg(test)] | ||
| 450 | mod tests { | ||
| 451 | use super::*; | ||
| 452 | use draw::edit::Modifiers; | ||
| 453 | |||
| 454 | fn shortcut() -> Modifiers { | ||
| 455 | Modifiers { | ||
| 456 | command: true, | ||
| 457 | control: Platform::CURRENT != Platform::MacOs, | ||
| 458 | ..Modifiers::default() | ||
| 459 | } | ||
| 460 | } | ||
| 461 | |||
| 462 | fn typed(keyboard: &mut Keyboard, key: &str, modifiers: Modifiers) { | ||
| 463 | assert!(keyboard.key(&Key::Character(key.into()), modifiers)); | ||
| 464 | } | ||
| 465 | |||
| 466 | fn recording(id: Command, replacing: Option<usize>) -> Keyboard { | ||
| 467 | Keyboard { | ||
| 468 | keymap: Keymap::default(), | ||
| 469 | recording: Some(Recording { | ||
| 470 | command: id, | ||
| 471 | replacing, | ||
| 472 | refused: None, | ||
| 473 | }), | ||
| 474 | } | ||
| 475 | } | ||
| 476 | |||
| 477 | #[test] | ||
| 478 | fn a_recorded_chord_replaces_adds_or_swaps_and_refuses_typing() { | ||
| 479 | let bold = Command::Toggle(Toggle::Bold); | ||
| 480 | let italic = Command::Toggle(Toggle::Italic); | ||
| 481 | let platform = Platform::CURRENT; | ||
| 482 | let mut keyboard = recording(Command::PageList, None); | ||
| 483 | typed(&mut keyboard, "j", Modifiers::default()); | ||
| 484 | assert!(matches!( | ||
| 485 | keyboard.recording.as_ref().unwrap().refused, | ||
| 486 | Some(Refusal::Unusable(_, Unusable::Typing)) | ||
| 487 | )); | ||
| 488 | typed(&mut keyboard, "j", shortcut()); | ||
| 489 | assert!(!keyboard.recording()); | ||
| 490 | let chord = commands::cmd('j'); | ||
| 491 | assert_eq!(keyboard.keymap.chords(Command::PageList, platform), [chord]); | ||
| 492 | assert_eq!( | ||
| 493 | keyboard.keymap.ran(chord, platform), | ||
| 494 | Some(Command::PageList) | ||
| 495 | ); | ||
| 496 | |||
| 497 | // Bold's chord, taken from Italic, which gets Bold's in exchange. | ||
| 498 | let mut keyboard = recording(bold, Some(0)); | ||
| 499 | typed(&mut keyboard, "i", shortcut()); | ||
| 500 | assert!(matches!( | ||
| 501 | keyboard.recording.as_ref().unwrap().refused, | ||
| 502 | Some(Refusal::Taken(_, owner)) if owner == italic | ||
| 503 | )); | ||
| 504 | keyboard.take(commands::cmd('i'), italic, true); | ||
| 505 | assert_eq!(keyboard.keymap.chords(bold, platform), [commands::cmd('i')]); | ||
| 506 | assert_eq!( | ||
| 507 | keyboard.keymap.chords(italic, platform), | ||
| 508 | [commands::cmd('b')] | ||
| 509 | ); | ||
| 510 | |||
| 511 | // A tag's chord, which the tag then goes without. | ||
| 512 | let mut keyboard = recording(Command::PageList, None); | ||
| 513 | typed(&mut keyboard, "1", shortcut()); | ||
| 514 | keyboard.take(commands::cmd('1'), Command::Tag(0), false); | ||
| 515 | assert_eq!( | ||
| 516 | keyboard.keymap.ran(commands::cmd('1'), platform), | ||
| 517 | Some(Command::PageList) | ||
| 518 | ); | ||
| 519 | |||
| 520 | // Chords set back to the defaults keep nothing. | ||
| 521 | let mut keyboard = recording(bold, Some(0)); | ||
| 522 | typed(&mut keyboard, "b", shortcut()); | ||
| 523 | assert_eq!(keyboard.keymap, Keymap::default()); | ||
| 524 | } | ||
| 525 | |||
| 526 | #[test] | ||
| 527 | fn every_command_falls_under_a_menu() { | ||
| 528 | assert_eq!(group(Command::Settings), "Snowbound"); | ||
| 529 | assert_eq!(group(Command::Paste), "Edit"); | ||
| 530 | assert_eq!(group(Command::Sidebar), "View"); | ||
| 531 | assert_eq!(group(Command::Toggle(Toggle::Italic)), "Format"); | ||
| 532 | assert_eq!(group(Command::CheckForUpdates), "Help"); | ||
| 533 | } | ||
| 534 | } | ||
crates/snowbound/src/library.rs+324-19| ... | @@ -3,6 +3,7 @@ | ... | @@ -3,6 +3,7 @@ |
| 3 | use canvas::{editor::NoteTag, gpu::TagArt}; | 3 | use canvas::{editor::NoteTag, gpu::TagArt}; |
| 4 | use notebook::discover::{Folder, SectionState}; | 4 | use notebook::discover::{Folder, SectionState}; |
| 5 | use notebook::session::{Background, Notebook, Section}; | 5 | use notebook::session::{Background, Notebook, Section}; |
| 6 | use notebook::sidecar::themes::Themes; | ||
| 6 | use notebook::smb::{Client, Credentials}; | 7 | use notebook::smb::{Client, Credentials}; |
| 7 | use std::{ | 8 | use std::{ |
| 8 | error::Error, | 9 | error::Error, |
| ... | @@ -312,6 +313,11 @@ pub struct Library { | ... | @@ -312,6 +313,11 @@ pub struct Library { |
| 312 | watch: Option<Arc<crate::watch::Watch>>, | 313 | watch: Option<Arc<crate::watch::Watch>>, |
| 313 | /// The art the notebook's tags draw with, as its `.snowbound` folder maps them. | 314 | /// The art the notebook's tags draw with, as its `.snowbound` folder maps them. |
| 314 | tag_art: Mutex<Arc<TagArt>>, | 315 | tag_art: Mutex<Arc<TagArt>>, |
| 316 | /// Sections opened ahead of being shown, or left open after, most recent first, which | ||
| 317 | /// `open` hands out before opening any. | ||
| 318 | kept: Mutex<crate::prefetch::Recent<String, Section>>, | ||
| 319 | /// The notebook's style themes, and when they were read. | ||
| 320 | themes: Mutex<Option<(Instant, Arc<Themes>)>>, | ||
| 315 | } | 321 | } |
| 316 | 322 | ||
| 317 | impl Library { | 323 | impl Library { |
| ... | @@ -329,7 +335,7 @@ impl Library { | ... | @@ -329,7 +335,7 @@ impl Library { |
| 329 | .and_then(|login| Self::on_share(location, mount.clone(), login, cache)) | 335 | .and_then(|login| Self::on_share(location, mount.clone(), login, cache)) |
| 330 | .unwrap_or_else(|reason| Self { | 336 | .unwrap_or_else(|reason| Self { |
| 331 | location: location.to_owned(), | 337 | location: location.to_owned(), |
| 332 | name: mount.name(), | 338 | name: display_name(cache, location).unwrap_or_else(|| mount.name()), |
| 333 | notebook: Err(reason), | 339 | notebook: Err(reason), |
| 334 | server: None, | 340 | server: None, |
| 335 | notice: None, | 341 | notice: None, |
| ... | @@ -337,6 +343,8 @@ impl Library { | ... | @@ -337,6 +343,8 @@ impl Library { |
| 337 | background: None, | 343 | background: None, |
| 338 | watch: None, | 344 | watch: None, |
| 339 | tag_art: Default::default(), | 345 | tag_art: Default::default(), |
| 346 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | ||
| 347 | themes: Default::default(), | ||
| 340 | }); | 348 | }); |
| 341 | } | 349 | } |
| 342 | let mut notice = None; | 350 | let mut notice = None; |
| ... | @@ -358,12 +366,14 @@ impl Library { | ... | @@ -358,12 +366,14 @@ impl Library { |
| 358 | }; | 366 | }; |
| 359 | Self { | 367 | Self { |
| 360 | location: location.to_owned(), | 368 | location: location.to_owned(), |
| 361 | name: file_name(Path::new(location)), | 369 | name: display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))), |
| 362 | background, | 370 | background, |
| 363 | watch, | 371 | watch, |
| 364 | tag_art: Mutex::new(Arc::new( | 372 | tag_art: Mutex::new(Arc::new( |
| 365 | notebook.as_ref().map(read_tag_art).unwrap_or_default(), | 373 | notebook.as_ref().map(read_tag_art).unwrap_or_default(), |
| 366 | )), | 374 | )), |
| 375 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | ||
| 376 | themes: Default::default(), | ||
| 367 | notebook: notebook.map(Some).map_err(|error| error.to_string()), | 377 | notebook: notebook.map(Some).map_err(|error| error.to_string()), |
| 368 | server: None, | 378 | server: None, |
| 369 | notice, | 379 | notice, |
| ... | @@ -397,11 +407,13 @@ impl Library { | ... | @@ -397,11 +407,13 @@ impl Library { |
| 397 | background.watch(notebook.replicas()); | 407 | background.watch(notebook.replicas()); |
| 398 | Ok(Self { | 408 | Ok(Self { |
| 399 | location: location.to_owned(), | 409 | location: location.to_owned(), |
| 400 | name: match server_address(location) { | 410 | name: display_name(cache, location).unwrap_or_else(|| match server_address(location) { |
| 401 | Some(_) => server.mount.name(), | 411 | Some(_) => server.mount.name(), |
| 402 | None => file_name(Path::new(location)), | 412 | None => file_name(Path::new(location)), |
| 403 | }, | 413 | }), |
| 404 | tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))), | 414 | tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))), |
| 415 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | ||
| 416 | themes: Default::default(), | ||
| 405 | notebook: Ok(Some(notebook)), | 417 | notebook: Ok(Some(notebook)), |
| 406 | server: Some(server), | 418 | server: Some(server), |
| 407 | notice: None, | 419 | notice: None, |
| ... | @@ -411,6 +423,17 @@ impl Library { | ... | @@ -411,6 +423,17 @@ impl Library { |
| 411 | }) | 423 | }) |
| 412 | } | 424 | } |
| 413 | 425 | ||
| 426 | /// Names the notebook `name` on this computer, leaving its folder as it is, as OneNote | ||
| 427 | /// 2010's Notebook Properties does; the notebook read again with `with` shows it. | ||
| 428 | pub fn set_display_name(&self, name: &str) -> io::Result<()> { | ||
| 429 | let file = display_names(&self.cache); | ||
| 430 | let mut names = read_display_names(&file); | ||
| 431 | names.insert(self.location.clone(), name.to_owned()); | ||
| 432 | let partial = file.with_extension("partial"); | ||
| 433 | std::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; | ||
| 434 | std::fs::rename(partial, file) | ||
| 435 | } | ||
| 436 | |||
| 414 | /// The art the notebook's tags draw with. | 437 | /// The art the notebook's tags draw with. |
| 415 | pub fn tag_art(&self) -> Arc<TagArt> { | 438 | pub fn tag_art(&self) -> Arc<TagArt> { |
| 416 | Arc::clone( | 439 | Arc::clone( |
| ... | @@ -455,6 +478,79 @@ impl Library { | ... | @@ -455,6 +478,79 @@ impl Library { |
| 455 | }); | 478 | }); |
| 456 | } | 479 | } |
| 457 | 480 | ||
| 481 | /// The notebook's style themes, read again after a while so other machines' changes | ||
| 482 | /// reach pages opened later; a section opened on its own has none. | ||
| 483 | pub fn themes(&self) -> Arc<Themes> { | ||
| 484 | let mut kept = self | ||
| 485 | .themes | ||
| 486 | .lock() | ||
| 487 | .unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
| 488 | if let Some((read, themes)) = &*kept | ||
| 489 | && read.elapsed() < Duration::from_secs(30) | ||
| 490 | { | ||
| 491 | return Arc::clone(themes); | ||
| 492 | } | ||
| 493 | let themes = match &self.notebook { | ||
| 494 | Ok(Some(notebook)) => notebook.themes().unwrap_or_else(|error| { | ||
| 495 | eprintln!("{}: reading its themes failed: {error}", self.location); | ||
| 496 | Themes::default() | ||
| 497 | }), | ||
| 498 | _ => Themes::default(), | ||
| 499 | }; | ||
| 500 | let themes = Arc::new(themes); | ||
| 501 | *kept = Some((Instant::now(), Arc::clone(&themes))); | ||
| 502 | themes | ||
| 503 | } | ||
| 504 | |||
| 505 | /// Takes `change` into the notebook's themes from now on, and keeps it in the notebook's | ||
| 506 | /// folder on a thread of its own. | ||
| 507 | pub fn save_themes(self: &Arc<Self>, change: Themes) { | ||
| 508 | if !matches!(self.notebook, Ok(Some(_))) { | ||
| 509 | return; | ||
| 510 | } | ||
| 511 | let mut themes = (*self.themes()).clone(); | ||
| 512 | notebook::sidecar::themes::merge(&mut themes, change.clone()); | ||
| 513 | *self | ||
| 514 | .themes | ||
| 515 | .lock() | ||
| 516 | .unwrap_or_else(|poisoned| poisoned.into_inner()) = | ||
| 517 | Some((Instant::now(), Arc::new(themes))); | ||
| 518 | let library = Arc::clone(self); | ||
| 519 | std::thread::spawn(move || { | ||
| 520 | let kept = library | ||
| 521 | .reopen() | ||
| 522 | .and_then(|notebook| Ok(notebook.save_themes(change)?)); | ||
| 523 | if let Err(error) = kept { | ||
| 524 | eprintln!("{}: keeping its themes failed: {error}", library.location); | ||
| 525 | } | ||
| 526 | }); | ||
| 527 | } | ||
| 528 | |||
| 529 | /// Deletes for good, on a thread of its own, what the notebook's recycle bin has held | ||
| 530 | /// unchanged past OneNote's 60 days, as OneNote 2010 prunes it a while after opening. | ||
| 531 | pub fn purge_recycle_bin(self: &Arc<Self>) { | ||
| 532 | if !matches!(self.notebook, Ok(Some(_))) { | ||
| 533 | return; | ||
| 534 | } | ||
| 535 | let library = Arc::clone(self); | ||
| 536 | std::thread::spawn(move || { | ||
| 537 | let now = std::time::SystemTime::now() | ||
| 538 | .duration_since(std::time::UNIX_EPOCH) | ||
| 539 | .map_or(0, |unix| unix.as_secs()) | ||
| 540 | // Time32 counts from 1980. | ||
| 541 | .saturating_sub(315_532_800); | ||
| 542 | let purged = library.reopen().and_then(|mut notebook| { | ||
| 543 | Ok(notebook.purge_recycle_bin(u32::try_from(now).unwrap_or(u32::MAX))?) | ||
| 544 | }); | ||
| 545 | if let Err(error) = purged { | ||
| 546 | eprintln!( | ||
| 547 | "{}: emptying the recycle bin failed: {error}", | ||
| 548 | library.location | ||
| 549 | ); | ||
| 550 | } | ||
| 551 | }); | ||
| 552 | } | ||
| 553 | |||
| 458 | /// This notebook read again, the way it was opened, for changing its structure. | 554 | /// This notebook read again, the way it was opened, for changing its structure. |
| 459 | pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> { | 555 | pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> { |
| 460 | Ok(match &self.server { | 556 | Ok(match &self.server { |
| ... | @@ -482,7 +578,7 @@ impl Library { | ... | @@ -482,7 +578,7 @@ impl Library { |
| 482 | }; | 578 | }; |
| 483 | Self { | 579 | Self { |
| 484 | location: self.location.clone(), | 580 | location: self.location.clone(), |
| 485 | name: self.name.clone(), | 581 | name: display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()), |
| 486 | notebook: Ok(Some(notebook)), | 582 | notebook: Ok(Some(notebook)), |
| 487 | server: self.server.clone(), | 583 | server: self.server.clone(), |
| 488 | notice: self.notice.clone(), | 584 | notice: self.notice.clone(), |
| ... | @@ -490,6 +586,8 @@ impl Library { | ... | @@ -490,6 +586,8 @@ impl Library { |
| 490 | background: self.background.clone(), | 586 | background: self.background.clone(), |
| 491 | watch, | 587 | watch, |
| 492 | tag_art: Mutex::new(self.tag_art()), | 588 | tag_art: Mutex::new(self.tag_art()), |
| 589 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | ||
| 590 | themes: Default::default(), | ||
| 493 | } | 591 | } |
| 494 | } | 592 | } |
| 495 | 593 | ||
| ... | @@ -502,6 +600,8 @@ impl Library { | ... | @@ -502,6 +600,8 @@ impl Library { |
| 502 | background, | 600 | background, |
| 503 | watch, | 601 | watch, |
| 504 | tag_art: Default::default(), | 602 | tag_art: Default::default(), |
| 603 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | ||
| 604 | themes: Default::default(), | ||
| 505 | notebook: Ok(Some(notebook)), | 605 | notebook: Ok(Some(notebook)), |
| 506 | server: None, | 606 | server: None, |
| 507 | notice: None, | 607 | notice: None, |
| ... | @@ -521,6 +621,8 @@ impl Library { | ... | @@ -521,6 +621,8 @@ impl Library { |
| 521 | background: None, | 621 | background: None, |
| 522 | watch: None, | 622 | watch: None, |
| 523 | tag_art: Default::default(), | 623 | tag_art: Default::default(), |
| 624 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | ||
| 625 | themes: Default::default(), | ||
| 524 | } | 626 | } |
| 525 | } | 627 | } |
| 526 | 628 | ||
| ... | @@ -533,7 +635,16 @@ impl Library { | ... | @@ -533,7 +635,16 @@ impl Library { |
| 533 | ) -> Result<Section, Box<dyn Error>> { | 635 | ) -> Result<Section, Box<dyn Error>> { |
| 534 | // The background may hold the replica for a step, which takes a network round trip, | 636 | // The background may hold the replica for a step, which takes a network round trip, |
| 535 | // or be making it as an offline copy. | 637 | // or be making it as an offline copy. |
| 536 | let deadline = Instant::now() + TIMEOUT * 3; | 638 | self.open_by(path, notify, Instant::now() + TIMEOUT * 3) |
| 639 | } | ||
| 640 | |||
| 641 | /// `open`, waiting until `deadline` for whatever holds the section's replica. | ||
| 642 | fn open_by( | ||
| 643 | &self, | ||
| 644 | path: &str, | ||
| 645 | notify: impl Fn() + Send + 'static, | ||
| 646 | deadline: Instant, | ||
| 647 | ) -> Result<Section, Box<dyn Error>> { | ||
| 537 | let notify = Arc::new(Mutex::new(notify)); | 648 | let notify = Arc::new(Mutex::new(notify)); |
| 538 | let notifier = || { | 649 | let notifier = || { |
| 539 | let notify = Arc::clone(&notify); | 650 | let notify = Arc::clone(&notify); |
| ... | @@ -544,6 +655,14 @@ impl Library { | ... | @@ -544,6 +655,14 @@ impl Library { |
| 544 | } | 655 | } |
| 545 | }; | 656 | }; |
| 546 | let section = loop { | 657 | let section = loop { |
| 658 | if let Some(kept) = self | ||
| 659 | .kept | ||
| 660 | .lock() | ||
| 661 | .ok() | ||
| 662 | .and_then(|mut kept| kept.take(&path.to_owned())) | ||
| 663 | { | ||
| 664 | return Ok(kept); | ||
| 665 | } | ||
| 547 | let opened = match (&self.notebook, &self.server) { | 666 | let opened = match (&self.notebook, &self.server) { |
| 548 | (Ok(Some(notebook)), Some(server)) => { | 667 | (Ok(Some(notebook)), Some(server)) => { |
| 549 | let file = match server.mount.root.as_str() { | 668 | let file = match server.mount.root.as_str() { |
| ... | @@ -567,7 +686,7 @@ impl Library { | ... | @@ -567,7 +686,7 @@ impl Library { |
| 567 | }) | 686 | }) |
| 568 | } | 687 | } |
| 569 | (Ok(Some(notebook)), None) if self.in_icloud() => { | 688 | (Ok(Some(notebook)), None) if self.in_icloud() => { |
| 570 | crate::icloud::section(notebook, path, notifier()) | 689 | crate::icloud::section(notebook, Path::new(&self.location), path, notifier()) |
| 571 | } | 690 | } |
| 572 | (Ok(Some(notebook)), None) => notebook.section(path, notifier()), | 691 | (Ok(Some(notebook)), None) => notebook.section(path, notifier()), |
| 573 | (Ok(None), _) if self.in_icloud() => { | 692 | (Ok(None), _) if self.in_icloud() => { |
| ... | @@ -595,6 +714,49 @@ impl Library { | ... | @@ -595,6 +714,49 @@ impl Library { |
| 595 | Ok(section) | 714 | Ok(section) |
| 596 | } | 715 | } |
| 597 | 716 | ||
| 717 | /// Opens the section at catalog `path`, unless it is kept open already, and keeps it | ||
| 718 | /// open for `open` to hand out. A section held elsewhere, as the one shown is, is left. | ||
| 719 | pub fn prefetch( | ||
| 720 | &self, | ||
| 721 | path: &str, | ||
| 722 | notify: impl Fn() + Send + 'static, | ||
| 723 | ) -> Result<Arc<notebook::Replica>, Box<dyn Error>> { | ||
| 724 | let section = self.open_by(path, notify, Instant::now())?; | ||
| 725 | let replica = Arc::clone(section.replica()); | ||
| 726 | self.keep(path, section); | ||
| 727 | Ok(replica) | ||
| 728 | } | ||
| 729 | |||
| 730 | /// Keeps `section`, at catalog `path`, open for `open` to hand out; the least recent | ||
| 731 | /// past `KEPT` close. | ||
| 732 | pub fn keep(&self, path: &str, section: Section) { | ||
| 733 | let gone = match self.kept.lock() { | ||
| 734 | Ok(mut kept) => kept.put(path.to_owned(), section), | ||
| 735 | Err(_) => vec![section], | ||
| 736 | }; | ||
| 737 | if !gone.is_empty() { | ||
| 738 | std::thread::spawn(move || close(gone)); | ||
| 739 | } | ||
| 740 | } | ||
| 741 | |||
| 742 | /// Works offline, or online again, in the background and in the sections kept open. | ||
| 743 | pub fn set_offline(&self, offline: bool) { | ||
| 744 | if let Some(background) = &self.background { | ||
| 745 | background.set_offline(offline); | ||
| 746 | } | ||
| 747 | if let Ok(mut kept) = self.kept.lock() { | ||
| 748 | kept.values_mut() | ||
| 749 | .for_each(|section| section.set_offline(offline)); | ||
| 750 | } | ||
| 751 | } | ||
| 752 | |||
| 753 | /// Closes the sections kept open, before the notebook's files change. | ||
| 754 | pub fn close_kept(&self) { | ||
| 755 | if let Ok(mut kept) = self.kept.lock() { | ||
| 756 | close(kept.trim(0, |_| 1)); | ||
| 757 | } | ||
| 758 | } | ||
| 759 | |||
| 598 | /// Where `file`, a section file of this notebook, sits on this computer: under the | 760 | /// Where `file`, a section file of this notebook, sits on this computer: under the |
| 599 | /// mount it was opened from when the embedded client holds it share-relative. | 761 | /// mount it was opened from when the embedded client holds it share-relative. |
| 600 | pub fn local(&self, file: &Path) -> Option<PathBuf> { | 762 | pub fn local(&self, file: &Path) -> Option<PathBuf> { |
| ... | @@ -619,23 +781,60 @@ impl Library { | ... | @@ -619,23 +781,60 @@ impl Library { |
| 619 | self.folder().is_some_and(crate::icloud::ubiquitous) | 781 | self.folder().is_some_and(crate::icloud::ubiquitous) |
| 620 | } | 782 | } |
| 621 | 783 | ||
| 622 | /// How the notebook's files are reached. | 784 | /// Where the notebook lives, as its reader knows the place: the server and share, iCloud |
| 623 | pub fn transport(&self) -> String { | 785 | /// Drive, a drive or this computer, then each folder down to the notebook's, left out. |
| 624 | match (&self.server, &self.notice) { | 786 | pub fn place(&self) -> Vec<String> { |
| 625 | (Some(server), _) => format!( | 787 | let mut place: Vec<String> = match self |
| 626 | "Snowbound’s SMB client, {}/{}", | 788 | .server |
| 627 | server.mount.server, server.mount.share | 789 | .as_ref() |
| 628 | ), | 790 | .map(|server| server.mount.clone()) |
| 629 | (None, Some(_)) => "The system’s mount of the share".to_owned(), | 791 | .or_else(|| server_address(&self.location)) |
| 630 | (None, None) if self.in_icloud() => "iCloud Drive".to_owned(), | 792 | { |
| 631 | (None, None) => "Folder on this computer".to_owned(), | 793 | Some(mount) => { |
| 632 | } | 794 | let host = bonjour_instance(&mount.server) |
| 795 | .unwrap_or_else(|| mount.host().trim_end_matches(".local").to_owned()); | ||
| 796 | [host, mount.share.clone()] | ||
| 797 | .into_iter() | ||
| 798 | .chain(mount.root.split('/').map(str::to_owned)) | ||
| 799 | .filter(|part| !part.is_empty()) | ||
| 800 | .collect() | ||
| 801 | } | ||
| 802 | None => { | ||
| 803 | let path = Path::new(&self.location); | ||
| 804 | let icloud = self.in_icloud(); | ||
| 805 | let this = if cfg!(target_os = "macos") { | ||
| 806 | "This Mac" | ||
| 807 | } else { | ||
| 808 | "This Computer" | ||
| 809 | }; | ||
| 810 | let tops = [ | ||
| 811 | ( | ||
| 812 | icloud.then(crate::icloud::folder).flatten(), | ||
| 813 | "iCloud Drive/Snowbound", | ||
| 814 | ), | ||
| 815 | (icloud.then(crate::icloud::drive).flatten(), "iCloud Drive"), | ||
| 816 | (std::env::var_os("HOME").map(PathBuf::from), this), | ||
| 817 | (Some(PathBuf::from("/Volumes")), ""), | ||
| 818 | ]; | ||
| 819 | let (top, rest) = tops | ||
| 820 | .into_iter() | ||
| 821 | .find_map(|(top, name)| Some((name, path.strip_prefix(top?).ok()?))) | ||
| 822 | .unwrap_or((this, path)); | ||
| 823 | top.split('/') | ||
| 824 | .map(str::to_owned) | ||
| 825 | .chain(rest.iter().map(|part| part.to_string_lossy().into_owned())) | ||
| 826 | .filter(|part| !part.is_empty() && part != "/") | ||
| 827 | .collect() | ||
| 828 | } | ||
| 829 | }; | ||
| 830 | place.pop(); | ||
| 831 | place | ||
| 633 | } | 832 | } |
| 634 | 833 | ||
| 635 | /// Names the section at catalog `path` across every open notebook, for remembering | 834 | /// Names the section at catalog `path` across every open notebook, for remembering |
| 636 | /// its pages. | 835 | /// its pages. |
| 637 | pub fn key(&self, path: &str) -> String { | 836 | pub fn key(&self, path: &str) -> String { |
| 638 | format!("{}\n{path}", self.location) | 837 | key(&self.location, path) |
| 639 | } | 838 | } |
| 640 | 839 | ||
| 641 | /// The readable sections of the folder at catalog path `folder`, as tabs in its order. | 840 | /// The readable sections of the folder at catalog path `folder`, as tabs in its order. |
| ... | @@ -660,6 +859,21 @@ impl Library { | ... | @@ -660,6 +859,21 @@ impl Library { |
| 660 | } | 859 | } |
| 661 | } | 860 | } |
| 662 | 861 | ||
| 862 | /// The file identity of the section at catalog `path`. | ||
| 863 | pub fn section_identity(&self, path: &str) -> Option<[u8; 16]> { | ||
| 864 | let Ok(Some(notebook)) = &self.notebook else { | ||
| 865 | return None; | ||
| 866 | }; | ||
| 867 | let mut folders = vec![notebook.catalog()]; | ||
| 868 | while let Some(folder) = folders.pop() { | ||
| 869 | if let Some(section) = folder.sections.iter().find(|section| section.path == path) { | ||
| 870 | return Some(section.file_id); | ||
| 871 | } | ||
| 872 | folders.extend(&folder.groups); | ||
| 873 | } | ||
| 874 | None | ||
| 875 | } | ||
| 876 | |||
| 663 | /// The first readable section, searching groups after sections, as OneNote opens a | 877 | /// The first readable section, searching groups after sections, as OneNote opens a |
| 664 | /// notebook. | 878 | /// notebook. |
| 665 | pub fn first_section(&self) -> Option<String> { | 879 | pub fn first_section(&self) -> Option<String> { |
| ... | @@ -696,6 +910,31 @@ impl Library { | ... | @@ -696,6 +910,31 @@ impl Library { |
| 696 | false | 910 | false |
| 697 | } | 911 | } |
| 698 | 912 | ||
| 913 | /// The notebook's colour, COLORREF, as its table of contents holds it. | ||
| 914 | pub fn color(&self) -> Option<u32> { | ||
| 915 | self.catalog()?.toc.as_ref()?.color | ||
| 916 | } | ||
| 917 | |||
| 918 | /// Whether the notebook lists sections not on this device yet, as iCloud Drive keeps | ||
| 919 | /// them elsewhere. | ||
| 920 | pub fn downloading(&self) -> bool { | ||
| 921 | let Some(catalog) = self.catalog() else { | ||
| 922 | return false; | ||
| 923 | }; | ||
| 924 | let mut folders = vec![catalog]; | ||
| 925 | while let Some(folder) = folders.pop() { | ||
| 926 | if folder | ||
| 927 | .unavailable | ||
| 928 | .iter() | ||
| 929 | .any(|entry| entry.reason == notebook::discover::Reason::Evicted) | ||
| 930 | { | ||
| 931 | return true; | ||
| 932 | } | ||
| 933 | folders.extend(&folder.groups); | ||
| 934 | } | ||
| 935 | false | ||
| 936 | } | ||
| 937 | |||
| 699 | /// The notebook's folders of sections, for the sidebar. | 938 | /// The notebook's folders of sections, for the sidebar. |
| 700 | pub fn catalog(&self) -> Option<&Folder> { | 939 | pub fn catalog(&self) -> Option<&Folder> { |
| 701 | match &self.notebook { | 940 | match &self.notebook { |
| ... | @@ -715,6 +954,28 @@ pub fn server_address(location: &str) -> Option<Mount> { | ... | @@ -715,6 +954,28 @@ pub fn server_address(location: &str) -> Option<Mount> { |
| 715 | } | 954 | } |
| 716 | 955 | ||
| 717 | /// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. | 956 | /// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. |
| 957 | /// `Library::key` for the notebook at `location`, open or not. | ||
| 958 | pub fn key(location: &str, path: &str) -> String { | ||
| 959 | format!("{location}\n{path}") | ||
| 960 | } | ||
| 961 | |||
| 962 | /// Where this computer keeps the display names notebooks were given, by location: OneNote | ||
| 963 | /// 2010 keeps them in its local cache, never in the notebook. | ||
| 964 | fn display_names(cache: &Path) -> PathBuf { | ||
| 965 | cache.join("display-names.json") | ||
| 966 | } | ||
| 967 | |||
| 968 | fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String> { | ||
| 969 | std::fs::read(file) | ||
| 970 | .ok() | ||
| 971 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) | ||
| 972 | .unwrap_or_default() | ||
| 973 | } | ||
| 974 | |||
| 975 | fn display_name(cache: &Path, location: &str) -> Option<String> { | ||
| 976 | read_display_names(&display_names(cache)).remove(location) | ||
| 977 | } | ||
| 978 | |||
| 718 | pub fn recycle_bin(path: &str) -> bool { | 979 | pub fn recycle_bin(path: &str) -> bool { |
| 719 | path.rsplit('/').next() == Some("OneNote_RecycleBin") | 980 | path.rsplit('/').next() == Some("OneNote_RecycleBin") |
| 720 | } | 981 | } |
| ... | @@ -781,6 +1042,17 @@ fn watcher( | ... | @@ -781,6 +1042,17 @@ fn watcher( |
| 781 | .map(Arc::new) | 1042 | .map(Arc::new) |
| 782 | } | 1043 | } |
| 783 | 1044 | ||
| 1045 | /// How many sections a notebook keeps open besides the one shown. | ||
| 1046 | const KEPT: usize = 3; | ||
| 1047 | |||
| 1048 | fn close(sections: Vec<Section>) { | ||
| 1049 | for section in sections { | ||
| 1050 | if let Err(error) = section.close() { | ||
| 1051 | eprintln!("Synchronization stopped: {error}"); | ||
| 1052 | } | ||
| 1053 | } | ||
| 1054 | } | ||
| 1055 | |||
| 784 | pub fn file_name(path: &Path) -> String { | 1056 | pub fn file_name(path: &Path) -> String { |
| 785 | path.canonicalize() | 1057 | path.canonicalize() |
| 786 | .ok() | 1058 | .ok() |
| ... | @@ -890,6 +1162,37 @@ pub fn locate(path: &Path) -> Located { | ... | @@ -890,6 +1162,37 @@ pub fn locate(path: &Path) -> Located { |
| 890 | mod tests { | 1162 | mod tests { |
| 891 | use super::*; | 1163 | use super::*; |
| 892 | 1164 | ||
| 1165 | /// A section kept open is what `open` hands out next, without opening it again, and | ||
| 1166 | /// readying a section another holds leaves it to that one at once. | ||
| 1167 | #[test] | ||
| 1168 | fn kept_sections_open_at_once() { | ||
| 1169 | let root = std::env::temp_dir().join(format!("snowbound-kept-{}", std::process::id())); | ||
| 1170 | let _ = std::fs::remove_dir_all(&root); | ||
| 1171 | let notebook = root.join("Personal"); | ||
| 1172 | std::fs::create_dir_all(&notebook).unwrap(); | ||
| 1173 | let sample = | ||
| 1174 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/sample-notebook/Personal"); | ||
| 1175 | for entry in std::fs::read_dir(sample).unwrap() { | ||
| 1176 | let entry = entry.unwrap(); | ||
| 1177 | std::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap(); | ||
| 1178 | } | ||
| 1179 | let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache")); | ||
| 1180 | let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone()); | ||
| 1181 | let readied = library.prefetch(&first, || {}).unwrap(); | ||
| 1182 | let opened = library.open(&first, || {}).unwrap(); | ||
| 1183 | assert!(Arc::ptr_eq(&readied, opened.replica())); | ||
| 1184 | drop(readied); | ||
| 1185 | let start = Instant::now(); | ||
| 1186 | assert!(library.prefetch(&first, || {}).is_err()); | ||
| 1187 | assert!(start.elapsed() < Duration::from_secs(1)); | ||
| 1188 | library.keep(&first, opened); | ||
| 1189 | library.prefetch(&second, || {}).unwrap(); | ||
| 1190 | library.close_kept(); | ||
| 1191 | library.open(&first, || {}).unwrap().close().unwrap(); | ||
| 1192 | drop(library); | ||
| 1193 | std::fs::remove_dir_all(&root).unwrap(); | ||
| 1194 | } | ||
| 1195 | |||
| 893 | #[cfg(unix)] | 1196 | #[cfg(unix)] |
| 894 | #[test] | 1197 | #[test] |
| 895 | fn within_mount_resolves_symlinks() { | 1198 | fn within_mount_resolves_symlinks() { |
| ... | @@ -1051,6 +1354,8 @@ mod tests { | ... | @@ -1051,6 +1354,8 @@ mod tests { |
| 1051 | background: None, | 1354 | background: None, |
| 1052 | watch: None, | 1355 | watch: None, |
| 1053 | tag_art: Default::default(), | 1356 | tag_art: Default::default(), |
| 1357 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | ||
| 1358 | themes: Default::default(), | ||
| 1054 | }; | 1359 | }; |
| 1055 | let shown = |root, file| library(root).local(Path::new(file)); | 1360 | let shown = |root, file| library(root).local(Path::new(file)); |
| 1056 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); | 1361 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); |
crates/snowbound/src/linux.rs+6-1| ... | @@ -206,11 +206,16 @@ impl Clipboard { | ... | @@ -206,11 +206,16 @@ impl Clipboard { |
| 206 | .unwrap_or_default() | 206 | .unwrap_or_default() |
| 207 | } | 207 | } |
| 208 | 208 | ||
| 209 | pub fn get_html(&mut self) -> Option<String> { | ||
| 210 | self.through_x11(|clipboard| clipboard.get().html().ok()) | ||
| 211 | } | ||
| 212 | |||
| 209 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { | 213 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { |
| 210 | self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?)) | 214 | self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?)) |
| 211 | } | 215 | } |
| 212 | 216 | ||
| 213 | /// Files and pictures come through X11 on Wayland too, whose clipboard Xwayland shares. | 217 | /// Files, pages and pictures come through X11 on Wayland too, whose clipboard Xwayland |
| 218 | /// shares. | ||
| 214 | fn through_x11<T>( | 219 | fn through_x11<T>( |
| 215 | &mut self, | 220 | &mut self, |
| 216 | read: impl FnOnce(&mut arboard::Clipboard) -> Option<T>, | 221 | read: impl FnOnce(&mut arboard::Clipboard) -> Option<T>, |
crates/snowbound/src/macos.rs+33| ... | @@ -193,6 +193,10 @@ impl Clipboard { | ... | @@ -193,6 +193,10 @@ impl Clipboard { |
| 193 | self.0.get().file_list().unwrap_or_default() | 193 | self.0.get().file_list().unwrap_or_default() |
| 194 | } | 194 | } |
| 195 | 195 | ||
| 196 | pub fn get_html(&mut self) -> Option<String> { | ||
| 197 | self.0.get().html().ok() | ||
| 198 | } | ||
| 199 | |||
| 196 | /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG. | 200 | /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG. |
| 197 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { | 201 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { |
| 198 | unsafe { | 202 | unsafe { |
| ... | @@ -1300,9 +1304,38 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo | ... | @@ -1300,9 +1304,38 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo |
| 1300 | let event_loop = builder.build()?; | 1304 | let event_loop = builder.build()?; |
| 1301 | QUIT.set(event_loop.create_proxy()) | 1305 | QUIT.set(event_loop.create_proxy()) |
| 1302 | .expect("Only one application event loop is created"); | 1306 | .expect("Only one application event loop is created"); |
| 1307 | // Winit's delegate gains -application:openFiles:, which every macOS from 10.6 sends for | ||
| 1308 | // the Finder's and `open`'s documents, before the launch finishes on a cold launch. | ||
| 1309 | unsafe { | ||
| 1310 | let app = NSApplication::sharedApplication(MainThreadMarker::new_unchecked()); | ||
| 1311 | let delegate: Retained<AnyObject> = msg_send_id![&app, delegate]; | ||
| 1312 | let mut class = ClassBuilder::new("SnowboundApplicationDelegate", delegate.class()) | ||
| 1313 | .expect("Unique application delegate class"); | ||
| 1314 | class.add_method( | ||
| 1315 | sel!(application:openFiles:), | ||
| 1316 | open_files as unsafe extern "C" fn(_, _, _, _), | ||
| 1317 | ); | ||
| 1318 | let class = class.register(); | ||
| 1319 | // The subclass adds no ivars, so the existing allocation remains valid. | ||
| 1320 | AnyObject::set_class(&delegate, class); | ||
| 1321 | } | ||
| 1303 | Ok(event_loop) | 1322 | Ok(event_loop) |
| 1304 | } | 1323 | } |
| 1305 | 1324 | ||
| 1325 | unsafe extern "C" fn open_files( | ||
| 1326 | _: &AnyObject, | ||
| 1327 | _: Sel, | ||
| 1328 | app: &NSApplication, | ||
| 1329 | files: &objc2_foundation::NSArray<NSString>, | ||
| 1330 | ) { | ||
| 1331 | if let Some(proxy) = QUIT.get() { | ||
| 1332 | let paths = files.iter().map(|file| file.to_string().into()).collect(); | ||
| 1333 | let _ = proxy.send_event(crate::UserEvent::Open(paths)); | ||
| 1334 | } | ||
| 1335 | // NSApplicationDelegateReplySuccess. | ||
| 1336 | let _: () = unsafe { msg_send![app, replyToOpenOrPrint: 0usize] }; | ||
| 1337 | } | ||
| 1338 | |||
| 1306 | /// Replaces Winit's application menu with the menu bar, whose items the table validates. | 1339 | /// Replaces Winit's application menu with the menu bar, whose items the table validates. |
| 1307 | pub fn install_menu() { | 1340 | pub fn install_menu() { |
| 1308 | let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); | 1341 | let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); |
crates/snowbound/src/main.rs+580-87| ... | @@ -15,6 +15,9 @@ mod history; | ... | @@ -15,6 +15,9 @@ mod history; |
| 15 | #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] | 15 | #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] |
| 16 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] | 16 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] |
| 17 | mod icloud; | 17 | mod icloud; |
| 18 | #[cfg_attr(target_os = "macos", allow(dead_code))] | ||
| 19 | mod instance; | ||
| 20 | mod keys; | ||
| 18 | mod library; | 21 | mod library; |
| 19 | mod link; | 22 | mod link; |
| 20 | mod manage; | 23 | mod manage; |
| ... | @@ -35,7 +38,9 @@ mod paste; | ... | @@ -35,7 +38,9 @@ mod paste; |
| 35 | #[cfg_attr(target_os = "macos", path = "macos.rs")] | 38 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| 36 | #[cfg_attr(windows, path = "windows.rs")] | 39 | #[cfg_attr(windows, path = "windows.rs")] |
| 37 | mod platform; | 40 | mod platform; |
| 41 | mod prefetch; | ||
| 38 | mod print; | 42 | mod print; |
| 43 | mod properties; | ||
| 39 | #[cfg_attr(target_os = "linux", path = "print_linux.rs")] | 44 | #[cfg_attr(target_os = "linux", path = "print_linux.rs")] |
| 40 | #[cfg_attr(target_os = "macos", path = "print_macos.rs")] | 45 | #[cfg_attr(target_os = "macos", path = "print_macos.rs")] |
| 41 | #[cfg_attr(windows, path = "print_windows.rs")] | 46 | #[cfg_attr(windows, path = "print_windows.rs")] |
| ... | @@ -57,6 +62,8 @@ mod surface; | ... | @@ -57,6 +62,8 @@ mod surface; |
| 57 | mod sync; | 62 | mod sync; |
| 58 | mod tags; | 63 | mod tags; |
| 59 | mod templates; | 64 | mod templates; |
| 65 | mod themes; | ||
| 66 | mod undo; | ||
| 60 | mod update; | 67 | mod update; |
| 61 | mod video; | 68 | mod video; |
| 62 | mod watch; | 69 | mod watch; |
| ... | @@ -203,6 +210,12 @@ enum UserEvent { | ... | @@ -203,6 +210,12 @@ enum UserEvent { |
| 203 | /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off. | 210 | /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off. |
| 204 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] | 211 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] |
| 205 | ICloudAccount, | 212 | ICloudAccount, |
| 213 | /// The app's iCloud Drive folder was looked up, or a notebook came or went at its top. | ||
| 214 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] | ||
| 215 | ICloudFolder, | ||
| 216 | /// The system or a later launch asked to open notebook folders, tables of contents or | ||
| 217 | /// sections, as the Finder does with a double-clicked file; none brings the window forward. | ||
| 218 | Open(Vec<PathBuf>), | ||
| 206 | } | 219 | } |
| 207 | 220 | ||
| 208 | /// Asks the event loop for a frame from any thread. | 221 | /// Asks the event loop for a frame from any thread. |
| ... | @@ -228,6 +241,11 @@ impl Clipboard { | ... | @@ -228,6 +241,11 @@ impl Clipboard { |
| 228 | let files = clipboard.get_files(); | 241 | let files = clipboard.get_files(); |
| 229 | if !files.is_empty() { | 242 | if !files.is_empty() { |
| 230 | Some(paste::Pasted::Files(files)) | 243 | Some(paste::Pasted::Files(files)) |
| 244 | } else if let Some(html) = clipboard | ||
| 245 | .get_html() | ||
| 246 | .filter(|html| html.to_ascii_lowercase().contains("<img")) | ||
| 247 | { | ||
| 248 | Some(paste::Pasted::Page(html)) | ||
| 231 | } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty()) | 249 | } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty()) |
| 232 | { | 250 | { |
| 233 | Some(paste::Pasted::Text(text)) | 251 | Some(paste::Pasted::Text(text)) |
| ... | @@ -307,6 +325,8 @@ struct App { | ... | @@ -307,6 +325,8 @@ struct App { |
| 307 | screenshot: Option<PathBuf>, | 325 | screenshot: Option<PathBuf>, |
| 308 | /// What the window starts from besides its input, until it opens. | 326 | /// What the window starts from besides its input, until it opens. |
| 309 | launch: Option<settings::Launch>, | 327 | launch: Option<settings::Launch>, |
| 328 | /// Files and folders to open, as File, Open does, once the window opens. | ||
| 329 | opening: Vec<PathBuf>, | ||
| 310 | state: Option<State>, | 330 | state: Option<State>, |
| 311 | startup_error: Option<Box<dyn Error>>, | 331 | startup_error: Option<Box<dyn Error>>, |
| 312 | } | 332 | } |
| ... | @@ -330,6 +350,20 @@ enum Input { | ... | @@ -330,6 +350,20 @@ enum Input { |
| 330 | }, | 350 | }, |
| 331 | } | 351 | } |
| 332 | 352 | ||
| 353 | impl Input { | ||
| 354 | /// Lists the notebook folder `root` and shows it first, where the window shows notebooks. | ||
| 355 | fn show(&mut self, root: &Path) -> std::io::Result<()> { | ||
| 356 | if let Input::Notebooks { locations, current } = self { | ||
| 357 | let location = std::path::absolute(root)?.to_string_lossy().into_owned(); | ||
| 358 | if !locations.contains(&location) { | ||
| 359 | locations.push(location.clone()); | ||
| 360 | } | ||
| 361 | *current = Some(location); | ||
| 362 | } | ||
| 363 | Ok(()) | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 333 | /// Asks the event loop to poll sections when their synchronization reports. | 367 | /// Asks the event loop to poll sections when their synchronization reports. |
| 334 | fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static { | 368 | fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static { |
| 335 | move || { | 369 | move || { |
| ... | @@ -337,6 +371,13 @@ fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static { | ... | @@ -337,6 +371,13 @@ fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static { |
| 337 | } | 371 | } |
| 338 | } | 372 | } |
| 339 | 373 | ||
| 374 | /// Asks the event loop to list the app's iCloud Drive folder again. | ||
| 375 | fn icloud_listed(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + Sync + 'static { | ||
| 376 | move || { | ||
| 377 | let _ = proxy.send_event(UserEvent::ICloudFolder); | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 340 | /// The open section and the page the editor shows. | 381 | /// The open section and the page the editor shows. |
| 341 | struct Session { | 382 | struct Session { |
| 342 | section: notebook::session::Section, | 383 | section: notebook::session::Section, |
| ... | @@ -501,6 +542,7 @@ struct Opening { | ... | @@ -501,6 +542,7 @@ struct Opening { |
| 501 | scene: (PageScene, [f32; 2]), | 542 | scene: (PageScene, [f32; 2]), |
| 502 | editor: CanvasEditor, | 543 | editor: CanvasEditor, |
| 503 | since: Instant, | 544 | since: Instant, |
| 545 | requested: Instant, | ||
| 504 | } | 546 | } |
| 505 | 547 | ||
| 506 | /// How long an opening page waits for its pictures before showing without them. | 548 | /// How long an opening page waits for its pictures before showing without them. |
| ... | @@ -519,8 +561,8 @@ enum Command { | ... | @@ -519,8 +561,8 @@ enum Command { |
| 519 | OpenFromServer(Option<String>), | 561 | OpenFromServer(Option<String>), |
| 520 | /// Asks where to keep a new notebook and creates it. | 562 | /// Asks where to keep a new notebook and creates it. |
| 521 | NewNotebook, | 563 | NewNotebook, |
| 522 | /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none. | 564 | /// Asks for a name and creates a notebook at the top of the app's iCloud Drive folder. |
| 523 | UseICloud, | 565 | NewICloudNotebook, |
| 524 | /// Opens the Snowbound Guide from the user's documents, copying it there first. | 566 | /// Opens the Snowbound Guide from the user's documents, copying it there first. |
| 525 | OpenGuide, | 567 | OpenGuide, |
| 526 | /// Adds Snowbound to the app menu. | 568 | /// Adds Snowbound to the app menu. |
| ... | @@ -600,6 +642,9 @@ struct State { | ... | @@ -600,6 +642,9 @@ struct State { |
| 600 | /// The newest read requested; older ones are dropped when they finish. | 642 | /// The newest read requested; older ones are dropped when they finish. |
| 601 | loading: u64, | 643 | loading: u64, |
| 602 | opening: Option<Opening>, | 644 | opening: Option<Opening>, |
| 645 | /// The section tab, if another, that the newest read opens, and when it was asked for. | ||
| 646 | switching: Option<(Option<usize>, Instant)>, | ||
| 647 | prefetch: prefetch::Prefetch, | ||
| 603 | /// Where each page was left this run, by `Library::key` and page space, as OneNote | 648 | /// Where each page was left this run, by `Library::key` and page space, as OneNote |
| 604 | /// returns to it until the notebook closes. | 649 | /// returns to it until the notebook closes. |
| 605 | places: HashMap<(String, ExGuid), Place>, | 650 | places: HashMap<(String, ExGuid), Place>, |
| ... | @@ -607,6 +652,8 @@ struct State { | ... | @@ -607,6 +652,8 @@ struct State { |
| 607 | last_pages: HashMap<String, ExGuid>, | 652 | last_pages: HashMap<String, ExGuid>, |
| 608 | /// Pages visited, for Back and Forward. | 653 | /// Pages visited, for Back and Forward. |
| 609 | trail: navigation::Trail, | 654 | trail: navigation::Trail, |
| 655 | /// What Undo and Redo take back across pages and sections. | ||
| 656 | undo: undo::Timeline, | ||
| 610 | swipe: navigation::Swipe, | 657 | swipe: navigation::Swipe, |
| 611 | /// Asks for a frame when a worker thread finishes something the page shows. | 658 | /// Asks for a frame when a worker thread finishes something the page shows. |
| 612 | redraw: std::task::Waker, | 659 | redraw: std::task::Waker, |
| ... | @@ -629,13 +676,30 @@ struct State { | ... | @@ -629,13 +676,30 @@ struct State { |
| 629 | folded: HashSet<String>, | 676 | folded: HashSet<String>, |
| 630 | /// The open context menu: what it was opened on, and where. | 677 | /// The open context menu: what it was opened on, and where. |
| 631 | menu: Option<(menus::Target, [f32; 2])>, | 678 | menu: Option<(menus::Target, [f32; 2])>, |
| 679 | /// The notebook the sync status popup shows, opened from its context menu at a point; | ||
| 680 | /// `None` shows the open section's, below the toolbar's button. | ||
| 681 | sync_notebook: Option<(Arc<Library>, [f32; 2])>, | ||
| 682 | /// What the palette's actions menu is on while it is open. | ||
| 683 | actions: Option<menus::Target>, | ||
| 684 | /// An action on a page of another section, done once that section opens on the page. | ||
| 685 | after_open: Option<(ExGuid, menus::Action)>, | ||
| 632 | /// The Options dialog's choices while it is open. | 686 | /// The Options dialog's choices while it is open. |
| 633 | options: Option<options::Options>, | 687 | options: Option<options::Options>, |
| 688 | /// The Themes dialog while it is open. | ||
| 689 | themes: Option<themes::Dialog>, | ||
| 690 | /// Print Preview and Settings: the choices last made, and the dialog's while open. | ||
| 691 | printing: print::Printing, | ||
| 634 | updates: update::Updates, | 692 | updates: update::Updates, |
| 635 | /// The Link dialog's fields while it is open. | 693 | /// The Link dialog's fields while it is open. |
| 636 | link: Option<link::LinkDialog>, | 694 | link: Option<link::LinkDialog>, |
| 695 | properties: Option<properties::Properties>, | ||
| 637 | /// Open Notebook from Server while it is open. | 696 | /// Open Notebook from Server while it is open. |
| 638 | server: Option<server::Connect>, | 697 | server: Option<server::Connect>, |
| 698 | /// New iCloud Notebook while it is open. | ||
| 699 | new_icloud: Option<manage::Naming>, | ||
| 700 | /// Notebooks in iCloud Drive a thread is reading, or following the download of, by | ||
| 701 | /// location. | ||
| 702 | icloud_reading: HashSet<String>, | ||
| 639 | /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. | 703 | /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. |
| 640 | tags: Vec<canvas::editor::NoteTag>, | 704 | tags: Vec<canvas::editor::NoteTag>, |
| 641 | /// The Customize Tags dialog's list while it is open. | 705 | /// The Customize Tags dialog's list while it is open. |
| ... | @@ -649,8 +713,6 @@ struct State { | ... | @@ -649,8 +713,6 @@ struct State { |
| 649 | hide_spelling: bool, | 713 | hide_spelling: bool, |
| 650 | /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure. | 714 | /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure. |
| 651 | pen_pressure: bool, | 715 | pen_pressure: bool, |
| 652 | /// Options' Default font, which new text and titles take. | ||
| 653 | default_font: settings::DefaultFont, | ||
| 654 | /// Options' "Page tabs appear on the left". | 716 | /// Options' "Page tabs appear on the left". |
| 655 | page_tabs_left: bool, | 717 | page_tabs_left: bool, |
| 656 | /// Options' "Navigation bar appears on the left" off: the notebooks on the right. | 718 | /// Options' "Navigation bar appears on the left" off: the notebooks on the right. |
| ... | @@ -790,6 +852,7 @@ impl State { | ... | @@ -790,6 +852,7 @@ impl State { |
| 790 | platform::install_text_input(&window); | 852 | platform::install_text_input(&window); |
| 791 | // Beneath the surface's layer, which is added over it. | 853 | // Beneath the surface's layer, which is added over it. |
| 792 | let backdrop = visible && platform::install_backdrop(&window); | 854 | let backdrop = visible && platform::install_backdrop(&window); |
| 855 | commands::Keymap::from_saved(&stored.keys).install(); | ||
| 793 | platform::install_menu(); | 856 | platform::install_menu(); |
| 794 | let access_adapter = | 857 | let access_adapter = |
| 795 | accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); | 858 | accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); |
| ... | @@ -812,7 +875,7 @@ impl State { | ... | @@ -812,7 +875,7 @@ impl State { |
| 812 | icloud::on_account_change(move || { | 875 | icloud::on_account_change(move || { |
| 813 | let _ = account.send_event(UserEvent::ICloudAccount); | 876 | let _ = account.send_event(UserEvent::ICloudAccount); |
| 814 | }); | 877 | }); |
| 815 | icloud::look_up(notify(proxy.clone())); | 878 | icloud::look_up(icloud_listed(proxy.clone())); |
| 816 | let mut notebooks = Vec::new(); | 879 | let mut notebooks = Vec::new(); |
| 817 | let mut session = None; | 880 | let mut session = None; |
| 818 | let mut sectionless = None; | 881 | let mut sectionless = None; |
| ... | @@ -859,6 +922,10 @@ impl State { | ... | @@ -859,6 +922,10 @@ impl State { |
| 859 | if let Err(error) = &library.notebook { | 922 | if let Err(error) = &library.notebook { |
| 860 | eprintln!("Cannot open the notebook at {}: {error}", library.location); | 923 | eprintln!("Cannot open the notebook at {}: {error}", library.location); |
| 861 | } | 924 | } |
| 925 | // Drawn offscreen, the app leaves the notebooks as they are. | ||
| 926 | if visible { | ||
| 927 | library.purge_recycle_bin(); | ||
| 928 | } | ||
| 862 | } | 929 | } |
| 863 | let shown = notebooks | 930 | let shown = notebooks |
| 864 | .iter() | 931 | .iter() |
| ... | @@ -946,6 +1013,7 @@ impl State { | ... | @@ -946,6 +1013,7 @@ impl State { |
| 946 | let spelling = spell::dictionary() | 1013 | let spelling = spell::dictionary() |
| 947 | .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone())); | 1014 | .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone())); |
| 948 | let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone()); | 1015 | let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone()); |
| 1016 | let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone()); | ||
| 949 | let mut state = Self { | 1017 | let mut state = Self { |
| 950 | author: stored.user_name.unwrap_or_else(platform::user_name), | 1018 | author: stored.user_name.unwrap_or_else(platform::user_name), |
| 951 | window, | 1019 | window, |
| ... | @@ -972,14 +1040,22 @@ impl State { | ... | @@ -972,14 +1040,22 @@ impl State { |
| 972 | dismissed: HashSet::new(), | 1040 | dismissed: HashSet::new(), |
| 973 | title_focus: None, | 1041 | title_focus: None, |
| 974 | menu: None, | 1042 | menu: None, |
| 1043 | sync_notebook: None, | ||
| 1044 | actions: None, | ||
| 1045 | after_open: None, | ||
| 975 | link: None, | 1046 | link: None, |
| 1047 | properties: None, | ||
| 976 | server: None, | 1048 | server: None, |
| 1049 | new_icloud: None, | ||
| 1050 | icloud_reading: HashSet::new(), | ||
| 977 | tags: stored | 1051 | tags: stored |
| 978 | .tags | 1052 | .tags |
| 979 | .unwrap_or_else(canvas::editor::NoteTag::defaults), | 1053 | .unwrap_or_else(canvas::editor::NoteTag::defaults), |
| 980 | tag_list: None, | 1054 | tag_list: None, |
| 981 | text_menu: None, | 1055 | text_menu: None, |
| 982 | options: None, | 1056 | options: None, |
| 1057 | themes: None, | ||
| 1058 | printing: print::Printing::default(), | ||
| 983 | updates, | 1059 | updates, |
| 984 | color_scheme: stored.color_scheme, | 1060 | color_scheme: stored.color_scheme, |
| 985 | light_pages: stored.light_pages, | 1061 | light_pages: stored.light_pages, |
| ... | @@ -1016,12 +1092,19 @@ impl State { | ... | @@ -1016,12 +1092,19 @@ impl State { |
| 1016 | ime_allowed: true, | 1092 | ime_allowed: true, |
| 1017 | clipboard, | 1093 | clipboard, |
| 1018 | loads: mpsc::channel(), | 1094 | loads: mpsc::channel(), |
| 1095 | prefetch, | ||
| 1019 | layouts, | 1096 | layouts, |
| 1020 | loading: 0, | 1097 | loading: 0, |
| 1021 | opening: None, | 1098 | opening: None, |
| 1099 | switching: None, | ||
| 1022 | places: HashMap::new(), | 1100 | places: HashMap::new(), |
| 1023 | last_pages: HashMap::new(), | 1101 | last_pages: HashMap::new(), |
| 1024 | trail: navigation::Trail::default(), | 1102 | trail: { |
| 1103 | let mut trail = navigation::Trail::default(); | ||
| 1104 | trail.recent = stored.recent; | ||
| 1105 | trail | ||
| 1106 | }, | ||
| 1107 | undo: undo::Timeline::default(), | ||
| 1025 | swipe: navigation::Swipe::default(), | 1108 | swipe: navigation::Swipe::default(), |
| 1026 | access_adapter, | 1109 | access_adapter, |
| 1027 | accessibility: accessibility::Accessibility::default(), | 1110 | accessibility: accessibility::Accessibility::default(), |
| ... | @@ -1029,13 +1112,13 @@ impl State { | ... | @@ -1029,13 +1112,13 @@ impl State { |
| 1029 | spelling, | 1112 | spelling, |
| 1030 | hide_spelling: stored.hide_spelling, | 1113 | hide_spelling: stored.hide_spelling, |
| 1031 | pen_pressure: !stored.ignore_pen_pressure, | 1114 | pen_pressure: !stored.ignore_pen_pressure, |
| 1032 | default_font: stored.default_font, | ||
| 1033 | page_tabs_left: stored.page_tabs_left, | 1115 | page_tabs_left: stored.page_tabs_left, |
| 1034 | navigation_bar_right: stored.navigation_bar_right, | 1116 | navigation_bar_right: stored.navigation_bar_right, |
| 1035 | servers: stored.servers, | 1117 | servers: stored.servers, |
| 1036 | correction: None, | 1118 | correction: None, |
| 1037 | }; | 1119 | }; |
| 1038 | state.view.snap_to_grid = !stored.ignore_grid; | 1120 | state.view.snap_to_grid = !stored.ignore_grid; |
| 1121 | state.view.editor.default_font = stored.default_font; | ||
| 1039 | // A notebook opened from its server that couldn't sign in asks to, as the Finder does. | 1122 | // A notebook opened from its server that couldn't sign in asks to, as the Finder does. |
| 1040 | let unsigned = state.notebooks.iter().find(|library| { | 1123 | let unsigned = state.notebooks.iter().find(|library| { |
| 1041 | library.notebook.is_err() && library::server_address(&library.location).is_some() | 1124 | library.notebook.is_err() && library::server_address(&library.location).is_some() |
| ... | @@ -1044,6 +1127,7 @@ impl State { | ... | @@ -1044,6 +1127,7 @@ impl State { |
| 1044 | state.commands.push(Command::OpenFromServer(Some(location))); | 1127 | state.commands.push(Command::OpenFromServer(Some(location))); |
| 1045 | } | 1128 | } |
| 1046 | state.visited(); | 1129 | state.visited(); |
| 1130 | state.prefetch_around(); | ||
| 1047 | state.show_spelling(); | 1131 | state.show_spelling(); |
| 1048 | state.title(); | 1132 | state.title(); |
| 1049 | platform::update_tag_menu(&state.tags); | 1133 | platform::update_tag_menu(&state.tags); |
| ... | @@ -1090,7 +1174,10 @@ impl State { | ... | @@ -1090,7 +1174,10 @@ impl State { |
| 1090 | lap("drawn", start); | 1174 | lap("drawn", start); |
| 1091 | self.sync_index(false, Vec::new()); | 1175 | self.sync_index(false, Vec::new()); |
| 1092 | let commands = std::mem::take(&mut self.commands); | 1176 | let commands = std::mem::take(&mut self.commands); |
| 1093 | let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some(); | 1177 | let follow = !commands.is_empty() |
| 1178 | || self.ui.wants_frame() | ||
| 1179 | || self.opening.is_some() | ||
| 1180 | || self.switching.is_some(); | ||
| 1094 | for command in commands { | 1181 | for command in commands { |
| 1095 | self.apply(command)?; | 1182 | self.apply(command)?; |
| 1096 | } | 1183 | } |
| ... | @@ -1118,8 +1205,12 @@ impl State { | ... | @@ -1118,8 +1205,12 @@ impl State { |
| 1118 | platform::cover_border_line(&mut self.ui, size[0]); | 1205 | platform::cover_border_line(&mut self.ui, size[0]); |
| 1119 | let (section, open_tab, open_page) = self.build()?; | 1206 | let (section, open_tab, open_page) = self.build()?; |
| 1120 | self.options_dialog(); | 1207 | self.options_dialog(); |
| 1208 | self.themes_dialog(); | ||
| 1209 | self.print_dialog(); | ||
| 1121 | self.server_dialog(); | 1210 | self.server_dialog(); |
| 1211 | self.new_icloud_dialog(); | ||
| 1122 | self.link_dialog()?; | 1212 | self.link_dialog()?; |
| 1213 | self.properties_dialog(); | ||
| 1123 | self.customize_tags(); | 1214 | self.customize_tags(); |
| 1124 | self.palette(); | 1215 | self.palette(); |
| 1125 | self.sync_popup()?; | 1216 | self.sync_popup()?; |
| ... | @@ -1185,7 +1276,8 @@ impl State { | ... | @@ -1185,7 +1276,8 @@ impl State { |
| 1185 | let target = self.ui.theme.section(section_color( | 1276 | let target = self.ui.theme.section(section_color( |
| 1186 | self.session | 1277 | self.session |
| 1187 | .as_ref() | 1278 | .as_ref() |
| 1188 | .and_then(|session| session.tabs[session.tab].color), | 1279 | .zip(self.open_tab()) |
| 1280 | .and_then(|(session, tab)| session.tabs[tab].color), | ||
| 1189 | )); | 1281 | )); |
| 1190 | self.ui.icon_palette = self.ui.theme.icon_palette( | 1282 | self.ui.icon_palette = self.ui.theme.icon_palette( |
| 1191 | target.accent, | 1283 | target.accent, |
| ... | @@ -1235,7 +1327,12 @@ impl State { | ... | @@ -1235,7 +1327,12 @@ impl State { |
| 1235 | ..Spec::default() | 1327 | ..Spec::default() |
| 1236 | }, | 1328 | }, |
| 1237 | ); | 1329 | ); |
| 1238 | let sidebar = self.sidebar(&theme); | 1330 | let sidebar = self.sidebar_width(); |
| 1331 | if !self.navigation_bar_right { | ||
| 1332 | self.sidebar(&theme, sidebar); | ||
| 1333 | } | ||
| 1334 | // Where the notebook button floats over the tab row while the sidebar is shut. | ||
| 1335 | let beside = 1.0 - sidebar / sidebar::WIDTH; | ||
| 1239 | self.ui.open( | 1336 | self.ui.open( |
| 1240 | "main", | 1337 | "main", |
| 1241 | Spec { | 1338 | Spec { |
| ... | @@ -1267,10 +1364,11 @@ impl State { | ... | @@ -1267,10 +1364,11 @@ impl State { |
| 1267 | // outlines start where the button's square ends; the room shrinks on the sidebar's | 1364 | // outlines start where the button's square ends; the room shrinks on the sidebar's |
| 1268 | // easing, so the tabs ease with it. | 1365 | // easing, so the tabs ease with it. |
| 1269 | let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0]; | 1366 | let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0]; |
| 1367 | let room = if self.navigation_bar_right { 0.0 } else { room }; | ||
| 1270 | self.ui.leaf( | 1368 | self.ui.leaf( |
| 1271 | "rail", | 1369 | "rail", |
| 1272 | Spec { | 1370 | Spec { |
| 1273 | size: [px(room * (1.0 - sidebar / sidebar::WIDTH)), px(1.0)], | 1371 | size: [px(room * beside), px(1.0)], |
| 1274 | ..Spec::default() | 1372 | ..Spec::default() |
| 1275 | }, | 1373 | }, |
| 1276 | ); | 1374 | ); |
| ... | @@ -1301,6 +1399,7 @@ impl State { | ... | @@ -1301,6 +1399,7 @@ impl State { |
| 1301 | .collect(); | 1399 | .collect(); |
| 1302 | let lit = self.page_drop(row); | 1400 | let lit = self.page_drop(row); |
| 1303 | let dragged = self.dragged_tab(row); | 1401 | let dragged = self.dragged_tab(row); |
| 1402 | let shown = self.open_tab().unwrap_or(session.tab); | ||
| 1304 | let ui::shell::Tabs { | 1403 | let ui::shell::Tabs { |
| 1305 | clicked, | 1404 | clicked, |
| 1306 | context, | 1405 | context, |
| ... | @@ -1313,7 +1412,7 @@ impl State { | ... | @@ -1313,7 +1412,7 @@ impl State { |
| 1313 | &mut self.ui, | 1412 | &mut self.ui, |
| 1314 | row, | 1413 | row, |
| 1315 | &tabs, | 1414 | &tabs, |
| 1316 | session.tab, | 1415 | shown, |
| 1317 | lit, | 1416 | lit, |
| 1318 | dragged, | 1417 | dragged, |
| 1319 | &section, | 1418 | &section, |
| ... | @@ -1373,6 +1472,12 @@ impl State { | ... | @@ -1373,6 +1472,12 @@ impl State { |
| 1373 | } | 1472 | } |
| 1374 | }; | 1473 | }; |
| 1375 | self.commands.extend(clicked); | 1474 | self.commands.extend(clicked); |
| 1475 | if let Some(count) = self.session.as_ref().map(|session| session.tabs.len()) | ||
| 1476 | && let Some(tab) = | ||
| 1477 | (0..count).find(|tab| self.ui.signal(ui::shell::tab_id(row, *tab)).hovered) | ||
| 1478 | { | ||
| 1479 | self.prefetch_section(tab); | ||
| 1480 | } | ||
| 1376 | self.ui.leaf( | 1481 | self.ui.leaf( |
| 1377 | "space", | 1482 | "space", |
| 1378 | Spec { | 1483 | Spec { |
| ... | @@ -1383,10 +1488,15 @@ impl State { | ... | @@ -1383,10 +1488,15 @@ impl State { |
| 1383 | if self.session.is_some() { | 1488 | if self.session.is_some() { |
| 1384 | self.page_tools(&theme); | 1489 | self.page_tools(&theme); |
| 1385 | } | 1490 | } |
| 1491 | let button = if self.navigation_bar_right && !self.temporary { | ||
| 1492 | TAB_ROW * beside | ||
| 1493 | } else { | ||
| 1494 | 0.0 | ||
| 1495 | }; | ||
| 1386 | self.ui.leaf( | 1496 | self.ui.leaf( |
| 1387 | "trail", | 1497 | "trail", |
| 1388 | Spec { | 1498 | Spec { |
| 1389 | size: [px(self.trailing() - FRAME), px(1.0)], | 1499 | size: [px(self.trailing() - FRAME + button), px(1.0)], |
| 1390 | ..Spec::default() | 1500 | ..Spec::default() |
| 1391 | }, | 1501 | }, |
| 1392 | ); | 1502 | ); |
| ... | @@ -1402,6 +1512,11 @@ impl State { | ... | @@ -1402,6 +1512,11 @@ impl State { |
| 1402 | ..Spec::default() | 1512 | ..Spec::default() |
| 1403 | }, | 1513 | }, |
| 1404 | ); | 1514 | ); |
| 1515 | // OneNote's "Page tabs appear on the left" lists them before the page. | ||
| 1516 | let mut open_page = None; | ||
| 1517 | if self.page_tabs_left { | ||
| 1518 | open_page = self.page_list(&theme, &section, row); | ||
| 1519 | } | ||
| 1405 | self.ui.open( | 1520 | self.ui.open( |
| 1406 | "column", | 1521 | "column", |
| 1407 | Spec { | 1522 | Spec { |
| ... | @@ -1427,6 +1542,9 @@ impl State { | ... | @@ -1427,6 +1542,9 @@ impl State { |
| 1427 | self.ui.close(); | 1542 | self.ui.close(); |
| 1428 | self.ui.close(); | 1543 | self.ui.close(); |
| 1429 | self.ui.close(); | 1544 | self.ui.close(); |
| 1545 | if self.navigation_bar_right { | ||
| 1546 | self.sidebar(&theme, sidebar); | ||
| 1547 | } | ||
| 1430 | self.sidebar_button(&theme, height); | 1548 | self.sidebar_button(&theme, height); |
| 1431 | self.ui.close(); | 1549 | self.ui.close(); |
| 1432 | self.context_menu(); | 1550 | self.context_menu(); |
| ... | @@ -1445,12 +1563,20 @@ impl State { | ... | @@ -1445,12 +1563,20 @@ impl State { |
| 1445 | if let Some(node) = self.ui.access(page()) { | 1563 | if let Some(node) = self.ui.access(page()) { |
| 1446 | node.set_tree_id(PAGE_TREE); | 1564 | node.set_tree_id(PAGE_TREE); |
| 1447 | } | 1565 | } |
| 1448 | self.template_strip(&theme); | 1566 | // A page opening has neither the templates nor the scroll of the page leaving. |
| 1567 | let opening = self.loading().is_some(); | ||
| 1568 | if !opening { | ||
| 1569 | self.template_strip(&theme); | ||
| 1570 | } | ||
| 1449 | if let Some(rect) = self.ui.rect(page()) { | 1571 | if let Some(rect) = self.ui.rect(page()) { |
| 1450 | self.transport(&theme, rect)?; | 1572 | self.transport(&theme, rect)?; |
| 1451 | } | 1573 | } |
| 1452 | let scroll = self.view.scroll(); | 1574 | let scroll = self.view.scroll(); |
| 1453 | for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() { | 1575 | for (index, axis) in [Axis::X, Axis::Y] |
| 1576 | .into_iter() | ||
| 1577 | .filter(|_| !opening) | ||
| 1578 | .enumerate() | ||
| 1579 | { | ||
| 1454 | if let Some(offset) = ui::scrollbar( | 1580 | if let Some(offset) = ui::scrollbar( |
| 1455 | &mut self.ui, | 1581 | &mut self.ui, |
| 1456 | index, | 1582 | index, |
| ... | @@ -1491,9 +1617,14 @@ impl State { | ... | @@ -1491,9 +1617,14 @@ impl State { |
| 1491 | } | 1617 | } |
| 1492 | self.page_events(signal.events)?; | 1618 | self.page_events(signal.events)?; |
| 1493 | self.ui.close(); | 1619 | self.ui.close(); |
| 1494 | let open_page = self.page_list(&theme, &section, row); | 1620 | if !self.page_tabs_left { |
| 1621 | open_page = self.page_list(&theme, &section, row); | ||
| 1622 | } | ||
| 1495 | self.ui.close(); | 1623 | self.ui.close(); |
| 1496 | self.ui.close(); | 1624 | self.ui.close(); |
| 1625 | if self.navigation_bar_right { | ||
| 1626 | self.sidebar(&theme, sidebar); | ||
| 1627 | } | ||
| 1497 | self.task_pane(&theme); | 1628 | self.task_pane(&theme); |
| 1498 | self.sidebar_button(&theme, height); | 1629 | self.sidebar_button(&theme, height); |
| 1499 | self.ui.close(); | 1630 | self.ui.close(); |
| ... | @@ -1523,6 +1654,7 @@ impl State { | ... | @@ -1523,6 +1654,7 @@ impl State { |
| 1523 | // Where the open page tab meets the page, concentric with its neighbours. | 1654 | // Where the open page tab meets the page, concentric with its neighbours. |
| 1524 | let join = PILL_MARGIN + rounding; | 1655 | let join = PILL_MARGIN + rounding; |
| 1525 | let [left, top, right, bottom] = page; | 1656 | let [left, top, right, bottom] = page; |
| 1657 | let left_tabs = self.page_tabs_left; | ||
| 1526 | let tab = open_page | 1658 | let tab = open_page |
| 1527 | .and_then(|id| self.ui.rect(id)) | 1659 | .and_then(|id| self.ui.rect(id)) |
| 1528 | .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP]) | 1660 | .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP]) |
| ... | @@ -1532,8 +1664,12 @@ impl State { | ... | @@ -1532,8 +1664,12 @@ impl State { |
| 1532 | row[1].max(panel[1]).max(top), | 1664 | row[1].max(panel[1]).max(top), |
| 1533 | row[3].min(panel[3]).min(bottom), | 1665 | row[3].min(panel[3]).min(bottom), |
| 1534 | ]; | 1666 | ]; |
| 1535 | let reach = row[2].min(panel[2]); | 1667 | // How far the tab reaches out from the page's side. |
| 1536 | (reach - right > 2.0 * join && end - start > 2.0 * rounding).then(|| { | 1668 | let (reach, out) = match left_tabs { |
| 1669 | true => (row[0].max(panel[0]), left - row[0].max(panel[0])), | ||
| 1670 | false => (row[2].min(panel[2]), row[2].min(panel[2]) - right), | ||
| 1671 | }; | ||
| 1672 | (out > 2.0 * join && end - start > 2.0 * rounding).then(|| { | ||
| 1537 | // A tab too near the page's corner joins it along the edge. | 1673 | // A tab too near the page's corner joins it along the edge. |
| 1538 | let start = if start < top + rounding + join { | 1674 | let start = if start < top + rounding + join { |
| 1539 | top | 1675 | top |
| ... | @@ -1548,9 +1684,24 @@ impl State { | ... | @@ -1548,9 +1684,24 @@ impl State { |
| 1548 | [start, end, reach] | 1684 | [start, end, reach] |
| 1549 | }) | 1685 | }) |
| 1550 | }); | 1686 | }); |
| 1551 | let mut outline = vec![([left, top], rounding)]; | 1687 | // Clockwise from the top left, the open tab's bump on the side it stands. |
| 1688 | let mut outline = Vec::new(); | ||
| 1552 | match tab { | 1689 | match tab { |
| 1690 | Some([start, end, reach]) if left_tabs => { | ||
| 1691 | if start > top { | ||
| 1692 | outline.push(([left, top], rounding)); | ||
| 1693 | } | ||
| 1694 | outline.extend([([right, top], rounding), ([right, bottom], rounding)]); | ||
| 1695 | if end < bottom { | ||
| 1696 | outline.extend([([left, bottom], rounding), ([left, end], join)]); | ||
| 1697 | } | ||
| 1698 | outline.extend([([reach, end], rounding), ([reach, start], rounding)]); | ||
| 1699 | if start > top { | ||
| 1700 | outline.push(([left, start], join)); | ||
| 1701 | } | ||
| 1702 | } | ||
| 1553 | Some([start, end, reach]) => { | 1703 | Some([start, end, reach]) => { |
| 1704 | outline.push(([left, top], rounding)); | ||
| 1554 | if start > top { | 1705 | if start > top { |
| 1555 | outline.extend([([right, top], rounding), ([right, start], join)]); | 1706 | outline.extend([([right, top], rounding), ([right, start], join)]); |
| 1556 | } | 1707 | } |
| ... | @@ -1558,10 +1709,15 @@ impl State { | ... | @@ -1558,10 +1709,15 @@ impl State { |
| 1558 | if end < bottom { | 1709 | if end < bottom { |
| 1559 | outline.extend([([right, end], join), ([right, bottom], rounding)]); | 1710 | outline.extend([([right, end], join), ([right, bottom], rounding)]); |
| 1560 | } | 1711 | } |
| 1712 | outline.push(([left, bottom], rounding)); | ||
| 1561 | } | 1713 | } |
| 1562 | None => outline.extend([([right, top], rounding), ([right, bottom], rounding)]), | 1714 | None => outline.extend([ |
| 1715 | ([left, top], rounding), | ||
| 1716 | ([right, top], rounding), | ||
| 1717 | ([right, bottom], rounding), | ||
| 1718 | ([left, bottom], rounding), | ||
| 1719 | ]), | ||
| 1563 | } | 1720 | } |
| 1564 | outline.push(([left, bottom], rounding)); | ||
| 1565 | let height = (frame[3] - frame[1]).max(1.0); | 1721 | let height = (frame[3] - frame[1]).max(1.0); |
| 1566 | let paper = self.paper().color; | 1722 | let paper = self.paper().color; |
| 1567 | self.ui.round_corners(&outline, paper, |y| { | 1723 | self.ui.round_corners(&outline, paper, |y| { |
| ... | @@ -1573,7 +1729,9 @@ impl State { | ... | @@ -1573,7 +1729,9 @@ impl State { |
| 1573 | // the open tab stands on it. | 1729 | // the open tab stands on it. |
| 1574 | let [start, end] = [frame[0], frame[2]]; | 1730 | let [start, end] = [frame[0], frame[2]]; |
| 1575 | let outer = platform::corner_radius(&self.window); | 1731 | let outer = platform::corner_radius(&self.window); |
| 1576 | let beside = outer * start / sidebar::WIDTH; | 1732 | let window = self.ui.rect(Id::ROOT).map_or(end, |root| root[2]); |
| 1733 | let [beside, beside_right] = | ||
| 1734 | [start, window - end].map(|gap| (outer * gap / sidebar::WIDTH).min(outer)); | ||
| 1577 | let strip = self.ui.theme.strip; | 1735 | let strip = self.ui.theme.strip; |
| 1578 | // The border runs half its width inside the frame, on whole device pixels at any | 1736 | // The border runs half its width inside the frame, on whole device pixels at any |
| 1579 | // scale, its corners concentric with the fill's. The fill is cut at the border's | 1737 | // scale, its corners concentric with the fill's. The fill is cut at the border's |
| ... | @@ -1585,7 +1743,7 @@ impl State { | ... | @@ -1585,7 +1743,7 @@ impl State { |
| 1585 | &[ | 1743 | &[ |
| 1586 | ([left, top], (outer - cut).max(0.0)), | 1744 | ([left, top], (outer - cut).max(0.0)), |
| 1587 | ([right, top], (outer - cut).max(0.0)), | 1745 | ([right, top], (outer - cut).max(0.0)), |
| 1588 | ([right, frame[3]], 0.0), | 1746 | ([right, frame[3]], (beside_right - cut).max(0.0)), |
| 1589 | ([left, frame[3]], (beside - cut).max(0.0)), | 1747 | ([left, frame[3]], (beside - cut).max(0.0)), |
| 1590 | ], | 1748 | ], |
| 1591 | paper, | 1749 | paper, |
| ... | @@ -1603,7 +1761,7 @@ impl State { | ... | @@ -1603,7 +1761,7 @@ impl State { |
| 1603 | .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)) | 1761 | .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)) |
| 1604 | .map(|x| x.clamp(left, right)); | 1762 | .map(|x| x.clamp(left, right)); |
| 1605 | // The border runs down both sides to the window's bottom, round the bottom corner | 1763 | // The border runs down both sides to the window's bottom, round the bottom corner |
| 1606 | // beside the sidebar. | 1764 | // beside the sidebar, on whichever side it stands. |
| 1607 | self.ui.border( | 1765 | self.ui.border( |
| 1608 | &[ | 1766 | &[ |
| 1609 | ([west + beside, frame[3]], 0.0), | 1767 | ([west + beside, frame[3]], 0.0), |
| ... | @@ -1618,7 +1776,8 @@ impl State { | ... | @@ -1618,7 +1776,8 @@ impl State { |
| 1618 | &[ | 1776 | &[ |
| 1619 | ([toe, north], 0.0), | 1777 | ([toe, north], 0.0), |
| 1620 | ([east, north], inner(outer)), | 1778 | ([east, north], inner(outer)), |
| 1621 | ([east, frame[3]], 0.0), | 1779 | ([east, frame[3]], inner(beside_right)), |
| 1780 | ([east - beside_right, frame[3]], 0.0), | ||
| 1622 | ], | 1781 | ], |
| 1623 | false, | 1782 | false, |
| 1624 | section.edge, | 1783 | section.edge, |
| ... | @@ -1756,6 +1915,7 @@ impl State { | ... | @@ -1756,6 +1915,7 @@ impl State { |
| 1756 | let session = self.session.as_ref(); | 1915 | let session = self.session.as_ref(); |
| 1757 | let update = self.updates.status(); | 1916 | let update = self.updates.status(); |
| 1758 | let label = format!("{:.0}%", self.view.zoom() * 100.0); | 1917 | let label = format!("{:.0}%", self.view.zoom() * 100.0); |
| 1918 | let sheet = self.gallery_sheet(); | ||
| 1759 | let drawing_pens = self.pens(); | 1919 | let drawing_pens = self.pens(); |
| 1760 | let ui = &mut self.ui; | 1920 | let ui = &mut self.ui; |
| 1761 | let text = theme.text; | 1921 | let text = theme.text; |
| ... | @@ -1850,6 +2010,22 @@ impl State { | ... | @@ -1850,6 +2010,22 @@ impl State { |
| 1850 | }, | 2010 | }, |
| 1851 | ); | 2011 | ); |
| 1852 | divider(ui, theme); | 2012 | divider(ui, theme); |
| 2013 | // Styles sits left of the font box (Clover, 2026-09-30), and like it never folds. | ||
| 2014 | let styles = toolbar_popup("styles"); | ||
| 2015 | let anchor = if status_of(Cmd::Styles).enabled { | ||
| 2016 | ui::shell::menu_button(ui, "styles", art::STYLES, None, styles) | ||
| 2017 | } else { | ||
| 2018 | ui::shell::unavailable(ui, "styles", art::STYLES, text, true); | ||
| 2019 | ui::Anchor::Below(ui.rect(ui.id("styles")).unwrap_or_default()) | ||
| 2020 | }; | ||
| 2021 | let shown = notebook::sidecar::themes::STYLES | ||
| 2022 | .iter() | ||
| 2023 | .find(|(name, _)| state.style.as_deref() == Some(*name)) | ||
| 2024 | .map_or("Styles".to_owned(), |(_, label)| format!("Styles: {label}")); | ||
| 2025 | ui::popup::tooltip(ui, &shown, "", None); | ||
| 2026 | if let Some(id) = themes::gallery(ui, styles, anchor, &sheet, state.style.as_deref()) { | ||
| 2027 | choice = Some(Choice::Command(id)); | ||
| 2028 | } | ||
| 1853 | let combo = ui.id("font"); | 2029 | let combo = ui.id("font"); |
| 1854 | // It gives up room last of all, once every group has folded. | 2030 | // It gives up room last of all, once every group has folded. |
| 1855 | let width = ui::Extent { | 2031 | let width = ui::Extent { |
| ... | @@ -2546,8 +2722,9 @@ impl State { | ... | @@ -2546,8 +2722,9 @@ impl State { |
| 2546 | self.ui.close(); | 2722 | self.ui.close(); |
| 2547 | } | 2723 | } |
| 2548 | 2724 | ||
| 2549 | /// The section's pages as tabs down the frame's right side, returning the open page's | 2725 | /// The section's pages as tabs down the frame's right side, or its left with OneNote's |
| 2550 | /// tab, which is the page's colour and joins it. | 2726 | /// "Page tabs appear on the left", returning the open page's tab, which is the page's |
| 2727 | /// colour and joins it. | ||
| 2551 | fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> { | 2728 | fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> { |
| 2552 | let session = self.session.as_ref()?; | 2729 | let session = self.session.as_ref()?; |
| 2553 | let panel = self.ui.id("panel"); | 2730 | let panel = self.ui.id("panel"); |
| ... | @@ -2567,6 +2744,11 @@ impl State { | ... | @@ -2567,6 +2744,11 @@ impl State { |
| 2567 | node.set_label("Pages"); | 2744 | node.set_label("Pages"); |
| 2568 | node.set_orientation(accesskit::Orientation::Vertical); | 2745 | node.set_orientation(accesskit::Orientation::Vertical); |
| 2569 | } | 2746 | } |
| 2747 | // Another section's pages are on their way. | ||
| 2748 | if self.loading().is_some() && self.switching.is_some_and(|(tab, _)| tab.is_some()) { | ||
| 2749 | self.ui.close(); | ||
| 2750 | return None; | ||
| 2751 | } | ||
| 2570 | let found = self | 2752 | let found = self |
| 2571 | .search | 2753 | .search |
| 2572 | .found_in(&session.library.key(&session.tabs[session.tab].path)); | 2754 | .found_in(&session.library.key(&session.tabs[session.tab].path)); |
| ... | @@ -2578,7 +2760,7 @@ impl State { | ... | @@ -2578,7 +2760,7 @@ impl State { |
| 2578 | section, | 2760 | section, |
| 2579 | session, | 2761 | session, |
| 2580 | &found, | 2762 | &found, |
| 2581 | rounding, | 2763 | (rounding, self.page_tabs_left), |
| 2582 | self.renaming.as_mut(), | 2764 | self.renaming.as_mut(), |
| 2583 | dragged, | 2765 | dragged, |
| 2584 | ); | 2766 | ); |
| ... | @@ -2587,7 +2769,12 @@ impl State { | ... | @@ -2587,7 +2769,12 @@ impl State { |
| 2587 | .map(|(page, version)| Command::OpenVersion { page, version }), | 2769 | .map(|(page, version)| Command::OpenVersion { page, version }), |
| 2588 | ); | 2770 | ); |
| 2589 | if let Some((space, point)) = rows.context { | 2771 | if let Some((space, point)) = rows.context { |
| 2590 | self.menu = Some((menus::Target::Page(space), point)); | 2772 | let target = menus::Target::Page { |
| 2773 | library: Arc::clone(&session.library), | ||
| 2774 | path: session.tabs[session.tab].path.clone(), | ||
| 2775 | space, | ||
| 2776 | }; | ||
| 2777 | self.menu = Some((target, point)); | ||
| 2591 | self.ui.open_popup(menus::id()); | 2778 | self.ui.open_popup(menus::id()); |
| 2592 | } | 2779 | } |
| 2593 | if let Some(space) = rows.renamed { | 2780 | if let Some(space) = rows.renamed { |
| ... | @@ -2600,12 +2787,19 @@ impl State { | ... | @@ -2600,12 +2787,19 @@ impl State { |
| 2600 | if !self.dragged() { | 2787 | if !self.dragged() { |
| 2601 | self.commands.extend(rows.clicked.map(Command::OpenPage)); | 2788 | self.commands.extend(rows.clicked.map(Command::OpenPage)); |
| 2602 | } | 2789 | } |
| 2790 | if let Some(space) = rows.hovered { | ||
| 2791 | self.prefetch_page(space); | ||
| 2792 | } | ||
| 2603 | self.ui.close(); | 2793 | self.ui.close(); |
| 2604 | rows.open | 2794 | rows.open |
| 2605 | } | 2795 | } |
| 2606 | 2796 | ||
| 2607 | /// Hands the page the events routed to its box, in its device pixels. | 2797 | /// Hands the page the events routed to its box, in its device pixels. |
| 2608 | fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> { | 2798 | fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> { |
| 2799 | // The page shown is on its way out. | ||
| 2800 | if self.loading().is_some() { | ||
| 2801 | return Ok(()); | ||
| 2802 | } | ||
| 2609 | let read_only = self.session.as_ref().is_some_and(Session::read_only); | 2803 | let read_only = self.session.as_ref().is_some_and(Session::read_only); |
| 2610 | let scale = self.ui.scale(); | 2804 | let scale = self.ui.scale(); |
| 2611 | let corner = self.ui.rect(page()).unwrap_or_default(); | 2805 | let corner = self.ui.rect(page()).unwrap_or_default(); |
| ... | @@ -2706,16 +2900,39 @@ impl State { | ... | @@ -2706,16 +2900,39 @@ impl State { |
| 2706 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { | 2900 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { |
| 2707 | match command { | 2901 | match command { |
| 2708 | Command::OpenSection(library, path) => { | 2902 | Command::OpenSection(library, path) => { |
| 2903 | let tab = match &self.session { | ||
| 2904 | // Back to the open section before another one opened. | ||
| 2905 | Some(session) | ||
| 2906 | if self.switching.is_some() | ||
| 2907 | && Arc::ptr_eq(&session.library, &library) | ||
| 2908 | && session.tabs[session.tab].path == path => | ||
| 2909 | { | ||
| 2910 | self.stop_loading(); | ||
| 2911 | return Ok(()); | ||
| 2912 | } | ||
| 2913 | Some(session) if session.library.location == library.location => { | ||
| 2914 | session.tabs.iter().position(|tab| tab.path == path) | ||
| 2915 | } | ||
| 2916 | _ => None, | ||
| 2917 | }; | ||
| 2918 | self.switching = Some((tab, Instant::now())); | ||
| 2709 | let notify = notify(self.proxy.clone()); | 2919 | let notify = notify(self.proxy.clone()); |
| 2710 | let last = self.last_pages.get(&library.key(&path)).copied(); | 2920 | let last = self.last_pages.get(&library.key(&path)).copied(); |
| 2711 | self.load(move || { | 2921 | self.load(move || { |
| 2922 | let start = Instant::now(); | ||
| 2712 | let section = library.open(&path, notify)?; | 2923 | let section = library.open(&path, notify)?; |
| 2924 | lap("switch section open", start); | ||
| 2713 | let (session, page) = read_session(section, library, path, last)?; | 2925 | let (session, page) = read_session(section, library, path, last)?; |
| 2714 | Ok(Loaded::Section(Box::new(session), page)) | 2926 | Ok(Loaded::Section(Box::new(session), page)) |
| 2715 | }); | 2927 | }); |
| 2716 | } | 2928 | } |
| 2717 | Command::OpenPage(space) => { | 2929 | Command::OpenPage(space) => { |
| 2718 | let session = self.session.as_ref().ok_or("No section is open")?; | 2930 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 2931 | if self.switching.is_some() && session.space == space && session.version.is_none() { | ||
| 2932 | self.stop_loading(); | ||
| 2933 | return Ok(()); | ||
| 2934 | } | ||
| 2935 | self.switching = Some((None, Instant::now())); | ||
| 2719 | let read = session.reader(space); | 2936 | let read = session.reader(space); |
| 2720 | self.load(move || Ok(Loaded::Page(space, read()?))); | 2937 | self.load(move || Ok(Loaded::Page(space, read()?))); |
| 2721 | } | 2938 | } |
| ... | @@ -2773,12 +2990,17 @@ impl State { | ... | @@ -2773,12 +2990,17 @@ impl State { |
| 2773 | } | 2990 | } |
| 2774 | Command::OpenFromServer(location) => self.open_server(location.as_deref()), | 2991 | Command::OpenFromServer(location) => self.open_server(location.as_deref()), |
| 2775 | Command::NewNotebook => self.new_notebook()?, | 2992 | Command::NewNotebook => self.new_notebook()?, |
| 2776 | Command::UseICloud => self.use_icloud()?, | 2993 | Command::NewICloudNotebook => self.new_icloud_notebook(), |
| 2777 | Command::OpenGuide => self.open_guide()?, | 2994 | Command::OpenGuide => self.open_guide()?, |
| 2778 | #[cfg(target_os = "linux")] | 2995 | #[cfg(target_os = "linux")] |
| 2779 | Command::Install => desktop::install(), | 2996 | Command::Install => desktop::install(), |
| 2780 | Command::CloseNotebook(library) => self.close_notebook(&library), | 2997 | Command::CloseNotebook(library) => self.close_notebook(&library), |
| 2781 | Command::Structure(library, change) => self.restructure(library, change), | 2998 | Command::Structure(library, change) => { |
| 2999 | if let Some(undo) = undo::structure_undo(&library, &change) { | ||
| 3000 | self.undo.record(undo); | ||
| 3001 | } | ||
| 3002 | self.restructure(library, change); | ||
| 3003 | } | ||
| 2782 | // The index follows the section's page list. | 3004 | // The index follows the section's page list. |
| 2783 | Command::NewPage { under } => { | 3005 | Command::NewPage { under } => { |
| 2784 | self.new_page(under)?; | 3006 | self.new_page(under)?; |
| ... | @@ -2812,8 +3034,12 @@ impl State { | ... | @@ -2812,8 +3034,12 @@ impl State { |
| 2812 | self.loading += 1; | 3034 | self.loading += 1; |
| 2813 | let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); | 3035 | let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); |
| 2814 | let layouts = Arc::clone(&self.layouts); | 3036 | let layouts = Arc::clone(&self.layouts); |
| 3037 | let requested = Instant::now(); | ||
| 3038 | let scenes = Arc::clone(&self.prefetch.scenes); | ||
| 3039 | let open = self.session.as_ref().map(Session::key); | ||
| 2815 | std::thread::spawn(move || { | 3040 | std::thread::spawn(move || { |
| 2816 | let laid = read().and_then(|loaded| { | 3041 | let laid = read().and_then(|loaded| { |
| 3042 | lap("switch read", requested); | ||
| 2817 | let (shown, page) = match loaded { | 3043 | let (shown, page) = match loaded { |
| 2818 | Loaded::Section(session, page) => (Shown::Section(session), page), | 3044 | Loaded::Section(session, page) => (Shown::Section(session), page), |
| 2819 | Loaded::Created(session, page) => (Shown::Created(session), page), | 3045 | Loaded::Created(session, page) => (Shown::Created(session), page), |
| ... | @@ -2822,8 +3048,28 @@ impl State { | ... | @@ -2822,8 +3048,28 @@ impl State { |
| 2822 | Loaded::Library(library, path) => return Ok(Laid::Library(library, path)), | 3048 | Loaded::Library(library, path) => return Ok(Laid::Library(library, path)), |
| 2823 | }; | 3049 | }; |
| 2824 | let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; | 3050 | let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; |
| 2825 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; | 3051 | let start = Instant::now(); |
| 3052 | let key = match &shown { | ||
| 3053 | Shown::Section(session) | Shown::Created(session) => { | ||
| 3054 | Some((session.key(), session.space)) | ||
| 3055 | } | ||
| 3056 | Shown::Page(space) => open.map(|open| (open, *space)), | ||
| 3057 | Shown::Version(..) => None, | ||
| 3058 | }; | ||
| 3059 | // A page shown or prepared lately keeps the pictures it drew. | ||
| 3060 | let kept = | ||
| 3061 | key.and_then(|(key, space)| prefetch::Prefetch::take(&scenes, &key, space)); | ||
| 3062 | let (scene, editor) = match kept { | ||
| 3063 | Some(mut scene) => { | ||
| 3064 | let mut editor = CanvasEditor::from_page(page, &mut engine)?; | ||
| 3065 | scene.refresh(&mut editor, &mut engine)?; | ||
| 3066 | (scene, editor) | ||
| 3067 | } | ||
| 3068 | None => PageScene::from_page(page, &mut engine)?, | ||
| 3069 | }; | ||
| 3070 | lap("switch layout", start); | ||
| 2826 | Ok(Laid::Page(Box::new(Opening { | 3071 | Ok(Laid::Page(Box::new(Opening { |
| 3072 | requested, | ||
| 2827 | loaded: shown, | 3073 | loaded: shown, |
| 2828 | scene: (scene, [0.0; 2]), | 3074 | scene: (scene, [0.0; 2]), |
| 2829 | editor, | 3075 | editor, |
| ... | @@ -2846,11 +3092,19 @@ impl State { | ... | @@ -2846,11 +3092,19 @@ impl State { |
| 2846 | Ok(Laid::Page(opening)) => self.opening = Some(*opening), | 3092 | Ok(Laid::Page(opening)) => self.opening = Some(*opening), |
| 2847 | Ok(Laid::Library(library, path)) => self.adopt(library, path.as_deref())?, | 3093 | Ok(Laid::Library(library, path)) => self.adopt(library, path.as_deref())?, |
| 2848 | Err(error) => { | 3094 | Err(error) => { |
| 3095 | if id == self.loading { | ||
| 3096 | self.switching = None; | ||
| 3097 | } | ||
| 2849 | eprintln!("{error}"); | 3098 | eprintln!("{error}"); |
| 2850 | platform::alert("Couldn't open", &error); | 3099 | platform::alert("Couldn't open", &error); |
| 2851 | } | 3100 | } |
| 2852 | } | 3101 | } |
| 2853 | } | 3102 | } |
| 3103 | let paper = canvas::gpu::Paper { | ||
| 3104 | color: self.ui.theme.paper, | ||
| 3105 | ink: self.ui.theme.paper_ink, | ||
| 3106 | }; | ||
| 3107 | self.prefetch.draw(&self.view, paper, &self.redraw); | ||
| 2854 | let Some(mut opening) = self.opening.take() else { | 3108 | let Some(mut opening) = self.opening.take() else { |
| 2855 | return Ok(()); | 3109 | return Ok(()); |
| 2856 | }; | 3110 | }; |
| ... | @@ -2867,15 +3121,27 @@ impl State { | ... | @@ -2867,15 +3121,27 @@ impl State { |
| 2867 | self.opening = Some(opening); | 3121 | self.opening = Some(opening); |
| 2868 | return Ok(()); | 3122 | return Ok(()); |
| 2869 | } | 3123 | } |
| 3124 | lap("switch pictures", opening.since); | ||
| 3125 | let requested = opening.requested; | ||
| 2870 | // A recording goes on the page it started on. | 3126 | // A recording goes on the page it started on. |
| 2871 | self.stop_recording(true)?; | 3127 | self.stop_recording(true)?; |
| 2872 | // The page shown so far keeps what was typed while the next one loaded. | 3128 | // The page shown so far keeps what was typed while the next one loaded. |
| 2873 | self.persist()?; | 3129 | self.persist()?; |
| 3130 | let mut leaving = self | ||
| 3131 | .session | ||
| 3132 | .as_ref() | ||
| 3133 | .filter(|session| !session.read_only()) | ||
| 3134 | .map(|session| session.space); | ||
| 2874 | if let Some(session) = &self.session { | 3135 | if let Some(session) = &self.session { |
| 2875 | let key = session.library.key(&session.tabs[session.tab].path); | 3136 | let key = session.key(); |
| 2876 | self.places | 3137 | self.places |
| 2877 | .insert((key.clone(), session.space), self.view.place()); | 3138 | .insert((key.clone(), session.space), self.view.place()); |
| 2878 | self.last_pages.insert(key, session.space); | 3139 | self.last_pages.insert(key.clone(), session.space); |
| 3140 | if session.version.is_none() | ||
| 3141 | && let Some(scene) = self.view.scene.take() | ||
| 3142 | { | ||
| 3143 | self.prefetch.keep(key, session.space, scene); | ||
| 3144 | } | ||
| 2879 | } | 3145 | } |
| 2880 | let created = matches!(opening.loaded, Shown::Created(_)); | 3146 | let created = matches!(opening.loaded, Shown::Created(_)); |
| 2881 | match opening.loaded { | 3147 | match opening.loaded { |
| ... | @@ -2894,14 +3160,23 @@ impl State { | ... | @@ -2894,14 +3160,23 @@ impl State { |
| 2894 | Some(library) => *library = Arc::clone(&session.library), | 3160 | Some(library) => *library = Arc::clone(&session.library), |
| 2895 | None => self.notebooks.push(Arc::clone(&session.library)), | 3161 | None => self.notebooks.push(Arc::clone(&session.library)), |
| 2896 | } | 3162 | } |
| 2897 | // The notebook's background sync takes the section over once it is closed. | 3163 | if session.library.in_icloud() && session.library.downloading() { |
| 3164 | self.fetch(Arc::clone(&session.library), None); | ||
| 3165 | } | ||
| 3166 | // The section left stays open a while for coming back to; the notebook's | ||
| 3167 | // background sync takes it over once it is closed. | ||
| 2898 | if let Some(previous) = self.session.replace(*session) { | 3168 | if let Some(previous) = self.session.replace(*session) { |
| 2899 | let section = previous.section; | 3169 | let library = &previous.library; |
| 2900 | std::thread::spawn(move || { | 3170 | if self.notebooks.iter().any(|open| Arc::ptr_eq(open, library)) { |
| 2901 | if let Err(error) = section.close() { | 3171 | library.keep(&previous.tabs[previous.tab].path, previous.section); |
| 2902 | eprintln!("Synchronization stopped: {error}"); | 3172 | } else { |
| 2903 | } | 3173 | let section = previous.section; |
| 2904 | }); | 3174 | std::thread::spawn(move || { |
| 3175 | if let Err(error) = section.close() { | ||
| 3176 | eprintln!("Synchronization stopped: {error}"); | ||
| 3177 | } | ||
| 3178 | }); | ||
| 3179 | } | ||
| 2905 | } | 3180 | } |
| 2906 | self.sectionless = None; | 3181 | self.sectionless = None; |
| 2907 | if other_notebook { | 3182 | if other_notebook { |
| ... | @@ -2918,15 +3193,41 @@ impl State { | ... | @@ -2918,15 +3193,41 @@ impl State { |
| 2918 | session.change = None; | 3193 | session.change = None; |
| 2919 | // Pages created, moved or deleted since the list was read. | 3194 | // Pages created, moved or deleted since the list was read. |
| 2920 | session.pages = session.section.pages()?; | 3195 | session.pages = session.section.pages()?; |
| 3196 | leaving = leaving.filter(|left| session.pages.iter().any(|(s, ..)| s == left)); | ||
| 2921 | } | 3197 | } |
| 2922 | } | 3198 | } |
| 2923 | let place = self.session.as_ref().and_then(|session| { | 3199 | let place = self.session.as_ref().and_then(|session| { |
| 2924 | let key = session.library.key(&session.tabs[session.tab].path); | 3200 | let key = session.library.key(&session.tabs[session.tab].path); |
| 2925 | self.places.get(&(key, session.space)).copied() | 3201 | self.places.get(&(key, session.space)).copied() |
| 2926 | }); | 3202 | }); |
| 2927 | self.view.open(opening.editor, Some(opening.scene), place); | 3203 | let left = self.view.open(opening.editor, Some(opening.scene), place); |
| 3204 | // Each page takes up the history it was left with. | ||
| 3205 | if let Some(leaving) = leaving { | ||
| 3206 | self.undo.park(leaving, left); | ||
| 3207 | } | ||
| 3208 | let shown = self | ||
| 3209 | .session | ||
| 3210 | .as_ref() | ||
| 3211 | .filter(|session| !session.read_only()) | ||
| 3212 | .map(|session| session.space); | ||
| 3213 | if let Some(parked) = shown.and_then(|space| self.undo.resume(space)) { | ||
| 3214 | self.view.resume(parked)?; | ||
| 3215 | } | ||
| 3216 | lap("switch shown", requested); | ||
| 3217 | self.switching = None; | ||
| 2928 | self.visited(); | 3218 | self.visited(); |
| 3219 | if let Some((space, action)) = self.after_open.take() | ||
| 3220 | && let Some(session) = self.session.as_ref().filter(|session| session.space == space) | ||
| 3221 | { | ||
| 3222 | let target = menus::Target::Page { | ||
| 3223 | library: Arc::clone(&session.library), | ||
| 3224 | path: session.tabs[session.tab].path.clone(), | ||
| 3225 | space, | ||
| 3226 | }; | ||
| 3227 | self.act_on(target, action); | ||
| 3228 | } | ||
| 2929 | self.opened()?; | 3229 | self.opened()?; |
| 3230 | self.prefetch_around(); | ||
| 2930 | self.refind(true)?; | 3231 | self.refind(true)?; |
| 2931 | if self.title_focus.take().is_some_and(|space| { | 3232 | if self.title_focus.take().is_some_and(|space| { |
| 2932 | self.session | 3233 | self.session |
| ... | @@ -2958,6 +3259,9 @@ impl State { | ... | @@ -2958,6 +3259,9 @@ impl State { |
| 2958 | /// the open section, now at catalog `path` in it, stays open, reopened where it moved. | 3259 | /// the open section, now at catalog `path` in it, stays open, reopened where it moved. |
| 2959 | /// Without `path` the notebook has no sections left, and one of its shows none. | 3260 | /// Without `path` the notebook has no sections left, and one of its shows none. |
| 2960 | fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> { | 3261 | fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> { |
| 3262 | if library.in_icloud() && library.downloading() { | ||
| 3263 | self.fetch(Arc::clone(&library), None); | ||
| 3264 | } | ||
| 2961 | match self | 3265 | match self |
| 2962 | .notebooks | 3266 | .notebooks |
| 2963 | .iter_mut() | 3267 | .iter_mut() |
| ... | @@ -2978,9 +3282,10 @@ impl State { | ... | @@ -2978,9 +3282,10 @@ impl State { |
| 2978 | if let Some(session) = self.session.take() { | 3282 | if let Some(session) = self.session.take() { |
| 2979 | session.section.close()?; | 3283 | session.section.close()?; |
| 2980 | } | 3284 | } |
| 2981 | self.sectionless = Some(library); | 3285 | self.sectionless = Some(Arc::clone(&library)); |
| 2982 | self.title(); | 3286 | self.title(); |
| 2983 | } | 3287 | } |
| 3288 | self.show_arrived(&library); | ||
| 2984 | return Ok(()); | 3289 | return Ok(()); |
| 2985 | }; | 3290 | }; |
| 2986 | let Some(session) = &mut self.session else { | 3291 | let Some(session) = &mut self.session else { |
| ... | @@ -3014,6 +3319,9 @@ impl State { | ... | @@ -3014,6 +3319,9 @@ impl State { |
| 3014 | 3319 | ||
| 3015 | /// Follows a page shown in place of another. | 3320 | /// Follows a page shown in place of another. |
| 3016 | fn opened(&mut self) -> Result<(), Box<dyn Error>> { | 3321 | fn opened(&mut self) -> Result<(), Box<dyn Error>> { |
| 3322 | if let Err(error) = self.wear_theme() { | ||
| 3323 | eprintln!("Restyling the page failed: {error}"); | ||
| 3324 | } | ||
| 3017 | // A page a search result shows leaves the keys with the search. | 3325 | // A page a search result shows leaves the keys with the search. |
| 3018 | if !search::takes_text(self.ui.focused()) { | 3326 | if !search::takes_text(self.ui.focused()) { |
| 3019 | self.ui.set_focus(Some(page())); | 3327 | self.ui.set_focus(Some(page())); |
| ... | @@ -3220,6 +3528,9 @@ impl State { | ... | @@ -3220,6 +3528,9 @@ impl State { |
| 3220 | session.pages = session.section.pages()?; | 3528 | session.pages = session.section.pages()?; |
| 3221 | } | 3529 | } |
| 3222 | self.edited(vec![space]); | 3530 | self.edited(vec![space]); |
| 3531 | let editor = &self.view.editor; | ||
| 3532 | self.undo | ||
| 3533 | .edited(space, editor.history_depth(), editor.typing().is_some()); | ||
| 3223 | Ok(()) | 3534 | Ok(()) |
| 3224 | } | 3535 | } |
| 3225 | 3536 | ||
| ... | @@ -3610,19 +3921,32 @@ impl State { | ... | @@ -3610,19 +3921,32 @@ impl State { |
| 3610 | self.view.tag_art = session.library.tag_art(); | 3921 | self.view.tag_art = session.library.tag_art(); |
| 3611 | } | 3922 | } |
| 3612 | let theme = &self.ui.theme; | 3923 | let theme = &self.ui.theme; |
| 3613 | let page_primitives = self.view.primitives(TextColors { | ||
| 3614 | caret: theme.caret, | ||
| 3615 | selection: if self.page_focused { | ||
| 3616 | theme.selection | ||
| 3617 | } else { | ||
| 3618 | theme.inactive_selection | ||
| 3619 | }, | ||
| 3620 | paper, | ||
| 3621 | })?; | ||
| 3622 | lap("page primitives", start); | ||
| 3623 | let scale = self.ui.scale(); | 3924 | let scale = self.ui.scale(); |
| 3624 | let corner = self.ui.rect(page()).unwrap_or_default(); | 3925 | let corner = self.ui.rect(page()).unwrap_or_default(); |
| 3625 | let viewport = self.view.viewport; | 3926 | // A page taking a while to open shows its outline, drawn in points from the corner. |
| 3927 | let (page_primitives, viewport) = match self.loading() { | ||
| 3928 | Some(since) => ( | ||
| 3929 | prefetch::skeleton(paper, since), | ||
| 3930 | canvas::gpu::Viewport { | ||
| 3931 | scale, | ||
| 3932 | origin: [0.0; 2], | ||
| 3933 | ..self.view.viewport | ||
| 3934 | }, | ||
| 3935 | ), | ||
| 3936 | None => ( | ||
| 3937 | self.view.primitives(TextColors { | ||
| 3938 | caret: theme.caret, | ||
| 3939 | selection: if self.page_focused { | ||
| 3940 | theme.selection | ||
| 3941 | } else { | ||
| 3942 | theme.inactive_selection | ||
| 3943 | }, | ||
| 3944 | paper, | ||
| 3945 | })?, | ||
| 3946 | self.view.viewport, | ||
| 3947 | ), | ||
| 3948 | }; | ||
| 3949 | lap("page primitives", start); | ||
| 3626 | let interface = self.ui.layers(); | 3950 | let interface = self.ui.layers(); |
| 3627 | let layers: Vec<_> = interface | 3951 | let layers: Vec<_> = interface |
| 3628 | .iter() | 3952 | .iter() |
| ... | @@ -3673,17 +3997,18 @@ impl State { | ... | @@ -3673,17 +3997,18 @@ impl State { |
| 3673 | (x0..x1).contains(&x) && (y0..y1).contains(&y) | 3997 | (x0..x1).contains(&x) && (y0..y1).contains(&y) |
| 3674 | } | 3998 | } |
| 3675 | 3999 | ||
| 4000 | /// Window point `point` in the page's device pixels. | ||
| 4001 | fn page_point(&self, point: [f32; 2]) -> [f32; 2] { | ||
| 4002 | let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); | ||
| 4003 | let scale = self.ui.scale(); | ||
| 4004 | [(point[0] - left) * scale, (point[1] - top) * scale] | ||
| 4005 | } | ||
| 4006 | |||
| 3676 | /// Zooms the page by `factor` about the pointer, when it is over the page. | 4007 | /// Zooms the page by `factor` about the pointer, when it is over the page. |
| 3677 | fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> { | 4008 | fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> { |
| 3678 | if self.over_page() { | 4009 | if self.over_page() { |
| 3679 | // The pointer here, as the page's own lags by the moves queued for the next frame. | 4010 | // The pointer here, as the page's own lags by the moves queued for the next frame. |
| 3680 | let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); | 4011 | let response = self.view.pinch(factor, self.page_point(self.pointer))?; |
| 3681 | let scale = self.ui.scale(); | ||
| 3682 | let anchor = [ | ||
| 3683 | (self.pointer[0] - left) * scale, | ||
| 3684 | (self.pointer[1] - top) * scale, | ||
| 3685 | ]; | ||
| 3686 | let response = self.view.pinch(factor, anchor)?; | ||
| 3687 | self.respond(response); | 4012 | self.respond(response); |
| 3688 | } | 4013 | } |
| 3689 | Ok(()) | 4014 | Ok(()) |
| ... | @@ -3755,10 +4080,15 @@ impl State { | ... | @@ -3755,10 +4080,15 @@ impl State { |
| 3755 | } | 4080 | } |
| 3756 | return; | 4081 | return; |
| 3757 | } | 4082 | } |
| 3758 | // On macOS the menu bar takes the chords of the items it enables first. | 4083 | if self.record_chord(&event) { |
| 4084 | return; | ||
| 4085 | } | ||
| 4086 | // On macOS the menu bar takes the chords of the items it enables first. The open | ||
| 4087 | // palette takes ⌘K, Link's chord, for its actions. | ||
| 3759 | if let ui::Event::Key { key, .. } = &event | 4088 | if let ui::Event::Key { key, .. } = &event |
| 3760 | && let Some(id) = | 4089 | && let Some(id) = |
| 3761 | commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers())) | 4090 | commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers())) |
| 4091 | && !(id == commands::Id::Link && self.ui.popup_open(palette::id())) | ||
| 3762 | { | 4092 | { |
| 3763 | self.choose(commands::Choice::Command(id)); | 4093 | self.choose(commands::Choice::Command(id)); |
| 3764 | } else { | 4094 | } else { |
| ... | @@ -3977,6 +4307,8 @@ struct Rows { | ... | @@ -3977,6 +4307,8 @@ struct Rows { |
| 3977 | context: Option<(ExGuid, [f32; 2])>, | 4307 | context: Option<(ExGuid, [f32; 2])>, |
| 3978 | /// The page held down, which a drag moves. | 4308 | /// The page held down, which a drag moves. |
| 3979 | held: Option<ExGuid>, | 4309 | held: Option<ExGuid>, |
| 4310 | /// A page other than the open one under the pointer. | ||
| 4311 | hovered: Option<ExGuid>, | ||
| 3980 | /// A page version clicked: the page, and the version's context. | 4312 | /// A page version clicked: the page, and the version's context. |
| 3981 | version: Option<(ExGuid, ExGuid)>, | 4313 | version: Option<(ExGuid, ExGuid)>, |
| 3982 | /// The page whose tab was pressed twice in a row, which renames it. | 4314 | /// The page whose tab was pressed twice in a row, which renames it. |
| ... | @@ -4013,7 +4345,7 @@ fn page_rows( | ... | @@ -4013,7 +4345,7 @@ fn page_rows( |
| 4013 | section: &ui::Section, | 4345 | section: &ui::Section, |
| 4014 | session: &Session, | 4346 | session: &Session, |
| 4015 | found: &HashSet<ExGuid>, | 4347 | found: &HashSet<ExGuid>, |
| 4016 | rounding: f32, | 4348 | shape: (f32, bool), |
| 4017 | mut renaming: Option<&mut rename::Renaming>, | 4349 | mut renaming: Option<&mut rename::Renaming>, |
| 4018 | dragged: Option<PageDrag>, | 4350 | dragged: Option<PageDrag>, |
| 4019 | ) -> Rows { | 4351 | ) -> Rows { |
| ... | @@ -4058,7 +4390,7 @@ fn page_rows( | ... | @@ -4058,7 +4390,7 @@ fn page_rows( |
| 4058 | section, | 4390 | section, |
| 4059 | session, | 4391 | session, |
| 4060 | found, | 4392 | found, |
| 4061 | rounding, | 4393 | shape, |
| 4062 | renaming.as_deref_mut(), | 4394 | renaming.as_deref_mut(), |
| 4063 | (space, title, *level), | 4395 | (space, title, *level), |
| 4064 | shift, | 4396 | shift, |
| ... | @@ -4085,7 +4417,7 @@ fn page_rows( | ... | @@ -4085,7 +4417,7 @@ fn page_rows( |
| 4085 | section, | 4417 | section, |
| 4086 | session, | 4418 | session, |
| 4087 | found, | 4419 | found, |
| 4088 | rounding, | 4420 | shape, |
| 4089 | renaming, | 4421 | renaming, |
| 4090 | (space, title, *level), | 4422 | (space, title, *level), |
| 4091 | top - laid, | 4423 | top - laid, |
| ... | @@ -4106,7 +4438,7 @@ fn page_row( | ... | @@ -4106,7 +4438,7 @@ fn page_row( |
| 4106 | section: &ui::Section, | 4438 | section: &ui::Section, |
| 4107 | session: &Session, | 4439 | session: &Session, |
| 4108 | found: &HashSet<ExGuid>, | 4440 | found: &HashSet<ExGuid>, |
| 4109 | rounding: f32, | 4441 | shape: (f32, bool), |
| 4110 | renaming: Option<&mut rename::Renaming>, | 4442 | renaming: Option<&mut rename::Renaming>, |
| 4111 | (space, title, level): (&ExGuid, &String, u32), | 4443 | (space, title, level): (&ExGuid, &String, u32), |
| 4112 | shift: f32, | 4444 | shift: f32, |
| ... | @@ -4135,8 +4467,11 @@ fn page_row( | ... | @@ -4135,8 +4467,11 @@ fn page_row( |
| 4135 | if selected && !lifted { | 4467 | if selected && !lifted { |
| 4136 | rows.open = Some(ui.id(space)); | 4468 | rows.open = Some(ui.id(space)); |
| 4137 | } | 4469 | } |
| 4138 | let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, rounding); | 4470 | let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, shape); |
| 4139 | rows.kept = rows.kept.or(kept); | 4471 | rows.kept = rows.kept.or(kept); |
| 4472 | if signal.hovered && !selected { | ||
| 4473 | rows.hovered = Some(*space); | ||
| 4474 | } | ||
| 4140 | if signal.clicked && !selected { | 4475 | if signal.clicked && !selected { |
| 4141 | rows.clicked = Some(*space); | 4476 | rows.clicked = Some(*space); |
| 4142 | } | 4477 | } |
| ... | @@ -4176,7 +4511,7 @@ fn page_row( | ... | @@ -4176,7 +4511,7 @@ fn page_row( |
| 4176 | rows.open = Some(ui.id(version.context)); | 4511 | rows.open = Some(ui.id(version.context)); |
| 4177 | } | 4512 | } |
| 4178 | height += ROW; | 4513 | height += ROW; |
| 4179 | if page_tab(ui, theme, &muted, &version.context, tab, selected, rounding) | 4514 | if page_tab(ui, theme, &muted, &version.context, tab, selected, shape) |
| 4180 | .0 | 4515 | .0 |
| 4181 | .clicked | 4516 | .clicked |
| 4182 | && !selected | 4517 | && !selected |
| ... | @@ -4208,7 +4543,7 @@ fn page_row( | ... | @@ -4208,7 +4543,7 @@ fn page_row( |
| 4208 | rows.open = Some(ui.id(version.space)); | 4543 | rows.open = Some(ui.id(version.space)); |
| 4209 | } | 4544 | } |
| 4210 | height += ROW; | 4545 | height += ROW; |
| 4211 | if page_tab(ui, theme, &muted, &version.space, tab, selected, rounding) | 4546 | if page_tab(ui, theme, &muted, &version.space, tab, selected, shape) |
| 4212 | .0 | 4547 | .0 |
| 4213 | .clicked | 4548 | .clicked |
| 4214 | && !selected | 4549 | && !selected |
| ... | @@ -4237,8 +4572,9 @@ struct PageTab<'a> { | ... | @@ -4237,8 +4572,9 @@ struct PageTab<'a> { |
| 4237 | lifted: bool, | 4572 | lifted: bool, |
| 4238 | } | 4573 | } |
| 4239 | 4574 | ||
| 4240 | /// A page's tab down the frame's right side: the open one is the page's colour and joins | 4575 | /// A page's tab down the frame's side, the left where `shape` says so beside its `rounding`: |
| 4241 | /// it, the others float free of it as pills rounded like the page. | 4576 | /// the open one is the page's colour and joins it, the others float free of it as pills |
| 4577 | /// rounded like the page. | ||
| 4242 | fn page_tab( | 4578 | fn page_tab( |
| 4243 | ui: &mut Ui, | 4579 | ui: &mut Ui, |
| 4244 | theme: &Theme, | 4580 | theme: &Theme, |
| ... | @@ -4246,7 +4582,7 @@ fn page_tab( | ... | @@ -4246,7 +4582,7 @@ fn page_tab( |
| 4246 | id: &ExGuid, | 4582 | id: &ExGuid, |
| 4247 | tab: PageTab, | 4583 | tab: PageTab, |
| 4248 | selected: bool, | 4584 | selected: bool, |
| 4249 | rounding: f32, | 4585 | (rounding, left): (f32, bool), |
| 4250 | ) -> (ui::Signal, Option<bool>) { | 4586 | ) -> (ui::Signal, Option<bool>) { |
| 4251 | // A rename field's text stands where the label did. | 4587 | // A rename field's text stands where the label did. |
| 4252 | let pad = 10.0 + 16.0 * tab.indent as f32 | 4588 | let pad = 10.0 + 16.0 * tab.indent as f32 |
| ... | @@ -4280,13 +4616,20 @@ fn page_tab( | ... | @@ -4280,13 +4616,20 @@ fn page_tab( |
| 4280 | fill | 4616 | fill |
| 4281 | } | 4617 | } |
| 4282 | }; | 4618 | }; |
| 4619 | // A pill stands off the page by its margin and off the window's edge a little more. | ||
| 4620 | let [page_side, edge_side] = [PILL_MARGIN, 6.0]; | ||
| 4621 | let [start, end] = if left { | ||
| 4622 | [edge_side, page_side] | ||
| 4623 | } else { | ||
| 4624 | [page_side, edge_side] | ||
| 4625 | }; | ||
| 4283 | let spec = Spec { | 4626 | let spec = Spec { |
| 4284 | fill: Some(tint(section.tab)), | 4627 | fill: Some(tint(section.tab)), |
| 4285 | hover_fill: Some(tint(section.hover())), | 4628 | hover_fill: Some(tint(section.hover())), |
| 4286 | radius: rounding, | 4629 | radius: rounding, |
| 4287 | inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], | 4630 | inset: [start, 0.0, end, ROW_GAP], |
| 4288 | // The label keeps its place as the tab opens and closes. | 4631 | // The label keeps its place as the tab opens and closes. |
| 4289 | pad: [pad - PILL_MARGIN, 0.0], | 4632 | pad: [if left { pad } else { pad - start }, 0.0], |
| 4290 | ..spec | 4633 | ..spec |
| 4291 | }; | 4634 | }; |
| 4292 | let color = if tab.dim { | 4635 | let color = if tab.dim { |
| ... | @@ -4743,6 +5086,7 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { | ... | @@ -4743,6 +5086,7 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { |
| 4743 | Cmd::Toggle(Toggle::Subscript) => art::SUBSCRIPT, | 5086 | Cmd::Toggle(Toggle::Subscript) => art::SUBSCRIPT, |
| 4744 | Cmd::Toggle(Toggle::Superscript) => art::SUPERSCRIPT, | 5087 | Cmd::Toggle(Toggle::Superscript) => art::SUPERSCRIPT, |
| 4745 | Cmd::ClearFormatting => art::CLEAR_FORMATTING, | 5088 | Cmd::ClearFormatting => art::CLEAR_FORMATTING, |
| 5089 | Cmd::Styles => art::STYLES, | ||
| 4746 | Cmd::Highlight => art::HIGHLIGHTER, | 5090 | Cmd::Highlight => art::HIGHLIGHTER, |
| 4747 | Cmd::FontColor => art::FONT_COLOR, | 5091 | Cmd::FontColor => art::FONT_COLOR, |
| 4748 | Cmd::Bullets => art::BULLETS, | 5092 | Cmd::Bullets => art::BULLETS, |
| ... | @@ -4971,7 +5315,7 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -4971,7 +5315,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 4971 | let event = match event { | 5315 | let event = match event { |
| 4972 | UserEvent::Picture(bytes) => { | 5316 | UserEvent::Picture(bytes) => { |
| 4973 | if let Some(state) = &mut self.state { | 5317 | if let Some(state) = &mut self.state { |
| 4974 | if let Err(error) = state.insert_picture(bytes) { | 5318 | if let Err(error) = state.insert_picture(bytes, None) { |
| 4975 | eprintln!("{error}"); | 5319 | eprintln!("{error}"); |
| 4976 | } | 5320 | } |
| 4977 | state.window.request_redraw(); | 5321 | state.window.request_redraw(); |
| ... | @@ -5005,6 +5349,12 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -5005,6 +5349,12 @@ impl ApplicationHandler<UserEvent> for App { |
| 5005 | } | 5349 | } |
| 5006 | return; | 5350 | return; |
| 5007 | } | 5351 | } |
| 5352 | UserEvent::ICloudFolder => { | ||
| 5353 | if let Some(state) = &mut self.state { | ||
| 5354 | state.list_icloud(); | ||
| 5355 | } | ||
| 5356 | return; | ||
| 5357 | } | ||
| 5008 | UserEvent::ICloudAccount => { | 5358 | UserEvent::ICloudAccount => { |
| 5009 | if let Some(state) = &mut self.state { | 5359 | if let Some(state) = &mut self.state { |
| 5010 | state.icloud_account_changed(); | 5360 | state.icloud_account_changed(); |
| ... | @@ -5012,6 +5362,21 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -5012,6 +5362,21 @@ impl ApplicationHandler<UserEvent> for App { |
| 5012 | } | 5362 | } |
| 5013 | return; | 5363 | return; |
| 5014 | } | 5364 | } |
| 5365 | UserEvent::Open(paths) => { | ||
| 5366 | match &mut self.state { | ||
| 5367 | Some(state) => { | ||
| 5368 | for path in paths { | ||
| 5369 | state.open_path(&path); | ||
| 5370 | } | ||
| 5371 | state.window.set_minimized(false); | ||
| 5372 | state.window.focus_window(); | ||
| 5373 | state.window.request_redraw(); | ||
| 5374 | } | ||
| 5375 | // A cold launch's documents arrive before the window opens. | ||
| 5376 | None => self.opening.extend(paths), | ||
| 5377 | } | ||
| 5378 | return; | ||
| 5379 | } | ||
| 5015 | UserEvent::Update => { | 5380 | UserEvent::Update => { |
| 5016 | if let Some(state) = &mut self.state { | 5381 | if let Some(state) = &mut self.state { |
| 5017 | state.updated(); | 5382 | state.updated(); |
| ... | @@ -5115,10 +5480,19 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -5115,10 +5480,19 @@ impl ApplicationHandler<UserEvent> for App { |
| 5115 | if self.state.is_some() { | 5480 | if self.state.is_some() { |
| 5116 | return; | 5481 | return; |
| 5117 | } | 5482 | } |
| 5483 | let mut input = self.input.take().unwrap(); | ||
| 5484 | // The window starts at the notebook asked for rather than switching to it. | ||
| 5485 | for path in &self.opening { | ||
| 5486 | if let library::Located::Notebook { root, .. } = library::locate(path) | ||
| 5487 | && let Err(error) = input.show(&root) | ||
| 5488 | { | ||
| 5489 | eprintln!("Cannot open {}: {error}", root.display()); | ||
| 5490 | } | ||
| 5491 | } | ||
| 5118 | match pollster::block_on(State::new( | 5492 | match pollster::block_on(State::new( |
| 5119 | event_loop, | 5493 | event_loop, |
| 5120 | self.proxy.clone(), | 5494 | self.proxy.clone(), |
| 5121 | self.input.take().unwrap(), | 5495 | input, |
| 5122 | &self.substitutes, | 5496 | &self.substitutes, |
| 5123 | self.screenshot.is_none(), | 5497 | self.screenshot.is_none(), |
| 5124 | self.launch.take().expect("The window opens once"), | 5498 | self.launch.take().expect("The window opens once"), |
| ... | @@ -5129,7 +5503,12 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -5129,7 +5503,12 @@ impl ApplicationHandler<UserEvent> for App { |
| 5129 | self.startup_error = state.screenshot(prefix).err(); | 5503 | self.startup_error = state.screenshot(prefix).err(); |
| 5130 | event_loop.exit(); | 5504 | event_loop.exit(); |
| 5131 | } | 5505 | } |
| 5132 | _ => self.state = Some(state), | 5506 | _ => { |
| 5507 | for path in self.opening.drain(..) { | ||
| 5508 | state.open_path(&path); | ||
| 5509 | } | ||
| 5510 | self.state = Some(state); | ||
| 5511 | } | ||
| 5133 | }, | 5512 | }, |
| 5134 | Err(error) => { | 5513 | Err(error) => { |
| 5135 | self.startup_error = Some(error); | 5514 | self.startup_error = Some(error); |
| ... | @@ -5193,6 +5572,9 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -5193,6 +5572,9 @@ impl ApplicationHandler<UserEvent> for App { |
| 5193 | if !focused { | 5572 | if !focused { |
| 5194 | // Someone switching to another device finds their edits there. | 5573 | // Someone switching to another device finds their edits there. |
| 5195 | state.publish_now(std::time::Duration::ZERO); | 5574 | state.publish_now(std::time::Duration::ZERO); |
| 5575 | } else { | ||
| 5576 | // Notebooks another device added or removed meanwhile. | ||
| 5577 | state.list_icloud(); | ||
| 5196 | } | 5578 | } |
| 5197 | state.window.request_redraw(); | 5579 | state.window.request_redraw(); |
| 5198 | } | 5580 | } |
| ... | @@ -5247,7 +5629,7 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -5247,7 +5629,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 5247 | WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), | 5629 | WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), |
| 5248 | WindowEvent::DroppedFile(path) => { | 5630 | WindowEvent::DroppedFile(path) => { |
| 5249 | let at = platform::drop_point(&state.window); | 5631 | let at = platform::drop_point(&state.window); |
| 5250 | state.attach(&path, at)?; | 5632 | state.place_file(&path, at)?; |
| 5251 | state.window.request_redraw(); | 5633 | state.window.request_redraw(); |
| 5252 | } | 5634 | } |
| 5253 | WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?, | 5635 | WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?, |
| ... | @@ -5425,6 +5807,7 @@ fn launch() -> Result<(), Box<dyn Error>> { | ... | @@ -5425,6 +5807,7 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 5425 | let mut cache = None; | 5807 | let mut cache = None; |
| 5426 | let mut settings_file = None; | 5808 | let mut settings_file = None; |
| 5427 | let mut screenshot = None; | 5809 | let mut screenshot = None; |
| 5810 | let mut opening = Vec::new(); | ||
| 5428 | while let Some(arg) = args.next() { | 5811 | while let Some(arg) = args.next() { |
| 5429 | if arg == "--substitute-font" { | 5812 | if arg == "--substitute-font" { |
| 5430 | substitutes.push(PathBuf::from( | 5813 | substitutes.push(PathBuf::from( |
| ... | @@ -5485,6 +5868,9 @@ fn launch() -> Result<(), Box<dyn Error>> { | ... | @@ -5485,6 +5868,9 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 5485 | .to_str() | 5868 | .to_str() |
| 5486 | .ok_or("The page title must be valid Unicode.")?, | 5869 | .ok_or("The page title must be valid Unicode.")?, |
| 5487 | )?); | 5870 | )?); |
| 5871 | } else if library::locate(Path::new(&arg)) != library::Located::Nothing { | ||
| 5872 | // A file the desktop opens with Snowbound, as a double-clicked section. | ||
| 5873 | opening.push(std::path::absolute(arg)?); | ||
| 5488 | } else { | 5874 | } else { |
| 5489 | positional.push(arg); | 5875 | positional.push(arg); |
| 5490 | } | 5876 | } |
| ... | @@ -5499,7 +5885,7 @@ fn launch() -> Result<(), Box<dyn Error>> { | ... | @@ -5499,7 +5885,7 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 5499 | } | 5885 | } |
| 5500 | if positional.len() > 2 { | 5886 | if positional.len() > 2 { |
| 5501 | return Err( | 5887 | return Err( |
| 5502 | "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--screenshot PNG_PREFIX] [--substitute-font FONT_FILE]..." | 5888 | "Usage: snowbound [NOTEBOOK_FOLDER | SECTION.one | TOC.onetoc2]... [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--screenshot PNG_PREFIX] [--substitute-font FONT_FILE]..." |
| 5503 | .into(), | 5889 | .into(), |
| 5504 | ); | 5890 | ); |
| 5505 | } | 5891 | } |
| ... | @@ -5531,16 +5917,14 @@ fn launch() -> Result<(), Box<dyn Error>> { | ... | @@ -5531,16 +5917,14 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 5531 | } else if editable { | 5917 | } else if editable { |
| 5532 | Input::Page(reference.unwrap()) | 5918 | Input::Page(reference.unwrap()) |
| 5533 | } else if positional.is_empty() && reference.is_none() { | 5919 | } else if positional.is_empty() && reference.is_none() { |
| 5534 | let mut locations = saved.notebooks.clone(); | 5920 | let mut input = Input::Notebooks { |
| 5535 | let mut current = saved.current.clone(); | 5921 | locations: saved.notebooks.clone(), |
| 5922 | current: saved.current.clone(), | ||
| 5923 | }; | ||
| 5536 | if let Some(root) = notebook { | 5924 | if let Some(root) = notebook { |
| 5537 | let location = std::path::absolute(root)?.to_string_lossy().into_owned(); | 5925 | input.show(&root)?; |
| 5538 | if !locations.contains(&location) { | ||
| 5539 | locations.push(location.clone()); | ||
| 5540 | } | ||
| 5541 | current = Some(location); | ||
| 5542 | } | 5926 | } |
| 5543 | Input::Notebooks { locations, current } | 5927 | input |
| 5544 | } else { | 5928 | } else { |
| 5545 | Input::Notes { | 5929 | Input::Notes { |
| 5546 | document: TextDocument::new( | 5930 | document: TextDocument::new( |
| ... | @@ -5552,10 +5936,22 @@ fn launch() -> Result<(), Box<dyn Error>> { | ... | @@ -5552,10 +5936,22 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 5552 | reference, | 5936 | reference, |
| 5553 | } | 5937 | } |
| 5554 | }; | 5938 | }; |
| 5939 | #[cfg(not(target_os = "macos"))] | ||
| 5940 | let instance = match (&input, &screenshot) { | ||
| 5941 | (Input::Notebooks { .. }, None) => match instance::claim(&cache, &opening) { | ||
| 5942 | Some(instance) => Some(instance), | ||
| 5943 | None => return Ok(()), | ||
| 5944 | }, | ||
| 5945 | _ => None, | ||
| 5946 | }; | ||
| 5555 | if screenshot.is_some() { | 5947 | if screenshot.is_some() { |
| 5556 | screenshot::prepare(); | 5948 | screenshot::prepare(); |
| 5557 | } | 5949 | } |
| 5558 | let event_loop = platform::event_loop(screenshot.is_some())?; | 5950 | let event_loop = platform::event_loop(screenshot.is_some())?; |
| 5951 | #[cfg(not(target_os = "macos"))] | ||
| 5952 | if let Some(instance) = instance { | ||
| 5953 | instance.serve(event_loop.create_proxy()); | ||
| 5954 | } | ||
| 5559 | if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") { | 5955 | if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") { |
| 5560 | replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?; | 5956 | replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?; |
| 5561 | } | 5957 | } |
| ... | @@ -5568,6 +5964,7 @@ fn launch() -> Result<(), Box<dyn Error>> { | ... | @@ -5568,6 +5964,7 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 5568 | saved, | 5964 | saved, |
| 5569 | cache, | 5965 | cache, |
| 5570 | }), | 5966 | }), |
| 5967 | opening, | ||
| 5571 | substitutes, | 5968 | substitutes, |
| 5572 | screenshot, | 5969 | screenshot, |
| 5573 | state: None, | 5970 | state: None, |
| ... | @@ -5589,6 +5986,26 @@ mod tests { | ... | @@ -5589,6 +5986,26 @@ mod tests { |
| 5589 | use super::*; | 5986 | use super::*; |
| 5590 | use canvas::document::TextPosition; | 5987 | use canvas::document::TextPosition; |
| 5591 | 5988 | ||
| 5989 | /// A notebook opened from the desktop is listed once and shown first, whether it was | ||
| 5990 | /// listed already or not. | ||
| 5991 | #[test] | ||
| 5992 | fn an_opened_notebook_is_listed_once_and_shown() { | ||
| 5993 | let root = std::env::temp_dir(); | ||
| 5994 | let [listed, opened] = ["Listed", "Opened"].map(|name| root.join(name)); | ||
| 5995 | let location = |path: &Path| path.to_string_lossy().into_owned(); | ||
| 5996 | let mut input = Input::Notebooks { | ||
| 5997 | locations: vec![location(&listed)], | ||
| 5998 | current: None, | ||
| 5999 | }; | ||
| 6000 | input.show(&opened).unwrap(); | ||
| 6001 | input.show(&listed).unwrap(); | ||
| 6002 | let Input::Notebooks { locations, current } = input else { | ||
| 6003 | unreachable!() | ||
| 6004 | }; | ||
| 6005 | assert_eq!(locations, [location(&listed), location(&opened)]); | ||
| 6006 | assert_eq!(current, Some(location(&listed))); | ||
| 6007 | } | ||
| 6008 | |||
| 5592 | /// The ops an editing session records reach the section through `apply` and read back | 6009 | /// The ops an editing session records reach the section through `apply` and read back |
| 5593 | /// as the editor's page. | 6010 | /// as the editor's page. |
| 5594 | #[test] | 6011 | #[test] |
| ... | @@ -5778,4 +6195,80 @@ mod tests { | ... | @@ -5778,4 +6195,80 @@ mod tests { |
| 5778 | theirs.close().unwrap(); | 6195 | theirs.close().unwrap(); |
| 5779 | std::fs::remove_dir_all(&directory).unwrap(); | 6196 | std::fs::remove_dir_all(&directory).unwrap(); |
| 5780 | } | 6197 | } |
| 6198 | |||
| 6199 | /// Times opening each section of a notebook and each of its pages, phase by phase, as a | ||
| 6200 | /// switch does: `SNOWBOUND_SWITCH_NOTEBOOK=FOLDER`, or a share through `ONESTORE_SMB_LAB` | ||
| 6201 | /// (`HOST:PORT`, share `agent`) at `ONESTORE_SMB_LAB_ROOT` with `ONESTORE_SMB_LAB_USER` | ||
| 6202 | /// and `ONESTORE_SMB_LAB_PASSWORD`. The first pass starts without replicas. | ||
| 6203 | #[test] | ||
| 6204 | #[ignore = "measures a notebook named by the environment"] | ||
| 6205 | fn switch_timings() { | ||
| 6206 | let cache = std::env::temp_dir().join(format!("snowbound-switch-{}", std::process::id())); | ||
| 6207 | let _ = std::fs::remove_dir_all(&cache); | ||
| 6208 | let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") { | ||
| 6209 | Ok(folder) => Library::notebook(&folder, &cache), | ||
| 6210 | Err(_) => { | ||
| 6211 | let user = std::env::var("ONESTORE_SMB_LAB_USER").ok(); | ||
| 6212 | let mount = library::Mount { | ||
| 6213 | server: std::env::var("ONESTORE_SMB_LAB").unwrap(), | ||
| 6214 | share: "agent".into(), | ||
| 6215 | user: user.clone(), | ||
| 6216 | domain: String::new(), | ||
| 6217 | root: std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap(), | ||
| 6218 | }; | ||
| 6219 | let login = library::Login { | ||
| 6220 | user: user.unwrap_or_default(), | ||
| 6221 | password: std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(), | ||
| 6222 | domain: String::new(), | ||
| 6223 | }; | ||
| 6224 | Library::on_share(&mount.url(), mount, login, &cache).unwrap() | ||
| 6225 | } | ||
| 6226 | }; | ||
| 6227 | let ms = |start: Instant| start.elapsed().as_secs_f64() * 1e3; | ||
| 6228 | let paper = canvas::gpu::Paper { | ||
| 6229 | color: [1.0; 4], | ||
| 6230 | ink: [0.0, 0.0, 0.0, 1.0], | ||
| 6231 | }; | ||
| 6232 | let mut engine = TextEngine::default(); | ||
| 6233 | for pass in ["cold", "warm"] { | ||
| 6234 | let start = Instant::now(); | ||
| 6235 | let library = Arc::new(open()); | ||
| 6236 | eprintln!("{pass}\tnotebook\t{:.1}", ms(start)); | ||
| 6237 | for tab in library.tabs("") { | ||
| 6238 | let start = Instant::now(); | ||
| 6239 | let section = library.open(&tab.path, || {}).unwrap(); | ||
| 6240 | let opened = ms(start); | ||
| 6241 | let start = Instant::now(); | ||
| 6242 | let (session, page) = | ||
| 6243 | read_session(section, Arc::clone(&library), tab.path.clone(), None).unwrap(); | ||
| 6244 | let read = ms(start); | ||
| 6245 | let start = Instant::now(); | ||
| 6246 | let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 6247 | let laid = ms(start); | ||
| 6248 | let start = Instant::now(); | ||
| 6249 | scene.settle(Some(&editor), 2.0, paper); | ||
| 6250 | eprintln!( | ||
| 6251 | "{pass}\tsection {}\topen {opened:.1}\tread {read:.1}\tlayout {laid:.1}\tpictures {:.1}", | ||
| 6252 | tab.name, | ||
| 6253 | ms(start) | ||
| 6254 | ); | ||
| 6255 | for (space, title, _) in session.pages.iter().skip(1) { | ||
| 6256 | let start = Instant::now(); | ||
| 6257 | let page = session.section.page(*space).unwrap(); | ||
| 6258 | let read = ms(start); | ||
| 6259 | let start = Instant::now(); | ||
| 6260 | let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 6261 | let laid = ms(start); | ||
| 6262 | let start = Instant::now(); | ||
| 6263 | scene.settle(Some(&editor), 2.0, paper); | ||
| 6264 | eprintln!( | ||
| 6265 | "{pass}\tpage {title}\tread {read:.1}\tlayout {laid:.1}\tpictures {:.1}", | ||
| 6266 | ms(start) | ||
| 6267 | ); | ||
| 6268 | } | ||
| 6269 | session.section.close().unwrap(); | ||
| 6270 | } | ||
| 6271 | } | ||
| 6272 | let _ = std::fs::remove_dir_all(&cache); | ||
| 6273 | } | ||
| 5781 | } | 6274 | } |
crates/snowbound/src/manage.rs+395-116| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | //! Changes to notebooks, sections, section groups and pages, as OneNote 2010 makes them: | 1 | //! Changes to notebooks, sections, section groups and pages, as OneNote 2010 makes them: |
| 2 | //! structure through `notebook::session::Notebook`, pages as ops on the open section. | 2 | //! structure through `notebook::session::Notebook`, pages as ops on the open section. |
| 3 | 3 | ||
| 4 | use crate::{Command, Library, Loaded, State, notify, platform, read_session}; | 4 | use crate::{Command, Library, Loaded, State, notify, platform, read_session, undo::Change}; |
| 5 | use canvas::template::Template; | 5 | use canvas::template::Template; |
| 6 | use notebook::session::Notebook; | 6 | use notebook::session::Notebook; |
| 7 | use onestore::{ | 7 | use onestore::{ |
| ... | @@ -9,7 +9,7 @@ use onestore::{ | ... | @@ -9,7 +9,7 @@ use onestore::{ |
| 9 | op::{Edit, Op, PageOp, SectionOp}, | 9 | op::{Edit, Op, PageOp, SectionOp}, |
| 10 | page::{Image, Page, PageObject}, | 10 | page::{Image, Page, PageObject}, |
| 11 | }; | 11 | }; |
| 12 | use std::{error::Error, sync::Arc}; | 12 | use std::{error::Error, path::Path, sync::Arc}; |
| 13 | 13 | ||
| 14 | /// A change to a notebook's sections and groups, by catalog path. | 14 | /// A change to a notebook's sections and groups, by catalog path. |
| 15 | pub enum Structure { | 15 | pub enum Structure { |
| ... | @@ -37,6 +37,44 @@ pub enum Structure { | ... | @@ -37,6 +37,44 @@ pub enum Structure { |
| 37 | folder: String, | 37 | folder: String, |
| 38 | paths: Vec<String>, | 38 | paths: Vec<String>, |
| 39 | }, | 39 | }, |
| 40 | /// Moves a section or group back into a folder, which then takes the order `paths`, as | ||
| 41 | /// Undo takes back a move. | ||
| 42 | Return { | ||
| 43 | path: String, | ||
| 44 | folder: String, | ||
| 45 | paths: Vec<String>, | ||
| 46 | }, | ||
| 47 | /// Colours a section's tab (COLORREF); none is OneNote's None. | ||
| 48 | Color { | ||
| 49 | path: String, | ||
| 50 | color: Option<u32>, | ||
| 51 | }, | ||
| 52 | /// Notebook Properties: the display name on this computer, and a colour picked for the | ||
| 53 | /// notebook. | ||
| 54 | Properties { | ||
| 55 | name: String, | ||
| 56 | color: Option<u32>, | ||
| 57 | }, | ||
| 58 | } | ||
| 59 | |||
| 60 | /// New iCloud Notebook while it is open: the name typed, and why the last was refused. | ||
| 61 | pub struct Naming { | ||
| 62 | name: String, | ||
| 63 | problem: Option<String>, | ||
| 64 | } | ||
| 65 | |||
| 66 | fn naming() -> ui::Id { | ||
| 67 | ui::Id::ROOT.child("new icloud notebook") | ||
| 68 | } | ||
| 69 | |||
| 70 | fn naming_field() -> ui::Id { | ||
| 71 | naming().child("name") | ||
| 72 | } | ||
| 73 | |||
| 74 | /// Whether the notebook at `location` is a folder at the top of the app's iCloud Drive folder, | ||
| 75 | /// which the sidebar lists as long as it is there. | ||
| 76 | pub fn in_icloud_folder(location: &str) -> bool { | ||
| 77 | crate::icloud::folder().is_some_and(|root| Path::new(location).parent() == Some(&root)) | ||
| 40 | } | 78 | } |
| 41 | 79 | ||
| 42 | /// `current`, the path of a section, after `from` moved to `to`: a section moved itself, | 80 | /// `current`, the path of a section, after `from` moved to `to`: a section moved itself, |
| ... | @@ -69,9 +107,16 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String { | ... | @@ -69,9 +107,16 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String { |
| 69 | } | 107 | } |
| 70 | 108 | ||
| 71 | impl State { | 109 | impl State { |
| 72 | /// A page OneNote 2010 would create now: titled, with its date and time. | 110 | /// A page OneNote 2010 would create now: titled in the Default font's face, with its date |
| 73 | fn dated_page(&self, before: Option<ExGuid>) -> Result<PageCreation, Box<dyn Error>> { | 111 | /// and time. |
| 74 | let creation = PageCreation::new(before, Some(""), &self.author)?; | 112 | pub(crate) fn dated_page( |
| 113 | &self, | ||
| 114 | before: Option<ExGuid>, | ||
| 115 | ) -> Result<PageCreation, Box<dyn Error>> { | ||
| 116 | let creation = PageCreation::new(before, Some(""), &self.author)?.titled_in( | ||
| 117 | &self.view.editor.default_font.face, | ||
| 118 | self.view.editor.default_font.color, | ||
| 119 | )?; | ||
| 75 | let [date, time] = platform::date_text(creation.created()); | 120 | let [date, time] = platform::date_text(creation.created()); |
| 76 | Ok(creation.dated(&date, &time)?) | 121 | Ok(creation.dated(&date, &time)?) |
| 77 | } | 122 | } |
| ... | @@ -113,6 +158,12 @@ impl State { | ... | @@ -113,6 +158,12 @@ impl State { |
| 113 | ops, | 158 | ops, |
| 114 | }, | 159 | }, |
| 115 | )?; | 160 | )?; |
| 161 | let shown = session.space; | ||
| 162 | self.made(Change::Delete { | ||
| 163 | pages: vec![space], | ||
| 164 | show: Some(shown), | ||
| 165 | created: true, | ||
| 166 | }); | ||
| 116 | self.title_focus = Some(space); | 167 | self.title_focus = Some(space); |
| 117 | self.commands.push(Command::OpenPage(space)); | 168 | self.commands.push(Command::OpenPage(space)); |
| 118 | Ok(()) | 169 | Ok(()) |
| ... | @@ -211,23 +262,292 @@ impl State { | ... | @@ -211,23 +262,292 @@ impl State { |
| 211 | self.create_notebook(root) | 262 | self.create_notebook(root) |
| 212 | } | 263 | } |
| 213 | 264 | ||
| 214 | /// Opens the notebooks in the app's own iCloud Drive folder, or makes one there. | 265 | /// New iCloud Notebook: asks for a name, the first free of "iCloud Notebook", "iCloud |
| 215 | pub(crate) fn use_icloud(&mut self) -> Result<(), Box<dyn Error>> { | 266 | /// Notebook 2"…, for a notebook at the top of the app's iCloud Drive folder. |
| 216 | let folder = crate::icloud::folder().ok_or("iCloud Drive is off")?; | 267 | pub(crate) fn new_icloud_notebook(&mut self) { |
| 217 | let mut notebooks: Vec<std::path::PathBuf> = std::fs::read_dir(&folder)? | 268 | let taken: Vec<String> = self |
| 218 | .flatten() | 269 | .notebooks |
| 219 | .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) | 270 | .iter() |
| 220 | .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) | 271 | .map(|library| library.name.clone()) |
| 221 | .map(|entry| entry.path()) | ||
| 222 | .collect(); | 272 | .collect(); |
| 223 | notebooks.sort(); | 273 | self.new_icloud = Some(Naming { |
| 224 | if notebooks.is_empty() { | 274 | name: unused(&taken, "iCloud Notebook", false), |
| 225 | return self.create_notebook(folder.join("My Notebook")); | 275 | problem: None, |
| 276 | }); | ||
| 277 | self.ui.open_popup(naming()); | ||
| 278 | self.ui.focus_all(naming_field()); | ||
| 279 | } | ||
| 280 | |||
| 281 | /// Builds New iCloud Notebook while it is open. | ||
| 282 | pub(crate) fn new_icloud_dialog(&mut self) { | ||
| 283 | use ui::{Anchor, Axis, Spec, children, fill, px}; | ||
| 284 | let Some(dialog) = &mut self.new_icloud else { | ||
| 285 | return; | ||
| 286 | }; | ||
| 287 | if !self.ui.popup_open(naming()) { | ||
| 288 | self.new_icloud = None; | ||
| 289 | return; | ||
| 290 | } | ||
| 291 | let ui = &mut self.ui; | ||
| 292 | let theme = ui.theme.clone(); | ||
| 293 | let row = theme.font_size * 2.0; | ||
| 294 | let entered = | ||
| 295 | ui::popup::navigation(ui, &[naming_field()], &[winit::keyboard::NamedKey::Enter]) | ||
| 296 | .contains(&winit::keyboard::NamedKey::Enter); | ||
| 297 | ui.open_as( | ||
| 298 | naming(), | ||
| 299 | Spec { | ||
| 300 | axis: Axis::Y, | ||
| 301 | size: [px(320.0), children()], | ||
| 302 | fill: Some(theme.popup), | ||
| 303 | border: Some(theme.chip), | ||
| 304 | shadow: Some(theme.shadow), | ||
| 305 | radius: 8.0, | ||
| 306 | pad: [16.0, 12.0], | ||
| 307 | gap: 6.0, | ||
| 308 | anchor: Some(Anchor::Dialog), | ||
| 309 | role: Some(accesskit::Role::Dialog), | ||
| 310 | ..Spec::default() | ||
| 311 | }, | ||
| 312 | ); | ||
| 313 | if let Some(node) = ui.access(naming()) { | ||
| 314 | node.set_label("New iCloud Notebook"); | ||
| 226 | } | 315 | } |
| 227 | for notebook in notebooks { | 316 | ui.leaf( |
| 228 | self.open_notebook(notebook.to_string_lossy().into_owned(), None); | 317 | "title", |
| 318 | Spec { | ||
| 319 | size: [fill(), px(row)], | ||
| 320 | text: Some("New iCloud Notebook"), | ||
| 321 | bold: true, | ||
| 322 | role: Some(accesskit::Role::Heading), | ||
| 323 | ..Spec::default() | ||
| 324 | }, | ||
| 325 | ); | ||
| 326 | ui::text_field( | ||
| 327 | ui, | ||
| 328 | naming_field(), | ||
| 329 | &mut dialog.name, | ||
| 330 | "", | ||
| 331 | Spec { | ||
| 332 | size: [fill(), px(row)], | ||
| 333 | fill: Some(theme.base), | ||
| 334 | border: Some(theme.accent), | ||
| 335 | radius: 4.0, | ||
| 336 | pad: [6.0, 0.0], | ||
| 337 | ..Spec::default() | ||
| 338 | }, | ||
| 339 | ); | ||
| 340 | crate::name(ui, naming_field(), "Name"); | ||
| 341 | if let Some(problem) = &dialog.problem { | ||
| 342 | ui.leaf( | ||
| 343 | "problem", | ||
| 344 | Spec { | ||
| 345 | size: [fill(), ui::fit()], | ||
| 346 | text: Some(problem), | ||
| 347 | overflow: ui::Overflow::Wrap, | ||
| 348 | pad: [0.0, 4.0], | ||
| 349 | role: Some(accesskit::Role::Status), | ||
| 350 | ..Spec::default() | ||
| 351 | }, | ||
| 352 | ); | ||
| 229 | } | 353 | } |
| 230 | Ok(()) | 354 | ui.open( |
| 355 | "buttons", | ||
| 356 | Spec { | ||
| 357 | size: [fill(), children()], | ||
| 358 | pad: [0.0, 8.0], | ||
| 359 | gap: 8.0, | ||
| 360 | ..Spec::default() | ||
| 361 | }, | ||
| 362 | ); | ||
| 363 | ui.leaf( | ||
| 364 | "space", | ||
| 365 | Spec { | ||
| 366 | size: [fill(), px(1.0)], | ||
| 367 | ..Spec::default() | ||
| 368 | }, | ||
| 369 | ); | ||
| 370 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | ||
| 371 | let create = ui::button(ui, "create", "Create").clicked || entered; | ||
| 372 | ui.close(); | ||
| 373 | ui.close(); | ||
| 374 | if cancel { | ||
| 375 | self.ui.close_popup(naming()); | ||
| 376 | self.ui.set_focus(Some(crate::page())); | ||
| 377 | self.new_icloud = None; | ||
| 378 | } else if create { | ||
| 379 | let name = dialog.name.trim().to_owned(); | ||
| 380 | let folder = crate::icloud::folder(); | ||
| 381 | dialog.problem = match &folder { | ||
| 382 | None => Some("Turn on iCloud Drive in System Settings.".into()), | ||
| 383 | Some(_) if name.is_empty() => Some("Enter a name.".into()), | ||
| 384 | Some(_) if name.starts_with('.') || name.contains(['/', ':']) => { | ||
| 385 | Some("Use a name without “/”, “:” or a leading “.”.".into()) | ||
| 386 | } | ||
| 387 | Some(folder) if folder.join(&name).exists() => Some(format!( | ||
| 388 | "“{name}” is already in iCloud Drive. Choose another name." | ||
| 389 | )), | ||
| 390 | Some(_) => None, | ||
| 391 | }; | ||
| 392 | if let (None, Some(folder)) = (&dialog.problem, folder) { | ||
| 393 | self.ui.close_popup(naming()); | ||
| 394 | self.new_icloud = None; | ||
| 395 | if let Err(error) = self.create_notebook(folder.join(name)) { | ||
| 396 | platform::alert("Couldn't create the notebook", &error.to_string()); | ||
| 397 | } | ||
| 398 | } | ||
| 399 | } | ||
| 400 | } | ||
| 401 | |||
| 402 | /// Lists the folders at the top of the app's iCloud Drive folder in the sidebar, each a | ||
| 403 | /// notebook, as they come and go, and brings down what iCloud Drive keeps elsewhere of | ||
| 404 | /// every notebook in it. | ||
| 405 | pub(crate) fn list_icloud(&mut self) { | ||
| 406 | let proxy = self.proxy.clone(); | ||
| 407 | // Listing a folder iCloud Drive has not brought down yet waits for it. | ||
| 408 | std::thread::spawn(move || { | ||
| 409 | let listed = crate::icloud::folder().and_then(|root| { | ||
| 410 | let mut listed: Vec<String> = std::fs::read_dir(&root) | ||
| 411 | .ok()? | ||
| 412 | .flatten() | ||
| 413 | .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) | ||
| 414 | .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) | ||
| 415 | .map(|entry| entry.path().to_string_lossy().into_owned()) | ||
| 416 | .collect(); | ||
| 417 | listed.sort(); | ||
| 418 | Some(listed) | ||
| 419 | }); | ||
| 420 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { | ||
| 421 | if let Some(listed) = listed { | ||
| 422 | state.listed_icloud(listed); | ||
| 423 | } | ||
| 424 | for library in state.notebooks.clone() { | ||
| 425 | if library.in_icloud() { | ||
| 426 | state.fetch(library, None); | ||
| 427 | } | ||
| 428 | } | ||
| 429 | Ok(()) | ||
| 430 | }))); | ||
| 431 | }); | ||
| 432 | } | ||
| 433 | |||
| 434 | /// Takes `listed`, the notebook folders at the top of the app's iCloud Drive folder, in | ||
| 435 | /// place of those the sidebar lists from it. | ||
| 436 | fn listed_icloud(&mut self, listed: Vec<String>) { | ||
| 437 | let gone: Vec<Arc<Library>> = self | ||
| 438 | .notebooks | ||
| 439 | .iter() | ||
| 440 | .filter(|library| { | ||
| 441 | in_icloud_folder(&library.location) && !listed.contains(&library.location) | ||
| 442 | }) | ||
| 443 | .cloned() | ||
| 444 | .collect(); | ||
| 445 | for library in gone { | ||
| 446 | eprintln!("DEBUG gone {} listed {listed:?}", library.location); | ||
| 447 | self.close_notebook(&library); | ||
| 448 | } | ||
| 449 | for location in listed { | ||
| 450 | if self.notebooks.iter().any(|open| open.location == location) | ||
| 451 | || !self.icloud_reading.insert(location.clone()) | ||
| 452 | { | ||
| 453 | continue; | ||
| 454 | } | ||
| 455 | let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); | ||
| 456 | std::thread::spawn(move || { | ||
| 457 | let library = Arc::new(Library::notebook(&location, &cache)); | ||
| 458 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { | ||
| 459 | state.icloud_reading.remove(&location); | ||
| 460 | if state.notebooks.iter().any(|open| open.location == location) { | ||
| 461 | return Ok(()); | ||
| 462 | } | ||
| 463 | state.notebooks.push(Arc::clone(&library)); | ||
| 464 | state.save_settings(); | ||
| 465 | state.show_arrived(&library); | ||
| 466 | state.fetch(library, None); | ||
| 467 | Ok(()) | ||
| 468 | }))); | ||
| 469 | }); | ||
| 470 | } | ||
| 471 | } | ||
| 472 | |||
| 473 | /// Shows `library`, a notebook that just arrived or whose sections did, where no other | ||
| 474 | /// notebook is shown: at its first section, or without one while none is here yet. | ||
| 475 | pub(crate) fn show_arrived(&mut self, library: &Arc<Library>) { | ||
| 476 | if self.session.is_some() | ||
| 477 | || self | ||
| 478 | .sectionless | ||
| 479 | .as_ref() | ||
| 480 | .is_some_and(|shown| shown.location != library.location) | ||
| 481 | { | ||
| 482 | return; | ||
| 483 | } | ||
| 484 | let first = library.first_section(); | ||
| 485 | self.sectionless = Some(Arc::clone(library)); | ||
| 486 | self.title(); | ||
| 487 | if let Some(path) = first { | ||
| 488 | self.commands | ||
| 489 | .push(Command::OpenSection(Arc::clone(library), path)); | ||
| 490 | } | ||
| 491 | } | ||
| 492 | |||
| 493 | /// Brings down the files of `library`, a notebook in iCloud Drive, that iCloud Drive keeps | ||
| 494 | /// elsewhere, `missing` of them when last asked, and reads the notebook again as they | ||
| 495 | /// arrive, until all have. | ||
| 496 | pub(crate) fn fetch(&mut self, library: Arc<Library>, missing: Option<usize>) { | ||
| 497 | let Some(root) = library.folder().map(Path::to_owned) else { | ||
| 498 | return; | ||
| 499 | }; | ||
| 500 | if missing.is_none() && !self.icloud_reading.insert(library.location.clone()) { | ||
| 501 | return; | ||
| 502 | } | ||
| 503 | let proxy = self.proxy.clone(); | ||
| 504 | std::thread::spawn(move || { | ||
| 505 | if missing.is_some() { | ||
| 506 | std::thread::sleep(std::time::Duration::from_secs(2)); | ||
| 507 | } | ||
| 508 | let now = crate::icloud::download(&root); | ||
| 509 | let arrived = missing.map_or(now == 0 && library.downloading(), |before| now < before); | ||
| 510 | let read = arrived | ||
| 511 | .then(|| { | ||
| 512 | library | ||
| 513 | .reopen() | ||
| 514 | .map(|notebook| Arc::new(library.with(notebook))) | ||
| 515 | }) | ||
| 516 | .and_then(|read| { | ||
| 517 | read.inspect_err(|error| eprintln!("{}: {error}", library.location)) | ||
| 518 | .ok() | ||
| 519 | }); | ||
| 520 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { | ||
| 521 | let Some(listed) = state | ||
| 522 | .notebooks | ||
| 523 | .iter() | ||
| 524 | .find(|open| open.location == library.location) | ||
| 525 | .cloned() | ||
| 526 | else { | ||
| 527 | state.icloud_reading.remove(&library.location); | ||
| 528 | return Ok(()); | ||
| 529 | }; | ||
| 530 | let current = match read { | ||
| 531 | Some(read) => { | ||
| 532 | let path = state | ||
| 533 | .session | ||
| 534 | .as_ref() | ||
| 535 | .filter(|session| session.library.location == read.location) | ||
| 536 | .map(|session| session.tabs[session.tab].path.clone()) | ||
| 537 | .filter(|path| read.contains(path)); | ||
| 538 | state.adopt(Arc::clone(&read), path.as_deref())?; | ||
| 539 | read | ||
| 540 | } | ||
| 541 | None => listed, | ||
| 542 | }; | ||
| 543 | if now == 0 { | ||
| 544 | state.icloud_reading.remove(&current.location); | ||
| 545 | } else { | ||
| 546 | state.fetch(current, Some(now)); | ||
| 547 | } | ||
| 548 | Ok(()) | ||
| 549 | }))); | ||
| 550 | }); | ||
| 231 | } | 551 | } |
| 232 | 552 | ||
| 233 | /// Creates a notebook in the new folder `root` and opens it. | 553 | /// Creates a notebook in the new folder `root` and opens it. |
| ... | @@ -252,6 +572,7 @@ impl State { | ... | @@ -252,6 +572,7 @@ impl State { |
| 252 | /// folders go with the account. Replicas holding edits stay, the open section's also | 572 | /// folders go with the account. Replicas holding edits stay, the open section's also |
| 253 | /// exported as a recovery archive; signing in again and reopening publishes them. | 573 | /// exported as a recovery archive; signing in again and reopening publishes them. |
| 254 | pub(crate) fn icloud_account_changed(&mut self) { | 574 | pub(crate) fn icloud_account_changed(&mut self) { |
| 575 | eprintln!("DEBUG account changed"); | ||
| 255 | let closing: Vec<Arc<Library>> = self | 576 | let closing: Vec<Arc<Library>> = self |
| 256 | .notebooks | 577 | .notebooks |
| 257 | .iter() | 578 | .iter() |
| ... | @@ -281,13 +602,14 @@ impl State { | ... | @@ -281,13 +602,14 @@ impl State { |
| 281 | for library in closing { | 602 | for library in closing { |
| 282 | self.close_notebook(&library); | 603 | self.close_notebook(&library); |
| 283 | } | 604 | } |
| 284 | crate::icloud::look_up(notify(self.proxy.clone())); | 605 | crate::icloud::look_up(crate::icloud_listed(self.proxy.clone())); |
| 285 | } | 606 | } |
| 286 | 607 | ||
| 287 | /// Closes `library`: its files stay, its offline copies go unless edits wait in them, | 608 | /// Closes `library`: its files stay, its offline copies go unless edits wait in them, |
| 288 | /// and the sidebar and the next launch leave it out. | 609 | /// and the sidebar and the next launch leave it out. |
| 289 | pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) { | 610 | pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) { |
| 290 | self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); | 611 | self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); |
| 612 | self.undo.close(&library.location); | ||
| 291 | if let Some(background) = &library.background { | 613 | if let Some(background) = &library.background { |
| 292 | background.discard(); | 614 | background.discard(); |
| 293 | } | 615 | } |
| ... | @@ -336,6 +658,8 @@ impl State { | ... | @@ -336,6 +658,8 @@ impl State { |
| 336 | .map(|session| session.tabs[session.tab].path.clone()); | 658 | .map(|session| session.tabs[session.tab].path.clone()); |
| 337 | let notify = notify(self.proxy.clone()); | 659 | let notify = notify(self.proxy.clone()); |
| 338 | self.load(move || { | 660 | self.load(move || { |
| 661 | // A section kept open would hold a file the change moves. | ||
| 662 | library.close_kept(); | ||
| 339 | let mut notebook = library.reopen()?; | 663 | let mut notebook = library.reopen()?; |
| 340 | let names = |notebook: &Notebook, folder: &str| -> Vec<String> { | 664 | let names = |notebook: &Notebook, folder: &str| -> Vec<String> { |
| 341 | let mut folders = vec![notebook.catalog()]; | 665 | let mut folders = vec![notebook.catalog()]; |
| ... | @@ -356,6 +680,11 @@ impl State { | ... | @@ -356,6 +680,11 @@ impl State { |
| 356 | } | 680 | } |
| 357 | Vec::new() | 681 | Vec::new() |
| 358 | }; | 682 | }; |
| 683 | // A notebook not shown stays so through a change of its colours or name. | ||
| 684 | let stays = matches!( | ||
| 685 | change, | ||
| 686 | Structure::Color { .. } | Structure::Properties { .. } | ||
| 687 | ); | ||
| 359 | // The open section where the change leaves it, and the section to show. | 688 | // The open section where the change leaves it, and the section to show. |
| 360 | let (followed, created) = match change { | 689 | let (followed, created) = match change { |
| 361 | Structure::NewSection { folder } => { | 690 | Structure::NewSection { folder } => { |
| ... | @@ -391,10 +720,36 @@ impl State { | ... | @@ -391,10 +720,36 @@ impl State { |
| 391 | notebook.reorder(&folder, &paths)?; | 720 | notebook.reorder(&folder, &paths)?; |
| 392 | (current, None) | 721 | (current, None) |
| 393 | } | 722 | } |
| 723 | Structure::Return { | ||
| 724 | path, | ||
| 725 | folder, | ||
| 726 | paths, | ||
| 727 | } => { | ||
| 728 | let moved = notebook.move_entry(&path, &folder)?; | ||
| 729 | let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); | ||
| 730 | notebook.reorder(&folder, &paths)?; | ||
| 731 | (current.map(|current| follow(&current, &path, &moved)), None) | ||
| 732 | } | ||
| 733 | Structure::Color { path, color } => { | ||
| 734 | notebook.set_section_color(&path, color)?; | ||
| 735 | (current, None) | ||
| 736 | } | ||
| 737 | Structure::Properties { name, color } => { | ||
| 738 | if name != library.name { | ||
| 739 | library.set_display_name(&name)?; | ||
| 740 | } | ||
| 741 | if let Some(color) = color { | ||
| 742 | notebook.set_color(color)?; | ||
| 743 | } | ||
| 744 | (current, None) | ||
| 745 | } | ||
| 394 | }; | 746 | }; |
| 395 | let fresh = created.is_some(); | 747 | let fresh = created.is_some(); |
| 396 | let open = created.or(followed.clone()); | 748 | let open = created.or(followed.clone()); |
| 397 | let library = Arc::new(library.with(notebook)); | 749 | let library = Arc::new(library.with(notebook)); |
| 750 | if stays && followed.is_none() { | ||
| 751 | return Ok(Loaded::Library(library, None)); | ||
| 752 | } | ||
| 398 | let Some(path) = shown(&library, open) else { | 753 | let Some(path) = shown(&library, open) else { |
| 399 | return Ok(Loaded::Library(library, None)); | 754 | return Ok(Loaded::Library(library, None)); |
| 400 | }; | 755 | }; |
| ... | @@ -416,115 +771,38 @@ impl State { | ... | @@ -416,115 +771,38 @@ impl State { |
| 416 | /// Deletes pages of the open section as OneNote 2010 does: each goes to the notebook's | 771 | /// Deletes pages of the open section as OneNote 2010 does: each goes to the notebook's |
| 417 | /// recycle bin, then leaves the section. A section left without pages gains a new one. | 772 | /// recycle bin, then leaves the section. A section left without pages gains a new one. |
| 418 | pub(crate) fn delete_pages(&mut self, spaces: Vec<ExGuid>) -> Result<(), Box<dyn Error>> { | 773 | pub(crate) fn delete_pages(&mut self, spaces: Vec<ExGuid>) -> Result<(), Box<dyn Error>> { |
| 419 | self.persist()?; | 774 | self.change_pages(Change::Delete { |
| 420 | let session = self.session.as_ref().ok_or("No section is open")?; | 775 | pages: spaces, |
| 421 | let pages: Vec<Page> = spaces | 776 | show: None, |
| 422 | .iter() | 777 | created: false, |
| 423 | .map(|space| session.section.page(*space)) | 778 | }) |
| 424 | .collect::<Result<_, _>>()?; | ||
| 425 | let remaining: Vec<ExGuid> = session | ||
| 426 | .pages | ||
| 427 | .iter() | ||
| 428 | .map(|(space, ..)| *space) | ||
| 429 | .filter(|space| !spaces.contains(space)) | ||
| 430 | .collect(); | ||
| 431 | // OneNote shows the page after the first deleted one, or the one before it. | ||
| 432 | let at = session | ||
| 433 | .pages | ||
| 434 | .iter() | ||
| 435 | .position(|(space, ..)| spaces.contains(space)) | ||
| 436 | .unwrap_or_default(); | ||
| 437 | let next = session.pages[at..] | ||
| 438 | .iter() | ||
| 439 | .chain(session.pages[..at].iter().rev()) | ||
| 440 | .map(|(space, ..)| *space) | ||
| 441 | .find(|space| remaining.contains(space)); | ||
| 442 | let mut ops = vec![Op::Section(SectionOp::Delete(spaces))]; | ||
| 443 | let next = match next { | ||
| 444 | Some(next) => next, | ||
| 445 | None => { | ||
| 446 | let creation = self.dated_page(None)?; | ||
| 447 | let space = creation.space(); | ||
| 448 | ops.push(Op::Section(SectionOp::Create(creation))); | ||
| 449 | space | ||
| 450 | } | ||
| 451 | }; | ||
| 452 | let notebook = | ||
| 453 | matches!(session.library.notebook, Ok(Some(_))).then(|| Arc::clone(&session.library)); | ||
| 454 | let replica = Arc::clone(session.section.replica()); | ||
| 455 | let author = self.author.clone(); | ||
| 456 | self.load(move || { | ||
| 457 | if let Some(library) = notebook { | ||
| 458 | library.reopen()?.recycle_pages(&pages, &author)?; | ||
| 459 | } | ||
| 460 | replica.apply( | ||
| 461 | &author, | ||
| 462 | Edit { | ||
| 463 | at: crate::filetime(), | ||
| 464 | ops, | ||
| 465 | }, | ||
| 466 | )?; | ||
| 467 | Ok(Loaded::Page(next, replica.page(next)?)) | ||
| 468 | }); | ||
| 469 | Ok(()) | ||
| 470 | } | 779 | } |
| 471 | 780 | ||
| 472 | /// Moves page `space` of the open section to the end of the section at catalog `path`, | 781 | /// Moves page `space` of the open section to the end of the section at catalog `path`, |
| 473 | /// as OneNote moves a page dropped on a section's tab: the page keeps its identity, | 782 | /// as OneNote moves a page dropped on a section's tab: the page keeps its identity, |
| 474 | /// title, date and content there and leaves this section. | 783 | /// title, date and content there and leaves this section. |
| 475 | pub(crate) fn move_page(&mut self, space: ExGuid, path: String) -> Result<(), Box<dyn Error>> { | 784 | pub(crate) fn move_page(&mut self, space: ExGuid, path: String) -> Result<(), Box<dyn Error>> { |
| 476 | self.persist()?; | ||
| 477 | let session = self.session.as_ref().ok_or("No section is open")?; | 785 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 478 | let import = notebook::session::moved(&session.section.page(space)?, &self.author)?; | 786 | let target = session |
| 479 | let at = session | 787 | .library |
| 480 | .pages | 788 | .section_identity(&path) |
| 481 | .iter() | 789 | .ok_or("That section is not in the notebook")?; |
| 482 | .position(|(listed, ..)| *listed == space) | 790 | self.change_pages(Change::Send { |
| 483 | .unwrap_or_default(); | 791 | page: space, |
| 484 | let next = session.pages[at + 1..] | 792 | target, |
| 485 | .iter() | 793 | }) |
| 486 | .chain(session.pages[..at].iter().rev()) | ||
| 487 | .map(|(listed, ..)| *listed) | ||
| 488 | .next(); | ||
| 489 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; | ||
| 490 | let next = match next { | ||
| 491 | Some(next) => next, | ||
| 492 | None => { | ||
| 493 | let fresh = self.dated_page(None)?; | ||
| 494 | let space = fresh.space(); | ||
| 495 | ops.push(Op::Section(SectionOp::Create(fresh))); | ||
| 496 | space | ||
| 497 | } | ||
| 498 | }; | ||
| 499 | let library = Arc::clone(&session.library); | ||
| 500 | let replica = Arc::clone(session.section.replica()); | ||
| 501 | let author = self.author.clone(); | ||
| 502 | self.load(move || { | ||
| 503 | let target = library.open(&path, || {})?; | ||
| 504 | target.replica().apply( | ||
| 505 | &author, | ||
| 506 | Edit { | ||
| 507 | at: crate::filetime(), | ||
| 508 | ops: vec![import], | ||
| 509 | }, | ||
| 510 | )?; | ||
| 511 | target.close()?; | ||
| 512 | replica.apply( | ||
| 513 | &author, | ||
| 514 | Edit { | ||
| 515 | at: crate::filetime(), | ||
| 516 | ops, | ||
| 517 | }, | ||
| 518 | )?; | ||
| 519 | Ok(Loaded::Page(next, replica.page(next)?)) | ||
| 520 | }); | ||
| 521 | Ok(()) | ||
| 522 | } | 794 | } |
| 523 | 795 | ||
| 524 | /// Moves or indents pages of the open section, in order. | 796 | /// Moves or indents pages of the open section, in order. |
| 525 | pub(crate) fn edit_pages(&mut self, edits: Vec<PageEdit>) -> Result<(), Box<dyn Error>> { | 797 | pub(crate) fn edit_pages(&mut self, edits: Vec<PageEdit>) -> Result<(), Box<dyn Error>> { |
| 526 | self.persist()?; | 798 | self.persist()?; |
| 527 | let session = self.session.as_mut().ok_or("No section is open")?; | 799 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 800 | let order = session | ||
| 801 | .pages | ||
| 802 | .iter() | ||
| 803 | .map(|(space, _, level)| (*space, *level)) | ||
| 804 | .collect(); | ||
| 805 | let moved = edits.iter().map(PageEdit::space).collect(); | ||
| 528 | session.section.apply( | 806 | session.section.apply( |
| 529 | &self.author, | 807 | &self.author, |
| 530 | Edit { | 808 | Edit { |
| ... | @@ -533,6 +811,7 @@ impl State { | ... | @@ -533,6 +811,7 @@ impl State { |
| 533 | }, | 811 | }, |
| 534 | )?; | 812 | )?; |
| 535 | session.pages = session.section.pages()?; | 813 | session.pages = session.section.pages()?; |
| 814 | self.made(Change::Arrange { order, moved }); | ||
| 536 | Ok(()) | 815 | Ok(()) |
| 537 | } | 816 | } |
| 538 | } | 817 | } |
crates/snowbound/src/meeting.rs+1| ... | @@ -102,6 +102,7 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> { | ... | @@ -102,6 +102,7 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> { |
| 102 | Definition { | 102 | Definition { |
| 103 | kind: Kind::Style { | 103 | kind: Kind::Style { |
| 104 | name: Some("p".into()), | 104 | name: Some("p".into()), |
| 105 | next: None, | ||
| 105 | }, | 106 | }, |
| 106 | format: Format { | 107 | format: Format { |
| 107 | language: None, | 108 | language: None, |
crates/snowbound/src/menubar.rs+34-7| ... | @@ -68,7 +68,6 @@ const MENUS: &[Item] = &[ | ... | @@ -68,7 +68,6 @@ const MENUS: &[Item] = &[ |
| 68 | C(Id::ShowNotebook), | 68 | C(Id::ShowNotebook), |
| 69 | S, | 69 | S, |
| 70 | C(Id::ExportPdf), | 70 | C(Id::ExportPdf), |
| 71 | C(Id::ExportSectionPdf), | ||
| 72 | S, | 71 | S, |
| 73 | C(Id::Print), | 72 | C(Id::Print), |
| 74 | ], | 73 | ], |
| ... | @@ -158,6 +157,31 @@ const MENUS: &[Item] = &[ | ... | @@ -158,6 +157,31 @@ const MENUS: &[Item] = &[ |
| 158 | Item::Menu( | 157 | Item::Menu( |
| 159 | "Format", | 158 | "Format", |
| 160 | &[ | 159 | &[ |
| 160 | Item::Menu( | ||
| 161 | "Style", | ||
| 162 | &[ | ||
| 163 | C(Id::Style(0)), | ||
| 164 | C(Id::Style(1)), | ||
| 165 | C(Id::Style(2)), | ||
| 166 | C(Id::Style(3)), | ||
| 167 | C(Id::Style(4)), | ||
| 168 | C(Id::Style(5)), | ||
| 169 | C(Id::Style(6)), | ||
| 170 | C(Id::Style(7)), | ||
| 171 | C(Id::Style(8)), | ||
| 172 | C(Id::Style(9)), | ||
| 173 | C(Id::Style(10)), | ||
| 174 | ], | ||
| 175 | ), | ||
| 176 | Item::Menu( | ||
| 177 | "Theme", | ||
| 178 | &[ | ||
| 179 | C(Id::Theme(crate::themes::Scope::Page)), | ||
| 180 | C(Id::Theme(crate::themes::Scope::Section)), | ||
| 181 | C(Id::Theme(crate::themes::Scope::Notebook)), | ||
| 182 | ], | ||
| 183 | ), | ||
| 184 | S, | ||
| 161 | Item::Menu("Font", &[Item::Fonts]), | 185 | Item::Menu("Font", &[Item::Fonts]), |
| 162 | Item::Menu("Size", &[Item::Sizes]), | 186 | Item::Menu("Size", &[Item::Sizes]), |
| 163 | S, | 187 | S, |
| ... | @@ -264,11 +288,10 @@ fn menu( | ... | @@ -264,11 +288,10 @@ fn menu( |
| 264 | for item in items { | 288 | for item in items { |
| 265 | match item { | 289 | match item { |
| 266 | Item::Command(id) => { | 290 | Item::Command(id) => { |
| 267 | let command = commands::command(*id); | ||
| 268 | choose( | 291 | choose( |
| 269 | &Choice::Command(*id), | 292 | &Choice::Command(*id), |
| 270 | command.title, | 293 | commands::command(*id).title, |
| 271 | command.mac.first().copied(), | 294 | commands::chords(*id).first().copied(), |
| 272 | ); | 295 | ); |
| 273 | } | 296 | } |
| 274 | Item::System(title, action, chord) => { | 297 | Item::System(title, action, chord) => { |
| ... | @@ -320,8 +343,12 @@ pub fn tags(mtm: MainThreadMarker, target: &AnyObject, menu: &NSMenu, tags: &[No | ... | @@ -320,8 +343,12 @@ pub fn tags(mtm: MainThreadMarker, target: &AnyObject, menu: &NSMenu, tags: &[No |
| 320 | menu.addItem(&NSMenuItem::separatorItem(mtm)); | 343 | menu.addItem(&NSMenuItem::separatorItem(mtm)); |
| 321 | } | 344 | } |
| 322 | for id in [Id::CustomizeTags, Id::RemoveTags, Id::FindTags] { | 345 | for id in [Id::CustomizeTags, Id::RemoveTags, Id::FindTags] { |
| 323 | let command = commands::command(id); | 346 | choose( |
| 324 | choose(menu, id, command.title, command.mac.first().copied()); | 347 | menu, |
| 348 | id, | ||
| 349 | commands::command(id).title, | ||
| 350 | commands::chords(id).first().copied(), | ||
| 351 | ); | ||
| 325 | } | 352 | } |
| 326 | } | 353 | } |
| 327 | 354 | ||
| ... | @@ -451,7 +478,7 @@ mod tests { | ... | @@ -451,7 +478,7 @@ mod tests { |
| 451 | } | 478 | } |
| 452 | // The keyboard's first chord for each command is on its menu item. | 479 | // The keyboard's first chord for each command is on its menu item. |
| 453 | for command in commands::COMMANDS { | 480 | for command in commands::COMMANDS { |
| 454 | let Some(chord) = command.mac.first() else { | 481 | let Some(chord) = commands::chords(command.id).first().copied() else { |
| 455 | continue; | 482 | continue; |
| 456 | }; | 483 | }; |
| 457 | let line = tree | 484 | let line = tree |
crates/snowbound/src/menus.rs+620-116| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | //! Context menus on pages, sections, section groups and notebooks, with OneNote 2010's | 1 | //! Context menus on pages, sections, section groups and notebooks, with OneNote 2010's |
| 2 | //! commands in its words, and moving pages by dragging their tabs. | 2 | //! commands in its words, and moving pages by dragging their tabs. |
| 3 | 3 | ||
| 4 | use crate::{Command, Library, State, manage::Structure, platform}; | 4 | use crate::{ |
| 5 | Command, Library, State, | ||
| 6 | commands::{self, Choice}, | ||
| 7 | manage::Structure, | ||
| 8 | platform, | ||
| 9 | }; | ||
| 5 | use onestore::{ExGuid, PageEdit}; | 10 | use onestore::{ExGuid, PageEdit}; |
| 6 | use std::sync::Arc; | 11 | use std::sync::Arc; |
| 7 | use ui::{Anchor, Id, popup::Item}; | 12 | use ui::{Anchor, Id, popup::Item}; |
| ... | @@ -46,12 +51,94 @@ impl Drag { | ... | @@ -46,12 +51,94 @@ impl Drag { |
| 46 | } | 51 | } |
| 47 | } | 52 | } |
| 48 | 53 | ||
| 49 | /// What a context menu was opened on. | 54 | /// What a context menu, or the palette's actions, are on. |
| 55 | #[derive(Clone)] | ||
| 50 | pub enum Target { | 56 | pub enum Target { |
| 51 | Page(ExGuid), | 57 | /// A page of a section, open or not. |
| 52 | Section { library: Arc<Library>, path: String }, | 58 | Page { |
| 53 | Group { library: Arc<Library>, path: String }, | 59 | library: Arc<Library>, |
| 60 | path: String, | ||
| 61 | space: ExGuid, | ||
| 62 | }, | ||
| 63 | Section { | ||
| 64 | library: Arc<Library>, | ||
| 65 | path: String, | ||
| 66 | }, | ||
| 67 | Group { | ||
| 68 | library: Arc<Library>, | ||
| 69 | path: String, | ||
| 70 | }, | ||
| 54 | Notebook(Arc<Library>), | 71 | Notebook(Arc<Library>), |
| 72 | /// A notebook shown lately but closed since, by location. | ||
| 73 | Closed(String), | ||
| 74 | /// A server saved to reconnect to, by address. | ||
| 75 | Server(String), | ||
| 76 | Command(commands::Id), | ||
| 77 | } | ||
| 78 | |||
| 79 | /// What can be done to a target, as its context menu and the palette's actions list it. | ||
| 80 | #[derive(Clone, Debug, PartialEq)] | ||
| 81 | pub enum Action { | ||
| 82 | Open, | ||
| 83 | Run, | ||
| 84 | Rename, | ||
| 85 | Delete, | ||
| 86 | /// Opens the submenu of the places `MoveTo` takes it. | ||
| 87 | Move, | ||
| 88 | /// Moves it to a section, or into a folder, by catalog path. | ||
| 89 | MoveTo(String), | ||
| 90 | CopyLink, | ||
| 91 | NewPage, | ||
| 92 | NewSubpage, | ||
| 93 | MakeSubpage, | ||
| 94 | PromoteSubpage, | ||
| 95 | Versions(bool), | ||
| 96 | NewSection, | ||
| 97 | NewGroup, | ||
| 98 | Reveal, | ||
| 99 | Close, | ||
| 100 | /// Opens the Themes dialog for it. | ||
| 101 | Theme, | ||
| 102 | /// Opens the notebook's sync status. | ||
| 103 | SyncStatus, | ||
| 104 | /// Opens the submenu of the colours `Color` gives a section. | ||
| 105 | Colors, | ||
| 106 | /// A section's colour, COLORREF; none is OneNote's None. | ||
| 107 | Color(Option<u32>), | ||
| 108 | Sync, | ||
| 109 | NewNotebook, | ||
| 110 | /// Moves a notebook up the notebook list, or down. | ||
| 111 | Raise(bool), | ||
| 112 | Properties, | ||
| 113 | } | ||
| 114 | |||
| 115 | /// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section | ||
| 116 | /// Color menu's order (`corpus/section-color/native`). | ||
| 117 | pub(crate) const SECTION_COLORS: [(u32, &str); 16] = [ | ||
| 118 | (0xe4a88a, "Blue"), | ||
| 119 | (0x69d8ff, "Yellow"), | ||
| 120 | (0x97c9b7, "Green"), | ||
| 121 | (0x9795ee, "Red"), | ||
| 122 | (0xde9eb4, "Purple"), | ||
| 123 | (0xaeba91, "Cyan"), | ||
| 124 | (0x78b0f6, "Orange"), | ||
| 125 | (0xbba4d5, "Magenta"), | ||
| 126 | (0xd2bb9b, "Blue Mist"), | ||
| 127 | (0xb79cab, "Purple Mist"), | ||
| 128 | (0x99d1e8, "Tan"), | ||
| 129 | (0x6ff9f5, "Lemon"), | ||
| 130 | (0x92e7ad, "Apple"), | ||
| 131 | (0xcabc4d, "Teal"), | ||
| 132 | (0x7575ba, "Red Chalk"), | ||
| 133 | (0xaa9595, "Silver"), | ||
| 134 | ]; | ||
| 135 | |||
| 136 | /// A place Move offers. | ||
| 137 | struct Destination { | ||
| 138 | name: String, | ||
| 139 | path: String, | ||
| 140 | icon: &'static [&'static str], | ||
| 141 | tint: Option<[f32; 4]>, | ||
| 55 | } | 142 | } |
| 56 | 143 | ||
| 57 | pub fn id() -> Id { | 144 | pub fn id() -> Id { |
| ... | @@ -134,115 +221,328 @@ impl State { | ... | @@ -134,115 +221,328 @@ impl State { |
| 134 | self.menu = None; | 221 | self.menu = None; |
| 135 | return; | 222 | return; |
| 136 | } | 223 | } |
| 137 | let item = |text| Item { | 224 | let (target, point) = (target.clone(), *point); |
| 138 | text, | 225 | let mut actions: Vec<(Action, Item)> = self.actions(&target); |
| 139 | ..Default::default() | 226 | // A notebook's menu heads with its sync status in a line, which opens the whole. |
| 227 | let status = match &target { | ||
| 228 | Target::Notebook(library) => self.session.as_ref().map(|session| { | ||
| 229 | let (text, icon) = crate::sync::line(library, session); | ||
| 230 | (Arc::clone(library), text, icon) | ||
| 231 | }), | ||
| 232 | _ => None, | ||
| 140 | }; | 233 | }; |
| 141 | let items: Vec<Item> = match target { | 234 | if let Some((_, text, icon)) = &status { |
| 142 | Target::Page(space) => { | 235 | actions[0].1.separated = true; |
| 143 | let pages = self | 236 | let item = Item { |
| 144 | .session | 237 | text, |
| 145 | .as_ref() | 238 | icon: Some(*icon), |
| 146 | .map_or(&[][..], |session| &session.pages[..]); | 239 | ..Item::default() |
| 240 | }; | ||
| 241 | actions.insert(0, (Action::SyncStatus, item)); | ||
| 242 | } | ||
| 243 | let action = self.action_menu(id(), Anchor::Point(point), None, &target, &actions); | ||
| 244 | match (action, status) { | ||
| 245 | (Some(Action::SyncStatus), Some((library, ..))) => { | ||
| 246 | self.menu = None; | ||
| 247 | self.ui.close_popup(id()); | ||
| 248 | self.show_sync(library, point); | ||
| 249 | } | ||
| 250 | (Some(action), _) => { | ||
| 251 | self.menu = None; | ||
| 252 | self.act_on(target, action); | ||
| 253 | } | ||
| 254 | (None, _) => {} | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 258 | /// What can be done to `target`, in its context menu's order, as each shows there. | ||
| 259 | pub(crate) fn actions(&self, target: &Target) -> Vec<(Action, Item<'static>)> { | ||
| 260 | // An action, its label, whether it is disabled, and whether a rule starts its group. | ||
| 261 | let item = |action: Action, text, disabled, separated| { | ||
| 262 | let submenu = matches!(action, Action::Move | Action::Colors); | ||
| 263 | let item = Item { | ||
| 264 | text, | ||
| 265 | disabled, | ||
| 266 | separated, | ||
| 267 | submenu, | ||
| 268 | ..Item::default() | ||
| 269 | }; | ||
| 270 | (action, item) | ||
| 271 | }; | ||
| 272 | let nowhere = || self.destinations(target).is_empty(); | ||
| 273 | match target { | ||
| 274 | Target::Page { | ||
| 275 | library, | ||
| 276 | path, | ||
| 277 | space, | ||
| 278 | } => { | ||
| 279 | let mut actions = vec![ | ||
| 280 | item(Action::Rename, "Rename", false, false), | ||
| 281 | item(Action::Delete, "Delete", false, false), | ||
| 282 | item(Action::Move, "Move to Section", nowhere(), false), | ||
| 283 | item(Action::CopyLink, "Copy Link to Page", false, true), | ||
| 284 | item(Action::NewPage, "New Page", false, true), | ||
| 285 | // OneNote offers this beside New Page, which is a plain + here. | ||
| 286 | item(Action::NewSubpage, "New Subpage", false, false), | ||
| 287 | item(Action::Theme, "Theme…", false, true), | ||
| 288 | ]; | ||
| 289 | // Levels and versions are known once the section is open. | ||
| 290 | let Some(session) = self.session.as_ref().filter(|_| self.open(library, path)) | ||
| 291 | else { | ||
| 292 | return actions; | ||
| 293 | }; | ||
| 294 | let pages = &session.pages; | ||
| 147 | let at = pages.iter().position(|(listed, ..)| listed == space); | 295 | let at = pages.iter().position(|(listed, ..)| listed == space); |
| 148 | let level = at.map_or(1, |at| pages[at].2); | 296 | let level = at.map_or(1, |at| pages[at].2); |
| 149 | let above = at | 297 | let above = at |
| 150 | .and_then(|at| at.checked_sub(1)) | 298 | .and_then(|at| at.checked_sub(1)) |
| 151 | .map_or(0, |above| pages[above].2); | 299 | .map_or(0, |above| pages[above].2); |
| 152 | let (versions, shown) = self.session.as_ref().map_or((false, false), |session| { | 300 | let shown = session.shown_history == Some(*space); |
| 153 | ( | 301 | let versions = if shown { |
| 154 | !session.page_versions(*space).is_empty(), | 302 | "Hide Page Versions" |
| 155 | session.shown_history == Some(*space), | 303 | } else { |
| 156 | ) | 304 | "Show Page Versions" |
| 157 | }); | 305 | }; |
| 306 | actions.extend([ | ||
| 307 | item( | ||
| 308 | Action::MakeSubpage, | ||
| 309 | "Make Subpage", | ||
| 310 | level > above || level >= 3, | ||
| 311 | true, | ||
| 312 | ), | ||
| 313 | item(Action::PromoteSubpage, "Promote Subpage", level <= 1, false), | ||
| 314 | item( | ||
| 315 | Action::Versions(!shown), | ||
| 316 | versions, | ||
| 317 | session.page_versions(*space).is_empty(), | ||
| 318 | true, | ||
| 319 | ), | ||
| 320 | ]); | ||
| 321 | actions | ||
| 322 | } | ||
| 323 | Target::Section { library, path } | Target::Group { library, path } => { | ||
| 324 | let mut actions = vec![ | ||
| 325 | item(Action::Rename, "Rename", false, false), | ||
| 326 | item(Action::Delete, "Delete", false, false), | ||
| 327 | item(Action::Move, "Move", nowhere(), false), | ||
| 328 | item(Action::NewSection, "New Section", false, true), | ||
| 329 | item(Action::NewGroup, "New Section Group", false, false), | ||
| 330 | ]; | ||
| 331 | if matches!(target, Target::Section { .. }) { | ||
| 332 | actions.extend([ | ||
| 333 | item(Action::Colors, "Section Color", false, true), | ||
| 334 | item( | ||
| 335 | Action::Reveal, | ||
| 336 | platform::SHOW_FILE, | ||
| 337 | section_file(library, path).is_none(), | ||
| 338 | false, | ||
| 339 | ), | ||
| 340 | item(Action::Theme, "Theme…", false, false), | ||
| 341 | ]); | ||
| 342 | } | ||
| 343 | actions | ||
| 344 | } | ||
| 345 | Target::Notebook(library) => { | ||
| 346 | let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library)); | ||
| 347 | let last = self.notebooks.len().saturating_sub(1); | ||
| 158 | vec![ | 348 | vec![ |
| 159 | item("Delete"), | 349 | item(Action::Rename, "Rename…", false, false), |
| 160 | Item { | 350 | item( |
| 161 | separated: true, | 351 | Action::Sync, |
| 162 | ..item("Copy Link to Page") | 352 | "Sync This Notebook Now", |
| 163 | }, | 353 | library.background.is_none(), |
| 164 | Item { | 354 | false, |
| 165 | separated: true, | 355 | ), |
| 166 | ..item("New Page") | 356 | // The app's iCloud Drive folder lists every notebook in it. |
| 167 | }, | 357 | item( |
| 168 | // OneNote offers this beside New Page, which is a plain + here. | 358 | Action::Close, |
| 169 | item("New Subpage"), | 359 | "Close This Notebook", |
| 170 | Item { | 360 | crate::manage::in_icloud_folder(&library.location), |
| 171 | separated: true, | 361 | false, |
| 172 | disabled: level > above || level >= 3, | 362 | ), |
| 173 | ..item("Make Subpage") | 363 | item(Action::CopyLink, "Copy Link to Notebook", false, true), |
| 174 | }, | 364 | item(Action::NewSection, "New Section", false, true), |
| 175 | Item { | 365 | item(Action::NewGroup, "New Section Group", false, false), |
| 176 | disabled: level <= 1, | 366 | item( |
| 177 | ..item("Promote Subpage") | 367 | Action::NewNotebook, |
| 178 | }, | 368 | commands::command(commands::Id::NewNotebook).title, |
| 179 | Item { | 369 | false, |
| 180 | separated: true, | 370 | false, |
| 181 | disabled: !versions, | 371 | ), |
| 182 | ..item(if shown { | 372 | item( |
| 183 | "Hide Page Versions" | 373 | Action::Raise(true), |
| 184 | } else { | 374 | "Move Up", |
| 185 | "Show Page Versions" | 375 | listed.is_none_or(|at| at == 0), |
| 186 | }) | 376 | true, |
| 187 | }, | 377 | ), |
| 378 | item( | ||
| 379 | Action::Raise(false), | ||
| 380 | "Move Down", | ||
| 381 | listed.is_none_or(|at| at == last), | ||
| 382 | false, | ||
| 383 | ), | ||
| 384 | item( | ||
| 385 | Action::Reveal, | ||
| 386 | commands::command(commands::Id::ShowNotebook).title, | ||
| 387 | library.folder().is_none(), | ||
| 388 | true, | ||
| 389 | ), | ||
| 390 | item(Action::Theme, "Theme…", false, false), | ||
| 391 | item( | ||
| 392 | Action::Properties, | ||
| 393 | "Properties…", | ||
| 394 | library.catalog().is_none(), | ||
| 395 | false, | ||
| 396 | ), | ||
| 188 | ] | 397 | ] |
| 189 | } | 398 | } |
| 190 | Target::Section { .. } | Target::Group { .. } => vec![ | 399 | Target::Closed(_) | Target::Server(_) => { |
| 191 | item("Rename"), | 400 | vec![item(Action::Delete, "Remove from Recent", false, false)] |
| 192 | item("Delete"), | 401 | } |
| 193 | Item { | 402 | Target::Command(_) => Vec::new(), |
| 194 | separated: true, | 403 | } |
| 195 | ..item("New Section") | 404 | } |
| 196 | }, | 405 | |
| 197 | item("New Section Group"), | 406 | /// Builds menu `id` of `actions` on `target` at `anchor`, under a filter field showing |
| 198 | ], | 407 | /// `filter` when given, with Move's places in its submenu: the action chosen. |
| 199 | Target::Notebook(_) => vec![ | 408 | pub(crate) fn action_menu( |
| 200 | item("New Section"), | 409 | &mut self, |
| 201 | item("New Section Group"), | 410 | id: Id, |
| 202 | Item { | 411 | anchor: Anchor, |
| 203 | separated: true, | 412 | filter: Option<&str>, |
| 204 | ..item("Close This Notebook") | 413 | target: &Target, |
| 205 | }, | 414 | actions: &[(Action, Item)], |
| 206 | ], | 415 | ) -> Option<Action> { |
| 207 | }; | 416 | let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect(); |
| 208 | let Some(chosen) = ui::popup::menu(&mut self.ui, id(), Anchor::Point(*point), &items, None) | 417 | let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter); |
| 209 | else { | 418 | let places = id.child("move"); |
| 210 | return; | 419 | let colors = id.child("colors"); |
| 420 | ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 { | ||
| 421 | Action::Move => Some(places), | ||
| 422 | Action::Colors => Some(colors), | ||
| 423 | _ => None, | ||
| 424 | }); | ||
| 425 | let moved = if self.ui.popup_open(places) { | ||
| 426 | let destinations = self.destinations(target); | ||
| 427 | let items: Vec<Item> = (destinations.iter()) | ||
| 428 | .map(|place| Item { | ||
| 429 | text: &place.name, | ||
| 430 | icon: Some(place.icon), | ||
| 431 | tint: place.tint, | ||
| 432 | ..Item::default() | ||
| 433 | }) | ||
| 434 | .collect(); | ||
| 435 | ui::popup::menu(&mut self.ui, places, anchor, &items, None) | ||
| 436 | .map(|index| Action::MoveTo(destinations[index].path.clone())) | ||
| 437 | } else { | ||
| 438 | None | ||
| 211 | }; | 439 | }; |
| 212 | let Some((target, _)) = self.menu.take() else { | 440 | let colored = match target { |
| 213 | return; | 441 | Target::Section { library, path } if self.ui.popup_open(colors) => { |
| 442 | let current = (library.tabs(&folder(path)).into_iter()) | ||
| 443 | .find(|tab| tab.path == *path) | ||
| 444 | .and_then(|tab| tab.color); | ||
| 445 | let theme = &self.ui.theme; | ||
| 446 | let mut items: Vec<Item> = (SECTION_COLORS.iter()) | ||
| 447 | .map(|&(color, text)| Item { | ||
| 448 | text, | ||
| 449 | icon: Some(crate::art::SECTION), | ||
| 450 | tint: Some(theme.section(crate::section_color(Some(color))).accent), | ||
| 451 | checked: Some(current == Some(color)), | ||
| 452 | ..Item::default() | ||
| 453 | }) | ||
| 454 | .collect(); | ||
| 455 | items.push(Item { | ||
| 456 | text: "None", | ||
| 457 | checked: Some(current.is_none()), | ||
| 458 | separated: true, | ||
| 459 | ..Item::default() | ||
| 460 | }); | ||
| 461 | ui::popup::menu(&mut self.ui, colors, anchor, &items, None) | ||
| 462 | .map(|index| Action::Color(SECTION_COLORS.get(index).map(|(color, _)| *color))) | ||
| 463 | } | ||
| 464 | _ => None, | ||
| 214 | }; | 465 | }; |
| 215 | let command = match (target, items[chosen].text) { | 466 | chosen |
| 216 | (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])), | 467 | .map(|index| actions[index].0.clone()) |
| 217 | (Target::Page(space), "Copy Link to Page") => { | 468 | .or(moved) |
| 218 | let copied = self | 469 | .or(colored) |
| 219 | .page_link(space, None) | 470 | } |
| 220 | .and_then(|link| self.clipboard.set_text(link)); | 471 | |
| 221 | if let Err(error) = copied { | 472 | /// Where Move takes `target`: the other sections of a page's folder, or the folders a |
| 222 | eprintln!("Copying the link failed: {error}"); | 473 | /// section or group may move into. |
| 223 | } | 474 | fn destinations(&self, target: &Target) -> Vec<Destination> { |
| 475 | let theme = &self.ui.theme; | ||
| 476 | match target { | ||
| 477 | Target::Page { library, path, .. } => (library.tabs(&folder(path)).into_iter()) | ||
| 478 | .filter(|tab| tab.path != *path) | ||
| 479 | .map(|tab| Destination { | ||
| 480 | tint: Some(theme.section(crate::section_color(tab.color)).accent), | ||
| 481 | name: tab.name, | ||
| 482 | path: tab.path, | ||
| 483 | icon: crate::art::SECTION, | ||
| 484 | }) | ||
| 485 | .collect(), | ||
| 486 | Target::Section { library, path } | Target::Group { library, path } => { | ||
| 487 | let within = format!("{path}/"); | ||
| 488 | let home = folder(path); | ||
| 489 | (folders(library).into_iter()) | ||
| 490 | .filter(|place| { | ||
| 491 | place.path != home | ||
| 492 | && place.path != *path | ||
| 493 | && !place.path.starts_with(&within) | ||
| 494 | }) | ||
| 495 | .map(|place| Destination { | ||
| 496 | name: match place.path.rsplit_once('/') { | ||
| 497 | _ if place.path.is_empty() => library.name.clone(), | ||
| 498 | Some((_, name)) => name.to_owned(), | ||
| 499 | None => place.path.clone(), | ||
| 500 | }, | ||
| 501 | path: place.path.clone(), | ||
| 502 | icon: if place.path.is_empty() { | ||
| 503 | crate::art::NOTEBOOK | ||
| 504 | } else { | ||
| 505 | crate::art::SECTION_GROUP | ||
| 506 | }, | ||
| 507 | tint: None, | ||
| 508 | }) | ||
| 509 | .collect() | ||
| 510 | } | ||
| 511 | _ => Vec::new(), | ||
| 512 | } | ||
| 513 | } | ||
| 514 | |||
| 515 | /// Does `action` to `target`. An action on a page of a section not open opens it there | ||
| 516 | /// first, then acts. | ||
| 517 | pub(crate) fn act_on(&mut self, target: Target, action: Action) { | ||
| 518 | let command = match (target, action) { | ||
| 519 | (Target::Command(id), _) => { | ||
| 520 | self.choose(Choice::Command(id)); | ||
| 521 | None | ||
| 522 | } | ||
| 523 | ( | ||
| 524 | Target::Page { | ||
| 525 | library, | ||
| 526 | path, | ||
| 527 | space, | ||
| 528 | }, | ||
| 529 | action, | ||
| 530 | ) if action == Action::Open || !self.open(&library, &path) => { | ||
| 531 | self.go(library, path, space); | ||
| 532 | self.after_open = (action != Action::Open).then_some((space, action)); | ||
| 533 | None | ||
| 534 | } | ||
| 535 | (Target::Page { space, .. }, action) => self.page_command(space, action), | ||
| 536 | (Target::Section { library, path }, Action::Open) => { | ||
| 537 | Some(Command::OpenSection(library, path)) | ||
| 538 | } | ||
| 539 | (Target::Notebook(library), Action::Open) => { | ||
| 540 | self.open_notebook(library.location.clone(), None); | ||
| 224 | None | 541 | None |
| 225 | } | 542 | } |
| 226 | (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }), | ||
| 227 | (Target::Page(space), "New Subpage") => Some(Command::NewPage { under: Some(space) }), | ||
| 228 | (Target::Page(page), "Show Page Versions") => Some(Command::History { page, show: true }), | ||
| 229 | (Target::Page(page), "Hide Page Versions") => Some(Command::History { page, show: false }), | ||
| 230 | (Target::Page(space), text) => self.session.as_ref().and_then(|session| { | ||
| 231 | let level = session | ||
| 232 | .pages | ||
| 233 | .iter() | ||
| 234 | .find(|(listed, ..)| *listed == space)? | ||
| 235 | .2; | ||
| 236 | let level = if text == "Make Subpage" { | ||
| 237 | level + 1 | ||
| 238 | } else { | ||
| 239 | level - 1 | ||
| 240 | }; | ||
| 241 | Some(Command::Pages(vec![PageEdit::set_level(space, level).ok()?])) | ||
| 242 | }), | ||
| 243 | ( | 543 | ( |
| 244 | Target::Section { library, path } | Target::Group { library, path }, | 544 | Target::Section { library, path } | Target::Group { library, path }, |
| 245 | "Rename", | 545 | Action::Rename, |
| 246 | ) => { | 546 | ) => { |
| 247 | self.rename(crate::rename::Target::Entry { | 547 | self.rename(crate::rename::Target::Entry { |
| 248 | library, | 548 | library, |
| ... | @@ -251,37 +551,162 @@ impl State { | ... | @@ -251,37 +551,162 @@ impl State { |
| 251 | }); | 551 | }); |
| 252 | None | 552 | None |
| 253 | } | 553 | } |
| 254 | (Target::Section { library, path }, "Delete") => platform::confirm( | 554 | (Target::Section { library, path }, Action::Delete) => platform::confirm( |
| 255 | "Are you sure you want to move this section to this notebook's Recycle Bin?", | 555 | "Are you sure you want to move this section to this notebook's Recycle Bin?", |
| 256 | &path, | 556 | &path, |
| 257 | "Cancel", | 557 | "Cancel", |
| 258 | "Delete", | 558 | "Delete", |
| 259 | ) | 559 | ) |
| 260 | .then(|| Command::Structure(library, Structure::Delete { path })), | 560 | .then(|| Command::Structure(library, Structure::Delete { path })), |
| 261 | (Target::Group { library, path }, "Delete") => platform::confirm( | 561 | (Target::Group { library, path }, Action::Delete) => platform::confirm( |
| 262 | "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", | 562 | "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", |
| 263 | path.rsplit('/').next().unwrap_or_default(), | 563 | path.rsplit('/').next().unwrap_or_default(), |
| 264 | "Cancel", | 564 | "Cancel", |
| 265 | "Delete", | 565 | "Delete", |
| 266 | ) | 566 | ) |
| 267 | .then(|| Command::Structure(library, Structure::Delete { path })), | 567 | .then(|| Command::Structure(library, Structure::Delete { path })), |
| 268 | (Target::Section { library, path }, text) => { | 568 | ( |
| 269 | let folder = folder(&path); | 569 | Target::Section { library, path } | Target::Group { library, path }, |
| 270 | Some(Command::Structure(library, new(text, folder))) | 570 | Action::MoveTo(folder), |
| 571 | ) => Some(Command::Structure(library, Structure::Move { path, folder })), | ||
| 572 | (Target::Section { library, path }, Action::Theme) => { | ||
| 573 | if let Some(identity) = library.section_identity(&path) { | ||
| 574 | let scope = notebook::sidecar::themes::Scope::section(identity); | ||
| 575 | self.show_themes(crate::themes::Scope::Section, library, scope); | ||
| 576 | } | ||
| 577 | None | ||
| 578 | } | ||
| 579 | (Target::Notebook(library), Action::Theme) => { | ||
| 580 | let scope = notebook::sidecar::themes::Scope::Notebook; | ||
| 581 | self.show_themes(crate::themes::Scope::Notebook, library, scope); | ||
| 582 | None | ||
| 583 | } | ||
| 584 | (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure( | ||
| 585 | library, | ||
| 586 | Structure::Color { path, color }, | ||
| 587 | )), | ||
| 588 | (Target::Section { library, path }, Action::Reveal) => { | ||
| 589 | if let Some(file) = section_file(&library, &path) { | ||
| 590 | platform::show_file(&file); | ||
| 591 | } | ||
| 592 | None | ||
| 593 | } | ||
| 594 | (Target::Section { library, path }, action) => { | ||
| 595 | new(&action, folder(&path)).map(|structure| Command::Structure(library, structure)) | ||
| 596 | } | ||
| 597 | (Target::Group { library, path }, action) => { | ||
| 598 | new(&action, path).map(|structure| Command::Structure(library, structure)) | ||
| 599 | } | ||
| 600 | (Target::Notebook(library), Action::Reveal) => { | ||
| 601 | platform::reveal(&library.location); | ||
| 602 | None | ||
| 603 | } | ||
| 604 | (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)), | ||
| 605 | (Target::Notebook(library), Action::Rename | Action::Properties) => { | ||
| 606 | self.open_properties(library); | ||
| 607 | None | ||
| 608 | } | ||
| 609 | (Target::Notebook(library), Action::Sync) => { | ||
| 610 | if let Some(session) = &self.session | ||
| 611 | && Arc::ptr_eq(&session.library, &library) | ||
| 612 | { | ||
| 613 | session.section.wake(); | ||
| 614 | } | ||
| 615 | library.background.iter().for_each(|background| background.wake()); | ||
| 616 | None | ||
| 617 | } | ||
| 618 | (Target::Notebook(library), Action::CopyLink) => { | ||
| 619 | // OneNote 2010's Copy Link to Notebook: the folder's path, spaces escaped. | ||
| 620 | let link = format!("onenote:///{}", library.location.replace(' ', "%20")); | ||
| 621 | if let Err(error) = self.clipboard.set_text(link) { | ||
| 622 | eprintln!("Copying the link failed: {error}"); | ||
| 623 | } | ||
| 624 | None | ||
| 625 | } | ||
| 626 | (Target::Notebook(_), Action::NewNotebook) => { | ||
| 627 | self.choose(Choice::Command(commands::Id::NewNotebook)); | ||
| 628 | None | ||
| 271 | } | 629 | } |
| 272 | (Target::Group { library, path }, text) => { | 630 | (Target::Notebook(library), Action::Raise(up)) => { |
| 273 | Some(Command::Structure(library, new(text, path))) | 631 | let at = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, &library)); |
| 632 | let to = at.and_then(|at| if up { at.checked_sub(1) } else { Some(at + 1) }); | ||
| 633 | if let (Some(at), Some(to)) = (at, to.filter(|to| *to < self.notebooks.len())) { | ||
| 634 | self.notebooks.swap(at, to); | ||
| 635 | self.save_settings(); | ||
| 636 | } | ||
| 637 | None | ||
| 274 | } | 638 | } |
| 275 | (Target::Notebook(library), "Close This Notebook") => { | 639 | (Target::Notebook(library), action) => { |
| 276 | Some(Command::CloseNotebook(library)) | 640 | new(&action, String::new()).map(|structure| Command::Structure(library, structure)) |
| 277 | } | 641 | } |
| 278 | (Target::Notebook(library), text) => { | 642 | (Target::Closed(location), Action::Delete) => { |
| 279 | Some(Command::Structure(library, new(text, String::new()))) | 643 | self.trail.recent.retain(|place| place.notebook != location); |
| 644 | self.save_settings(); | ||
| 645 | None | ||
| 646 | } | ||
| 647 | (Target::Closed(location), _) => { | ||
| 648 | self.open_notebook(location, None); | ||
| 649 | None | ||
| 650 | } | ||
| 651 | (Target::Server(address), action) => { | ||
| 652 | if let Some(index) = self.servers.iter().position(|saved| *saved == address) { | ||
| 653 | self.saved_server((index, action == Action::Delete)); | ||
| 654 | } | ||
| 655 | None | ||
| 280 | } | 656 | } |
| 281 | }; | 657 | }; |
| 282 | self.commands.extend(command); | 658 | self.commands.extend(command); |
| 283 | } | 659 | } |
| 284 | 660 | ||
| 661 | /// What `action` on page `space` of the open section does. | ||
| 662 | fn page_command(&mut self, space: ExGuid, action: Action) -> Option<Command> { | ||
| 663 | match action { | ||
| 664 | Action::Rename => { | ||
| 665 | self.rename(crate::rename::Target::Page(space)); | ||
| 666 | None | ||
| 667 | } | ||
| 668 | Action::Delete => Some(Command::DeletePages(vec![space])), | ||
| 669 | Action::MoveTo(path) => Some(Command::MovePage { space, path }), | ||
| 670 | Action::CopyLink => { | ||
| 671 | let copied = self | ||
| 672 | .page_link(space, None) | ||
| 673 | .and_then(|link| self.clipboard.set_text(link)); | ||
| 674 | if let Err(error) = copied { | ||
| 675 | eprintln!("Copying the link failed: {error}"); | ||
| 676 | } | ||
| 677 | None | ||
| 678 | } | ||
| 679 | Action::NewPage => Some(Command::NewPage { under: None }), | ||
| 680 | Action::NewSubpage => Some(Command::NewPage { under: Some(space) }), | ||
| 681 | Action::Theme => { | ||
| 682 | let session = self.session.as_ref()?; | ||
| 683 | let identity = session.section.page(space).ok()?.identity?; | ||
| 684 | let library = Arc::clone(&session.library); | ||
| 685 | let scope = notebook::sidecar::themes::Scope::page(identity); | ||
| 686 | self.show_themes(crate::themes::Scope::Page, library, scope); | ||
| 687 | None | ||
| 688 | } | ||
| 689 | Action::Versions(show) => Some(Command::History { page: space, show }), | ||
| 690 | Action::MakeSubpage | Action::PromoteSubpage => { | ||
| 691 | let session = self.session.as_ref()?; | ||
| 692 | let level = session | ||
| 693 | .pages | ||
| 694 | .iter() | ||
| 695 | .find(|(listed, ..)| *listed == space)? | ||
| 696 | .2; | ||
| 697 | let level = if action == Action::MakeSubpage { | ||
| 698 | level + 1 | ||
| 699 | } else { | ||
| 700 | level - 1 | ||
| 701 | }; | ||
| 702 | Some(Command::Pages(vec![ | ||
| 703 | PageEdit::set_level(space, level).ok()?, | ||
| 704 | ])) | ||
| 705 | } | ||
| 706 | _ => None, | ||
| 707 | } | ||
| 708 | } | ||
| 709 | |||
| 285 | /// The section tab, of those built as `row`, under the pointer while a page is dragged | 710 | /// The section tab, of those built as `row`, under the pointer while a page is dragged |
| 286 | /// there, which a drop moves the page into. | 711 | /// there, which a drop moves the page into. |
| 287 | pub(crate) fn page_drop(&self, row: Id) -> Option<usize> { | 712 | pub(crate) fn page_drop(&self, row: Id) -> Option<usize> { |
| ... | @@ -472,11 +897,90 @@ impl State { | ... | @@ -472,11 +897,90 @@ impl State { |
| 472 | } | 897 | } |
| 473 | } | 898 | } |
| 474 | 899 | ||
| 475 | /// The new section or group a menu's `text` asks for in `folder`. | 900 | /// The file of the section at catalog `path` on this computer; none on a server reached |
| 476 | fn new(text: &str, folder: String) -> Structure { | 901 | /// without a mount. |
| 477 | if text == "New Section Group" { | 902 | fn section_file(library: &Library, path: &str) -> Option<std::path::PathBuf> { |
| 478 | Structure::NewGroup { folder } | 903 | Some(library.folder()?.join(path)) |
| 479 | } else { | 904 | } |
| 480 | Structure::NewSection { folder } | 905 | |
| 906 | /// The new section or group `action` asks for in `folder`. | ||
| 907 | fn new(action: &Action, folder: String) -> Option<Structure> { | ||
| 908 | match action { | ||
| 909 | Action::NewSection => Some(Structure::NewSection { folder }), | ||
| 910 | Action::NewGroup => Some(Structure::NewGroup { folder }), | ||
| 911 | _ => None, | ||
| 912 | } | ||
| 913 | } | ||
| 914 | |||
| 915 | /// `library`'s folders of sections, the notebook's first, then its section groups breadth | ||
| 916 | /// first, but for the recycle bin; none for a section opened on its own. | ||
| 917 | pub(crate) fn folders(library: &Library) -> Vec<&notebook::discover::Folder> { | ||
| 918 | let mut folders: Vec<_> = library.catalog().into_iter().collect(); | ||
| 919 | let mut at = 0; | ||
| 920 | while let Some(&folder) = folders.get(at) { | ||
| 921 | folders.extend( | ||
| 922 | (folder.groups.iter()).filter(|group| !crate::library::recycle_bin(&group.path)), | ||
| 923 | ); | ||
| 924 | at += 1; | ||
| 925 | } | ||
| 926 | folders | ||
| 927 | } | ||
| 928 | |||
| 929 | #[cfg(test)] | ||
| 930 | mod tests { | ||
| 931 | use super::SECTION_COLORS; | ||
| 932 | use notebook::{discover::SectionState, session::Notebook}; | ||
| 933 | |||
| 934 | /// Section Color and Notebook Properties' colour as the app stores them: each of OneNote's | ||
| 935 | /// colours and None on a section of its own, and Teal on the notebook. | ||
| 936 | /// `SNOWBOUND_SECTION_COLOR_EXPORT` names a new directory receiving the notebook for a | ||
| 937 | /// cold reopen in OneNote 2010 (`corpus/section-color`). | ||
| 938 | #[test] | ||
| 939 | fn sections_and_the_notebook_take_onenotes_colours() { | ||
| 940 | let temporary = | ||
| 941 | std::env::temp_dir().join(format!("snowbound-colors-{}", std::process::id())); | ||
| 942 | let _ = std::fs::remove_dir_all(&temporary); | ||
| 943 | let root = temporary.join("Colors"); | ||
| 944 | let cache = temporary.join("cache"); | ||
| 945 | std::fs::create_dir_all(&temporary).unwrap(); | ||
| 946 | let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); | ||
| 947 | let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap(); | ||
| 948 | let named: Vec<(Option<u32>, &str)> = (SECTION_COLORS.iter()) | ||
| 949 | .map(|&(color, name)| (Some(color), name)) | ||
| 950 | .chain([(None, "None")]) | ||
| 951 | .collect(); | ||
| 952 | for (color, name) in &named { | ||
| 953 | let path = notebook.create_section("", name, &page()).unwrap(); | ||
| 954 | notebook.set_section_color(&path, *color).unwrap(); | ||
| 955 | } | ||
| 956 | notebook.set_color(0xcabc4d).unwrap(); | ||
| 957 | |||
| 958 | let reopened = Notebook::open(&root, &cache).unwrap(); | ||
| 959 | let catalog = reopened.catalog(); | ||
| 960 | assert_eq!(catalog.toc.as_ref().unwrap().color, Some(0xcabc4d)); | ||
| 961 | let colors: Vec<(String, Option<u32>)> = (catalog.sections.iter()) | ||
| 962 | .map(|section| match &section.state { | ||
| 963 | SectionState::Readable { color, .. } => (section.path.clone(), *color), | ||
| 964 | _ => panic!("{}", section.path), | ||
| 965 | }) | ||
| 966 | .collect(); | ||
| 967 | // The section a new notebook starts with keeps the colour it was made in. | ||
| 968 | let expected: Vec<(String, Option<u32>)> = [(Some(0xe4a88a), "New Section 1")] | ||
| 969 | .iter() | ||
| 970 | .chain(&named) | ||
| 971 | .map(|(color, name)| (format!("{name}.one"), *color)) | ||
| 972 | .collect(); | ||
| 973 | assert_eq!(colors, expected); | ||
| 974 | if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") { | ||
| 975 | let directory = std::path::Path::new(&directory); | ||
| 976 | std::fs::create_dir_all(directory).unwrap(); | ||
| 977 | for entry in std::fs::read_dir(&root).unwrap() { | ||
| 978 | let entry = entry.unwrap(); | ||
| 979 | if entry.file_type().unwrap().is_file() { | ||
| 980 | std::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); | ||
| 981 | } | ||
| 982 | } | ||
| 983 | } | ||
| 984 | std::fs::remove_dir_all(&temporary).unwrap(); | ||
| 481 | } | 985 | } |
| 482 | } | 986 | } |
crates/snowbound/src/navigation.rs+113-23| ... | @@ -1,33 +1,42 @@ | ... | @@ -1,33 +1,42 @@ |
| 1 | //! Back and Forward through the pages visited, across sections and notebooks, as OneNote | 1 | //! Back and Forward through the pages visited, across sections and notebooks, as OneNote |
| 2 | //! 2010's Quick Access Toolbar offers them. | 2 | //! 2010's Quick Access Toolbar offers them, and the pages shown lately, which the palette |
| 3 | //! lists first. | ||
| 3 | 4 | ||
| 4 | use super::*; | 5 | use super::*; |
| 5 | use std::time::Duration; | 6 | use canvas::search::Index; |
| 7 | use std::{collections::HashSet, time::Duration}; | ||
| 6 | use winit::event::TouchPhase; | 8 | use winit::event::TouchPhase; |
| 7 | 9 | ||
| 8 | /// A page shown: its notebook's location, its section's catalog path and the page. | 10 | /// A page shown: its notebook's location, its section's catalog path and the page. |
| 9 | #[derive(Clone, Debug, PartialEq)] | 11 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 10 | pub struct Place { | 12 | pub struct Place { |
| 11 | pub notebook: String, | 13 | pub notebook: String, |
| 12 | pub section: String, | 14 | pub section: String, |
| 13 | pub page: ExGuid, | 15 | pub page: ExGuid, |
| 14 | } | 16 | } |
| 15 | 17 | ||
| 16 | /// Pages visited before and after the one shown. | 18 | /// Pages visited before and after the one shown, and those shown lately. |
| 17 | #[derive(Default)] | 19 | #[derive(Default)] |
| 18 | pub struct Trail { | 20 | pub struct Trail { |
| 19 | back: Vec<Place>, | 21 | back: Vec<Place>, |
| 20 | here: Option<Place>, | 22 | here: Option<Place>, |
| 21 | forward: Vec<Place>, | 23 | forward: Vec<Place>, |
| 24 | /// Each page shown lately once, latest first, kept between launches. | ||
| 25 | pub recent: Vec<Place>, | ||
| 22 | } | 26 | } |
| 23 | 27 | ||
| 24 | /// Places Back keeps, at most. | 28 | /// Places Back keeps, at most. |
| 25 | const KEPT: usize = 100; | 29 | const KEPT: usize = 100; |
| 30 | /// Pages `Trail::recent` keeps, at most. | ||
| 31 | const RECENT: usize = 8; | ||
| 26 | 32 | ||
| 27 | impl Trail { | 33 | impl Trail { |
| 28 | /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages | 34 | /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages |
| 29 | /// ahead, as a browser does. | 35 | /// ahead, as a browser does. |
| 30 | pub fn visit(&mut self, place: Place) { | 36 | pub fn visit(&mut self, place: Place) { |
| 37 | self.recent.retain(|recent| *recent != place); | ||
| 38 | self.recent.insert(0, place.clone()); | ||
| 39 | self.recent.truncate(RECENT); | ||
| 31 | if self.here.as_ref() == Some(&place) { | 40 | if self.here.as_ref() == Some(&place) { |
| 32 | return; | 41 | return; |
| 33 | } | 42 | } |
| ... | @@ -59,17 +68,58 @@ impl Trail { | ... | @@ -59,17 +68,58 @@ impl Trail { |
| 59 | pub fn open(&self) -> [bool; 2] { | 68 | pub fn open(&self) -> [bool; 2] { |
| 60 | [!self.back.is_empty(), !self.forward.is_empty()] | 69 | [!self.back.is_empty(), !self.forward.is_empty()] |
| 61 | } | 70 | } |
| 71 | |||
| 72 | /// Drops the recent pages whose section `listed` refuses, and those whose section `index` | ||
| 73 | /// has read without them. Returns whether any went. | ||
| 74 | pub fn prune(&mut self, index: &Index, listed: impl Fn(&Place) -> bool) -> bool { | ||
| 75 | let read: HashSet<&str> = (index.entries().iter()) | ||
| 76 | .map(|entry| entry.section.as_str()) | ||
| 77 | .collect(); | ||
| 78 | let before = self.recent.len(); | ||
| 79 | self.recent.retain(|place| { | ||
| 80 | let key = crate::library::key(&place.notebook, &place.section); | ||
| 81 | listed(place) | ||
| 82 | && (!read.contains(key.as_str()) | ||
| 83 | || (index.entries().iter()) | ||
| 84 | .any(|entry| entry.space == place.page && entry.section == key)) | ||
| 85 | }); | ||
| 86 | self.recent.len() != before | ||
| 87 | } | ||
| 62 | } | 88 | } |
| 63 | 89 | ||
| 64 | impl State { | 90 | impl State { |
| 65 | /// Notes the page just shown for Back and Forward. | 91 | /// Notes the page just shown for Back and Forward, and among the recent pages. |
| 66 | pub(crate) fn visited(&mut self) { | 92 | pub(crate) fn visited(&mut self) { |
| 67 | if let Some(session) = &self.session { | 93 | let Some(session) = &self.session else { |
| 68 | self.trail.visit(Place { | 94 | return; |
| 69 | notebook: session.library.location.clone(), | 95 | }; |
| 70 | section: session.tabs[session.tab].path.clone(), | 96 | let place = Place { |
| 71 | page: session.space, | 97 | notebook: session.library.location.clone(), |
| 72 | }); | 98 | section: session.tabs[session.tab].path.clone(), |
| 99 | page: session.space, | ||
| 100 | }; | ||
| 101 | let moved = self.trail.recent.first() != Some(&place); | ||
| 102 | self.trail.visit(place); | ||
| 103 | if moved { | ||
| 104 | self.save_settings(); | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | /// Whether `library`'s section at `path` is the one open. | ||
| 109 | pub(crate) fn open(&self, library: &Library, path: &str) -> bool { | ||
| 110 | self.session.as_ref().is_some_and(|session| { | ||
| 111 | session.library.location == library.location && session.tabs[session.tab].path == path | ||
| 112 | }) | ||
| 113 | } | ||
| 114 | |||
| 115 | /// Shows page `space` of `library`'s section at `path`, opening the section on it where | ||
| 116 | /// it is not the one open. | ||
| 117 | pub(crate) fn go(&mut self, library: Arc<Library>, path: String, space: ExGuid) { | ||
| 118 | if self.open(&library, &path) { | ||
| 119 | self.commands.push(Command::OpenPage(space)); | ||
| 120 | } else { | ||
| 121 | self.last_pages.insert(library.key(&path), space); | ||
| 122 | self.commands.push(Command::OpenSection(library, path)); | ||
| 73 | } | 123 | } |
| 74 | } | 124 | } |
| 75 | 125 | ||
| ... | @@ -87,13 +137,6 @@ impl State { | ... | @@ -87,13 +137,6 @@ impl State { |
| 87 | let Some(place) = self.trail.step(forward, exists) else { | 137 | let Some(place) = self.trail.step(forward, exists) else { |
| 88 | return; | 138 | return; |
| 89 | }; | 139 | }; |
| 90 | if let Some(session) = &self.session | ||
| 91 | && session.library.location == place.notebook | ||
| 92 | && session.tabs[session.tab].path == place.section | ||
| 93 | { | ||
| 94 | self.commands.push(Command::OpenPage(place.page)); | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | let Some(library) = self | 140 | let Some(library) = self |
| 98 | .notebooks | 141 | .notebooks |
| 99 | .iter() | 142 | .iter() |
| ... | @@ -101,11 +144,7 @@ impl State { | ... | @@ -101,11 +144,7 @@ impl State { |
| 101 | else { | 144 | else { |
| 102 | return; | 145 | return; |
| 103 | }; | 146 | }; |
| 104 | let library = Arc::clone(library); | 147 | self.go(Arc::clone(library), place.section, place.page); |
| 105 | self.last_pages | ||
| 106 | .insert(library.key(&place.section), place.page); | ||
| 107 | self.commands | ||
| 108 | .push(Command::OpenSection(library, place.section)); | ||
| 109 | } | 148 | } |
| 110 | } | 149 | } |
| 111 | 150 | ||
| ... | @@ -228,6 +267,57 @@ mod tests { | ... | @@ -228,6 +267,57 @@ mod tests { |
| 228 | assert_eq!(trail.open(), [true, false]); | 267 | assert_eq!(trail.open(), [true, false]); |
| 229 | } | 268 | } |
| 230 | 269 | ||
| 270 | #[test] | ||
| 271 | fn recent_pages_are_kept_once_latest_first_and_bounded() { | ||
| 272 | let mut trail = Trail::default(); | ||
| 273 | for page in [1, 2, 1] { | ||
| 274 | trail.visit(place("A.one", page)); | ||
| 275 | } | ||
| 276 | assert_eq!(trail.recent, [place("A.one", 1), place("A.one", 2)]); | ||
| 277 | for page in 0..20 { | ||
| 278 | trail.visit(place("B.one", page)); | ||
| 279 | } | ||
| 280 | assert_eq!(trail.recent.len(), RECENT); | ||
| 281 | assert_eq!(trail.recent[0], place("B.one", 19)); | ||
| 282 | } | ||
| 283 | |||
| 284 | #[test] | ||
| 285 | fn recent_pages_go_with_their_section_or_once_the_index_lacks_them() { | ||
| 286 | let page = |title: &str| onestore::page::Page { | ||
| 287 | title: title.into(), | ||
| 288 | identity: None, | ||
| 289 | created: None, | ||
| 290 | margin_origin: [0.0; 2], | ||
| 291 | color: None, | ||
| 292 | rule_lines: None, | ||
| 293 | objects: Vec::new(), | ||
| 294 | definitions: Default::default(), | ||
| 295 | }; | ||
| 296 | let mut index = Index::default(); | ||
| 297 | let section = crate::library::key("/notebooks/Personal", "A.one"); | ||
| 298 | let kept = place("A.one", 1); | ||
| 299 | index.set(canvas::search::Entry::new( | ||
| 300 | &section, | ||
| 301 | kept.page, | ||
| 302 | &page("Kept"), | ||
| 303 | 0, | ||
| 304 | )); | ||
| 305 | let mut trail = Trail::default(); | ||
| 306 | // Visited oldest first: the index has read A without page 2, not yet B, and C is gone. | ||
| 307 | for gone in [ | ||
| 308 | place("C.one", 1), | ||
| 309 | place("B.one", 1), | ||
| 310 | place("A.one", 2), | ||
| 311 | kept.clone(), | ||
| 312 | ] { | ||
| 313 | trail.visit(gone); | ||
| 314 | } | ||
| 315 | let listed = |place: &Place| place.section != "C.one"; | ||
| 316 | assert!(trail.prune(&index, listed)); | ||
| 317 | assert_eq!(trail.recent, [kept, place("B.one", 1)]); | ||
| 318 | assert!(!trail.prune(&index, listed), "nothing more goes"); | ||
| 319 | } | ||
| 320 | |||
| 231 | #[test] | 321 | #[test] |
| 232 | fn back_keeps_its_last_hundred_places() { | 322 | fn back_keeps_its_last_hundred_places() { |
| 233 | let mut trail = Trail::default(); | 323 | let mut trail = Trail::default(); |
crates/snowbound/src/options.rs+674-206| ... | @@ -1,14 +1,14 @@ | ... | @@ -1,14 +1,14 @@ |
| 1 | //! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists | 1 | //! OneNote's Options dialog as one scrolling list of sections (`resources/settings.md`): |
| 2 | //! them, grouped under the same headings, each kept when OK is chosen. | 2 | //! indexed on the left, filtered by the search field above, each choice kept when OK is |
| 3 | //! chosen. | ||
| 3 | 4 | ||
| 4 | use crate::{State, platform, settings::ColorScheme, update}; | 5 | use crate::{State, platform, settings::ColorScheme, update}; |
| 5 | use accesskit::Role; | 6 | use accesskit::Role; |
| 6 | use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px}; | 7 | use canvas::editor::DefaultFont; |
| 8 | use ui::{Anchor, Axis, Extent, Flags, Id, Size, Spec, Theme, Ui, fill, popup::Item, px}; | ||
| 7 | use winit::keyboard::NamedKey; | 9 | use winit::keyboard::NamedKey; |
| 8 | 10 | ||
| 9 | const WIDTH: f32 = 640.0; | 11 | const WIDTH: f32 = 680.0; |
| 10 | /// The pages' area, the same for every page so the dialog keeps its size. | ||
| 11 | const HEIGHT: f32 = 320.0; | ||
| 12 | const NAV: f32 = 150.0; | 12 | const NAV: f32 = 150.0; |
| 13 | /// The column the fields' labels share. | 13 | /// The column the fields' labels share. |
| 14 | const LABEL: f32 = 104.0; | 14 | const LABEL: f32 = 104.0; |
| ... | @@ -17,38 +17,214 @@ const SCHEMES: [(ColorScheme, &str); 3] = [ | ... | @@ -17,38 +17,214 @@ const SCHEMES: [(ColorScheme, &str); 3] = [ |
| 17 | (ColorScheme::Light, "Light"), | 17 | (ColorScheme::Light, "Light"), |
| 18 | (ColorScheme::Dark, "Dark"), | 18 | (ColorScheme::Dark, "Dark"), |
| 19 | ]; | 19 | ]; |
| 20 | /// The sizes Default font offers, OneNote 2010's list in points. | ||
| 21 | const SIZES: [f32; 19] = [ | ||
| 22 | 8.0, 9.0, 9.5, 10.0, 10.5, 11.0, 11.5, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, | ||
| 23 | 36.0, 48.0, 72.0, | ||
| 24 | ]; | ||
| 25 | |||
| 26 | /// An entry of the index and its part of the list. | ||
| 27 | struct Section { | ||
| 28 | name: &'static str, | ||
| 29 | groups: &'static [Group], | ||
| 30 | } | ||
| 31 | |||
| 32 | /// Rows under a banded heading, as OneNote groups an Options page; an empty heading has no | ||
| 33 | /// band. | ||
| 34 | struct Group { | ||
| 35 | heading: &'static str, | ||
| 36 | rows: &'static [Row], | ||
| 37 | } | ||
| 20 | 38 | ||
| 21 | #[derive(Clone, Copy, PartialEq)] | 39 | struct Row { |
| 22 | enum Page { | 40 | label: &'static str, |
| 23 | General, | 41 | /// Other words search finds the row by. |
| 24 | Display, | 42 | keywords: &'static str, |
| 25 | SaveBackup, | 43 | control: Control, |
| 26 | Advanced, | ||
| 27 | } | 44 | } |
| 28 | 45 | ||
| 29 | impl Page { | 46 | enum Control { |
| 30 | const ALL: [(Page, &str); 4] = [ | 47 | /// A check box before the label, bound to a choice. |
| 31 | (Page::General, "General"), | 48 | Check(fn(&mut Options) -> &mut bool), |
| 32 | (Page::Display, "Display"), | 49 | /// The label in the labels' column, then what the function builds. |
| 33 | (Page::SaveBackup, "Save & Backup"), | 50 | Field(fn(&mut State, &mut Options)), |
| 34 | (Page::Advanced, "Advanced"), | 51 | /// What `build` makes across the row, unlabelled, from the search's words; `finds` tells |
| 35 | ]; | 52 | /// whether they find anything in it. Words found in the row's own texts give it none. |
| 53 | Block { | ||
| 54 | build: fn(&mut State, &mut Options, &[String]), | ||
| 55 | finds: fn(&[String]) -> bool, | ||
| 56 | }, | ||
| 36 | } | 57 | } |
| 37 | 58 | ||
| 38 | /// The page shown and what the fields hold until OK keeps them. | 59 | /// Every section, in the list's order. |
| 60 | const SECTIONS: &[Section] = &[ | ||
| 61 | Section { | ||
| 62 | name: "General", | ||
| 63 | groups: &[ | ||
| 64 | Group { | ||
| 65 | heading: "User Interface Options", | ||
| 66 | rows: &[ | ||
| 67 | Row { | ||
| 68 | label: "Appearance:", | ||
| 69 | keywords: "color colour scheme dark light mode system theme", | ||
| 70 | control: Control::Field(appearance), | ||
| 71 | }, | ||
| 72 | Row { | ||
| 73 | label: "Pages match UI theme", | ||
| 74 | keywords: "dark white paper background", | ||
| 75 | control: Control::Check(|options| &mut options.pages_match), | ||
| 76 | }, | ||
| 77 | ], | ||
| 78 | }, | ||
| 79 | Group { | ||
| 80 | heading: "Personalize", | ||
| 81 | rows: &[Row { | ||
| 82 | label: "User name:", | ||
| 83 | keywords: "author name", | ||
| 84 | control: Control::Field(user_name_field), | ||
| 85 | }], | ||
| 86 | }, | ||
| 87 | ], | ||
| 88 | }, | ||
| 89 | Section { | ||
| 90 | name: "Editing", | ||
| 91 | groups: &[ | ||
| 92 | Group { | ||
| 93 | heading: "Default font", | ||
| 94 | rows: &[ | ||
| 95 | Row { | ||
| 96 | label: "Font:", | ||
| 97 | keywords: "typeface text new", | ||
| 98 | control: Control::Field(font_face), | ||
| 99 | }, | ||
| 100 | Row { | ||
| 101 | label: "Size:", | ||
| 102 | keywords: "font points text new", | ||
| 103 | control: Control::Field(font_size), | ||
| 104 | }, | ||
| 105 | Row { | ||
| 106 | label: "Font color:", | ||
| 107 | keywords: "colour text new automatic", | ||
| 108 | control: Control::Field(font_color), | ||
| 109 | }, | ||
| 110 | ], | ||
| 111 | }, | ||
| 112 | Group { | ||
| 113 | heading: "Proofing", | ||
| 114 | rows: &[Row { | ||
| 115 | label: "Hide spelling errors", | ||
| 116 | keywords: "spell check misspelled words", | ||
| 117 | control: Control::Check(|options| &mut options.hide_spelling), | ||
| 118 | }], | ||
| 119 | }, | ||
| 120 | Group { | ||
| 121 | heading: "Pen", | ||
| 122 | rows: &[Row { | ||
| 123 | label: "Use pen pressure sensitivity", | ||
| 124 | keywords: "tablet stylus ink stroke width drawing", | ||
| 125 | control: Control::Check(|options| &mut options.pen_pressure), | ||
| 126 | }], | ||
| 127 | }, | ||
| 128 | ], | ||
| 129 | }, | ||
| 130 | Section { | ||
| 131 | name: "Display", | ||
| 132 | groups: &[Group { | ||
| 133 | heading: "", | ||
| 134 | rows: &[ | ||
| 135 | Row { | ||
| 136 | label: "Page tabs appear on the left", | ||
| 137 | keywords: "pages list side layout", | ||
| 138 | control: Control::Check(|options| &mut options.page_tabs_left), | ||
| 139 | }, | ||
| 140 | Row { | ||
| 141 | label: "Navigation bar appears on the left", | ||
| 142 | keywords: "notebooks sidebar side right layout", | ||
| 143 | control: Control::Check(|options| &mut options.navigation_bar_left), | ||
| 144 | }, | ||
| 145 | ], | ||
| 146 | }], | ||
| 147 | }, | ||
| 148 | Section { | ||
| 149 | name: "Sync & Storage", | ||
| 150 | groups: &[Group { | ||
| 151 | heading: "Cache file location", | ||
| 152 | rows: &[Row { | ||
| 153 | label: "Path:", | ||
| 154 | keywords: "folder replica data disk", | ||
| 155 | control: Control::Field(cache), | ||
| 156 | }], | ||
| 157 | }], | ||
| 158 | }, | ||
| 159 | Section { | ||
| 160 | name: "Updates", | ||
| 161 | groups: &[Group { | ||
| 162 | heading: "", | ||
| 163 | rows: &[ | ||
| 164 | Row { | ||
| 165 | label: "Version:", | ||
| 166 | keywords: "about build release", | ||
| 167 | control: Control::Field(version), | ||
| 168 | }, | ||
| 169 | Row { | ||
| 170 | label: "Check for updates automatically", | ||
| 171 | keywords: "update download", | ||
| 172 | control: Control::Check(|options| &mut options.automatic_updates), | ||
| 173 | }, | ||
| 174 | #[cfg(target_os = "linux")] | ||
| 175 | Row { | ||
| 176 | label: "Installed:", | ||
| 177 | keywords: "install uninstall app menu remove", | ||
| 178 | control: Control::Field(installed), | ||
| 179 | }, | ||
| 180 | ], | ||
| 181 | }], | ||
| 182 | }, | ||
| 183 | Section { | ||
| 184 | name: "Keyboard", | ||
| 185 | groups: &[Group { | ||
| 186 | heading: "", | ||
| 187 | rows: &[Row { | ||
| 188 | label: "Shortcuts", | ||
| 189 | keywords: "keys chords hotkeys bindings commands menus", | ||
| 190 | control: Control::Block { | ||
| 191 | build: |state, options, query| options.keyboard.build(&mut state.ui, query), | ||
| 192 | finds: crate::keys::searched, | ||
| 193 | }, | ||
| 194 | }], | ||
| 195 | }], | ||
| 196 | }, | ||
| 197 | ]; | ||
| 198 | |||
| 199 | /// What the fields hold until OK keeps them, and where the list is. | ||
| 39 | pub struct Options { | 200 | pub struct Options { |
| 40 | page: Page, | 201 | query: String, |
| 202 | /// The section last picked in the index, marked until the list is scrolled. | ||
| 203 | picked: Option<&'static str>, | ||
| 41 | user_name: String, | 204 | user_name: String, |
| 42 | color_scheme: ColorScheme, | 205 | color_scheme: ColorScheme, |
| 43 | light_pages: bool, | 206 | pages_match: bool, |
| 207 | hide_spelling: bool, | ||
| 44 | automatic_updates: bool, | 208 | automatic_updates: bool, |
| 45 | pen_pressure: bool, | 209 | pen_pressure: bool, |
| 210 | default_font: DefaultFont, | ||
| 211 | page_tabs_left: bool, | ||
| 212 | navigation_bar_left: bool, | ||
| 213 | pub(crate) keyboard: crate::keys::Keyboard, | ||
| 46 | } | 214 | } |
| 47 | 215 | ||
| 48 | fn id() -> Id { | 216 | fn id() -> Id { |
| 49 | Id::ROOT.child("options") | 217 | Id::ROOT.child("options") |
| 50 | } | 218 | } |
| 51 | 219 | ||
| 220 | fn search() -> Id { | ||
| 221 | id().child("search") | ||
| 222 | } | ||
| 223 | |||
| 224 | fn list() -> Id { | ||
| 225 | id().child("list") | ||
| 226 | } | ||
| 227 | |||
| 52 | fn user_name() -> Id { | 228 | fn user_name() -> Id { |
| 53 | id().child("user-name") | 229 | id().child("user-name") |
| 54 | } | 230 | } |
| ... | @@ -57,45 +233,107 @@ fn schemes() -> Id { | ... | @@ -57,45 +233,107 @@ fn schemes() -> Id { |
| 57 | id().child("color-schemes") | 233 | id().child("color-schemes") |
| 58 | } | 234 | } |
| 59 | 235 | ||
| 236 | fn fonts() -> Id { | ||
| 237 | id().child("fonts") | ||
| 238 | } | ||
| 239 | |||
| 240 | fn sizes() -> Id { | ||
| 241 | id().child("sizes") | ||
| 242 | } | ||
| 243 | |||
| 244 | fn font_colors() -> Id { | ||
| 245 | id().child("font-colors") | ||
| 246 | } | ||
| 247 | |||
| 248 | /// Whether `word`, lowercase, starts a word of `text`: "pen" finds "Pen" and "pen-like", | ||
| 249 | /// not "Open". | ||
| 250 | fn starts_word(text: &str, word: &str) -> bool { | ||
| 251 | let text = text.to_lowercase(); | ||
| 252 | text.match_indices(word).any(|(at, _)| { | ||
| 253 | text[..at] | ||
| 254 | .chars() | ||
| 255 | .next_back() | ||
| 256 | .is_none_or(|before| !before.is_alphanumeric()) | ||
| 257 | }) | ||
| 258 | } | ||
| 259 | |||
| 260 | /// Whether every word of `query`, lowercase, starts a word in one of `texts`. | ||
| 261 | pub(crate) fn found(query: &[String], texts: &[&str]) -> bool { | ||
| 262 | query | ||
| 263 | .iter() | ||
| 264 | .all(|word| texts.iter().any(|text| starts_word(text, word))) | ||
| 265 | } | ||
| 266 | |||
| 267 | /// Whether any word of `query` starts a word of `text`, which search then highlights. | ||
| 268 | fn lit(query: &[String], text: &str) -> bool { | ||
| 269 | query.iter().any(|word| starts_word(text, word)) | ||
| 270 | } | ||
| 271 | |||
| 272 | /// Whether every word of the query is in `row`'s section name, group heading, label or | ||
| 273 | /// keywords. | ||
| 274 | fn titled(query: &[String], section: &Section, group: &Group, row: &Row) -> bool { | ||
| 275 | found( | ||
| 276 | query, | ||
| 277 | &[section.name, group.heading, row.label, row.keywords], | ||
| 278 | ) | ||
| 279 | } | ||
| 280 | |||
| 281 | /// Whether search shows `row`: its own texts, or a block's contents, hold the query. | ||
| 282 | fn shown(query: &[String], section: &Section, group: &Group, row: &Row) -> bool { | ||
| 283 | titled(query, section, group, row) | ||
| 284 | || matches!(row.control, Control::Block { finds, .. } if finds(query)) | ||
| 285 | } | ||
| 286 | |||
| 60 | impl State { | 287 | impl State { |
| 61 | pub(crate) fn open_options(&mut self) { | 288 | pub(crate) fn open_options(&mut self) { |
| 62 | self.options = Some(Options { | 289 | self.options = Some(Options { |
| 63 | page: Page::General, | 290 | query: String::new(), |
| 291 | picked: None, | ||
| 64 | user_name: self.author.clone(), | 292 | user_name: self.author.clone(), |
| 65 | color_scheme: self.color_scheme, | 293 | color_scheme: self.color_scheme, |
| 66 | light_pages: self.light_pages, | 294 | pages_match: !self.light_pages, |
| 295 | hide_spelling: self.hide_spelling, | ||
| 67 | automatic_updates: self.updates.automatic(), | 296 | automatic_updates: self.updates.automatic(), |
| 68 | pen_pressure: self.pen_pressure, | 297 | pen_pressure: self.pen_pressure, |
| 298 | default_font: self.view.editor.default_font.clone(), | ||
| 299 | page_tabs_left: self.page_tabs_left, | ||
| 300 | navigation_bar_left: !self.navigation_bar_right, | ||
| 301 | keyboard: crate::keys::Keyboard::new(), | ||
| 69 | }); | 302 | }); |
| 70 | self.ui.open_popup(id()); | 303 | self.ui.open_popup(id()); |
| 71 | self.ui.set_focus(Some(user_name())); | 304 | self.ui.set_focus(Some(search())); |
| 72 | } | 305 | } |
| 73 | 306 | ||
| 74 | /// Builds the Options dialog while it is open. OK, or Enter in a field, keeps its choices | 307 | /// Builds the Options dialog while it is open. OK, or Enter in the user name, keeps its |
| 75 | /// with a non-empty user name; Cancel, Escape or a press outside leave them. | 308 | /// choices with a non-empty user name; Cancel, Escape or a press outside leave them. |
| 76 | pub(crate) fn options_dialog(&mut self) { | 309 | pub(crate) fn options_dialog(&mut self) { |
| 77 | let Some(options) = &mut self.options else { | 310 | let Some(mut options) = self.options.take() else { |
| 78 | return; | 311 | return; |
| 79 | }; | 312 | }; |
| 80 | let ui = &mut self.ui; | 313 | if !self.ui.popup_open(id()) { |
| 81 | if !ui.popup_open(id()) { | ||
| 82 | self.options = None; | ||
| 83 | return; | 314 | return; |
| 84 | } | 315 | } |
| 85 | let theme = ui.theme.clone(); | 316 | let theme = self.ui.theme.clone(); |
| 86 | let row = theme.font_size * 2.0; | 317 | let row = row_height(&theme); |
| 318 | let ui = &mut self.ui; | ||
| 87 | let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter]) | 319 | let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter]) |
| 88 | .contains(&NamedKey::Enter); | 320 | .contains(&NamedKey::Enter); |
| 321 | // As tall as its contents up to the window, which the list then scrolls within. | ||
| 322 | let loose = Extent { | ||
| 323 | size: Size::Children, | ||
| 324 | strictness: 0.0, | ||
| 325 | }; | ||
| 89 | ui.open_as( | 326 | ui.open_as( |
| 90 | id(), | 327 | id(), |
| 91 | Spec { | 328 | Spec { |
| 92 | axis: Axis::Y, | 329 | axis: Axis::Y, |
| 93 | size: [px(WIDTH), children()], | 330 | size: [px(WIDTH), loose], |
| 94 | fill: Some(theme.popup), | 331 | fill: Some(theme.popup), |
| 95 | border: Some(theme.chip), | 332 | border: Some(theme.chip), |
| 96 | shadow: Some(theme.shadow), | 333 | shadow: Some(theme.shadow), |
| 97 | radius: 8.0, | 334 | radius: 8.0, |
| 98 | pad: [6.0, 6.0], | 335 | pad: [6.0, 6.0], |
| 336 | gap: 6.0, | ||
| 99 | anchor: Some(Anchor::Dialog), | 337 | anchor: Some(Anchor::Dialog), |
| 100 | role: Some(Role::Dialog), | 338 | role: Some(Role::Dialog), |
| 101 | ..Spec::default() | 339 | ..Spec::default() |
| ... | @@ -104,15 +342,59 @@ impl State { | ... | @@ -104,15 +342,59 @@ impl State { |
| 104 | if let Some(node) = ui.access(id()) { | 342 | if let Some(node) = ui.access(id()) { |
| 105 | node.set_label("Options"); | 343 | node.set_label("Options"); |
| 106 | } | 344 | } |
| 345 | let before = options.query.clone(); | ||
| 346 | ui::text_field( | ||
| 347 | ui, | ||
| 348 | search(), | ||
| 349 | &mut options.query, | ||
| 350 | "Search options", | ||
| 351 | Spec { | ||
| 352 | size: [fill(), px(row)], | ||
| 353 | fill: Some(theme.base), | ||
| 354 | border: Some(if ui.focused() == Some(search()) { | ||
| 355 | theme.accent | ||
| 356 | } else { | ||
| 357 | theme.chip | ||
| 358 | }), | ||
| 359 | radius: 4.0, | ||
| 360 | pad: [8.0, 0.0], | ||
| 361 | role: Some(Role::SearchInput), | ||
| 362 | ..Spec::default() | ||
| 363 | }, | ||
| 364 | ); | ||
| 365 | if let Some(node) = ui.access(search()) { | ||
| 366 | node.set_label("Search options"); | ||
| 367 | } | ||
| 368 | let query: Vec<String> = options | ||
| 369 | .query | ||
| 370 | .split_whitespace() | ||
| 371 | .map(str::to_lowercase) | ||
| 372 | .collect(); | ||
| 373 | if options.query != before { | ||
| 374 | options.picked = None; | ||
| 375 | ui.scroll_to(list(), list().child("top")); | ||
| 376 | } | ||
| 377 | let sections: Vec<&Section> = SECTIONS | ||
| 378 | .iter() | ||
| 379 | .filter(|section| { | ||
| 380 | section.groups.iter().any(|group| { | ||
| 381 | group | ||
| 382 | .rows | ||
| 383 | .iter() | ||
| 384 | .any(|row| shown(&query, section, group, row)) | ||
| 385 | }) | ||
| 386 | }) | ||
| 387 | .collect(); | ||
| 107 | ui.open( | 388 | ui.open( |
| 108 | "body", | 389 | "body", |
| 109 | Spec { | 390 | Spec { |
| 110 | size: [fill(), px(HEIGHT)], | 391 | size: [fill(), loose], |
| 392 | gap: 6.0, | ||
| 111 | ..Spec::default() | 393 | ..Spec::default() |
| 112 | }, | 394 | }, |
| 113 | ); | 395 | ); |
| 114 | let pages = ui.open( | 396 | let index = ui.open( |
| 115 | "pages", | 397 | "index", |
| 116 | Spec { | 398 | Spec { |
| 117 | axis: Axis::Y, | 399 | axis: Axis::Y, |
| 118 | size: [px(NAV), fill()], | 400 | size: [px(NAV), fill()], |
| ... | @@ -124,177 +406,175 @@ impl State { | ... | @@ -124,177 +406,175 @@ impl State { |
| 124 | ..Spec::default() | 406 | ..Spec::default() |
| 125 | }, | 407 | }, |
| 126 | ); | 408 | ); |
| 127 | if let Some(node) = ui.access(pages) { | 409 | if let Some(node) = ui.access(index) { |
| 128 | node.set_orientation(accesskit::Orientation::Vertical); | 410 | node.set_orientation(accesskit::Orientation::Vertical); |
| 129 | } | 411 | } |
| 130 | for (page, name) in Page::ALL { | 412 | // The section in the top quarter of the list, or the last once the list is scrolled to |
| 131 | let shown = page == options.page; | 413 | // its end. |
| 132 | let spec = Spec { | 414 | let current = options.picked.or_else(|| { |
| 133 | flags: Flags::CLICKABLE, | 415 | let [_, top, _, bottom] = ui.rect(list())?; |
| 134 | size: [fill(), px(row)], | 416 | let starts = |id| ui.rect(id).map(|rect: [f32; 4]| rect[1]); |
| 135 | text: Some(name), | 417 | let end = starts(list().child("top")).is_some_and(|start| start < top - 1.0) |
| 136 | bold: shown, | 418 | && starts(list().child("end")).is_some_and(|end| end <= bottom + 1.0); |
| 137 | fill: shown.then(|| theme.hover()), | 419 | sections |
| 138 | hover_fill: Some(theme.hover()), | 420 | .iter() |
| 139 | radius: 4.0, | 421 | .rfind(|section| { |
| 140 | pad: [8.0, 0.0], | 422 | end || starts(section_id(section)) |
| 141 | role: Some(Role::Tab), | 423 | .is_some_and(|start| start <= top + (bottom - top) / 4.0) |
| 142 | ..Spec::default() | 424 | }) |
| 143 | }; | 425 | .or(sections.first()) |
| 144 | let tab = ui.open(name, spec); | 426 | .map(|section| section.name) |
| 427 | }); | ||
| 428 | for section in &sections { | ||
| 429 | let marked = current == Some(section.name); | ||
| 430 | let tab = ui.open( | ||
| 431 | section.name, | ||
| 432 | Spec { | ||
| 433 | flags: Flags::CLICKABLE, | ||
| 434 | size: [fill(), px(row)], | ||
| 435 | text: Some(section.name), | ||
| 436 | bold: marked, | ||
| 437 | fill: marked.then(|| theme.hover()), | ||
| 438 | hover_fill: Some(theme.hover()), | ||
| 439 | radius: 4.0, | ||
| 440 | pad: [8.0, 0.0], | ||
| 441 | role: Some(Role::Tab), | ||
| 442 | ..Spec::default() | ||
| 443 | }, | ||
| 444 | ); | ||
| 145 | if let Some(node) = ui.access(tab) { | 445 | if let Some(node) = ui.access(tab) { |
| 146 | node.set_selected(shown); | 446 | node.set_selected(marked); |
| 147 | } | 447 | } |
| 148 | ui.close(); | 448 | ui.close(); |
| 149 | if ui.signal(tab).clicked { | 449 | if ui.signal(tab).clicked { |
| 150 | options.page = page; | 450 | options.picked = Some(section.name); |
| 451 | ui.scroll_to(list(), section_id(section)); | ||
| 151 | } | 452 | } |
| 152 | } | 453 | } |
| 153 | ui.close(); | 454 | ui.close(); |
| 154 | ui.open( | 455 | ui.open_as( |
| 155 | "page", | 456 | list(), |
| 156 | Spec { | 457 | Spec { |
| 458 | flags: Flags::SCROLL | Flags::CLIP, | ||
| 157 | axis: Axis::Y, | 459 | axis: Axis::Y, |
| 158 | size: [fill(), fill()], | 460 | size: [fill(), loose], |
| 159 | pad: [16.0, 10.0], | 461 | pad: [10.0, 0.0], |
| 160 | gap: 6.0, | ||
| 161 | ..Spec::default() | 462 | ..Spec::default() |
| 162 | }, | 463 | }, |
| 163 | ); | 464 | ); |
| 164 | match options.page { | 465 | if !ui.signal(list()).events.is_empty() { |
| 165 | Page::General => { | 466 | options.picked = None; |
| 166 | heading(ui, &theme, "User Interface Options"); | 467 | } |
| 167 | field(ui, "Appearance:", |ui| { | 468 | ui.leaf( |
| 168 | let combo = ui.id("combo"); | 469 | "top", |
| 169 | let current = SCHEMES | 470 | Spec { |
| 170 | .iter() | 471 | size: [fill(), px(0.0)], |
| 171 | .find(|(scheme, _)| *scheme == options.color_scheme) | 472 | ..Spec::default() |
| 172 | .map_or("", |(_, name)| name); | 473 | }, |
| 173 | ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true); | 474 | ); |
| 174 | let items = SCHEMES.map(|(scheme, name)| Item { | 475 | if sections.is_empty() { |
| 175 | text: name, | 476 | ui.leaf( |
| 176 | checked: Some(scheme == options.color_scheme), | 477 | "none", |
| 177 | current: scheme == options.color_scheme, | 478 | Spec { |
| 178 | ..Item::default() | 479 | size: [fill(), px(row * 2.0)], |
| 179 | }); | 480 | text: Some("No options match"), |
| 180 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | 481 | color: Some(theme.text_dim), |
| 181 | if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) { | 482 | center: true, |
| 182 | options.color_scheme = SCHEMES[index].0; | 483 | ..Spec::default() |
| 183 | } | 484 | }, |
| 184 | }); | 485 | ); |
| 185 | heading(ui, &theme, "Personalize"); | 486 | } |
| 186 | field(ui, "User name:", |ui| { | 487 | for section in &sections { |
| 187 | ui::text_field( | 488 | self.ui.open_as( |
| 188 | ui, | 489 | section_id(section), |
| 189 | user_name(), | 490 | Spec { |
| 190 | &mut options.user_name, | 491 | axis: Axis::Y, |
| 191 | "", | 492 | size: [fill(), ui::children()], |
| 192 | Spec { | 493 | pad: [0.0, 4.0], |
| 193 | size: [px(260.0), px(row)], | 494 | gap: 4.0, |
| 194 | fill: Some(theme.base), | 495 | ..Spec::default() |
| 195 | border: Some(theme.accent), | 496 | }, |
| 196 | radius: 4.0, | 497 | ); |
| 197 | pad: [6.0, 0.0], | 498 | title( |
| 198 | ..Spec::default() | 499 | &mut self.ui, |
| 199 | }, | 500 | &theme, |
| 200 | ); | 501 | section.name, |
| 201 | if let Some(node) = ui.access(user_name()) { | 502 | lit(&query, section.name), |
| 202 | node.set_label("User name"); | 503 | ); |
| 203 | } | 504 | for group in section.groups { |
| 204 | }); | 505 | let rows: Vec<&Row> = group |
| 205 | heading(ui, &theme, "Updates"); | 506 | .rows |
| 206 | ui.leaf( | 507 | .iter() |
| 207 | "version", | 508 | .filter(|row| shown(&query, section, group, row)) |
| 208 | Spec { | 509 | .collect(); |
| 209 | size: [fill(), px(row)], | 510 | if rows.is_empty() { |
| 210 | text: Some(&update::describe_running()), | 511 | continue; |
| 211 | pad: [8.0, 0.0], | ||
| 212 | ..Spec::default() | ||
| 213 | }, | ||
| 214 | ); | ||
| 215 | if ui::check_box( | ||
| 216 | ui, | ||
| 217 | "automatic-updates", | ||
| 218 | "Check for updates automatically", | ||
| 219 | options.automatic_updates, | ||
| 220 | ) | ||
| 221 | .clicked | ||
| 222 | { | ||
| 223 | options.automatic_updates = !options.automatic_updates; | ||
| 224 | } | ||
| 225 | #[cfg(target_os = "linux")] | ||
| 226 | if crate::desktop::uninstallable() { | ||
| 227 | field(ui, "Installed:", |ui| { | ||
| 228 | let binary = crate::desktop::binary().unwrap_or_default(); | ||
| 229 | ui.leaf( | ||
| 230 | "path", | ||
| 231 | Spec { | ||
| 232 | flags: Flags::CLIP, | ||
| 233 | size: [fill(), px(row)], | ||
| 234 | text: Some(&binary.to_string_lossy()), | ||
| 235 | color: Some(theme.text_dim), | ||
| 236 | ..Spec::default() | ||
| 237 | }, | ||
| 238 | ); | ||
| 239 | if ui::button(ui, "uninstall", "Uninstall…").clicked { | ||
| 240 | crate::desktop::uninstall(); | ||
| 241 | } | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | } | ||
| 245 | Page::Display => { | ||
| 246 | heading(ui, &theme, "Display"); | ||
| 247 | let matching = !options.light_pages; | ||
| 248 | if ui::check_box(ui, "pages-match-theme", "Pages match UI theme", matching).clicked | ||
| 249 | { | ||
| 250 | options.light_pages = matching; | ||
| 251 | } | 512 | } |
| 252 | } | 513 | if !group.heading.is_empty() { |
| 253 | Page::Advanced => { | 514 | heading( |
| 254 | heading(ui, &theme, "Pen"); | 515 | &mut self.ui, |
| 255 | if ui::check_box( | 516 | &theme, |
| 256 | ui, | 517 | group.heading, |
| 257 | "pen-pressure", | 518 | lit(&query, group.heading), |
| 258 | "Use pen pressure sensitivity", | 519 | ); |
| 259 | options.pen_pressure, | ||
| 260 | ) | ||
| 261 | .clicked | ||
| 262 | { | ||
| 263 | options.pen_pressure = !options.pen_pressure; | ||
| 264 | } | 520 | } |
| 265 | } | 521 | for row in rows { |
| 266 | Page::SaveBackup => { | 522 | let highlight = !matches!(row.control, Control::Block { .. }) |
| 267 | heading(ui, &theme, "Cache file location"); | 523 | && (lit(&query, row.label) || lit(&query, row.keywords)); |
| 268 | let cache = &self.cache; | 524 | self.ui.open( |
| 269 | field(ui, "Path:", |ui| { | 525 | row.label, |
| 270 | ui.leaf( | ||
| 271 | "path", | ||
| 272 | Spec { | 526 | Spec { |
| 273 | flags: Flags::CLIP, | 527 | size: [fill(), ui::children()], |
| 274 | size: [fill(), px(row)], | 528 | fill: highlight.then(|| ui::mix(theme.popup, theme.accent, 0.18)), |
| 275 | text: Some(&cache.to_string_lossy()), | 529 | radius: 4.0, |
| 276 | color: Some(theme.text_dim), | 530 | pad: [8.0, 0.0], |
| 531 | gap: 8.0, | ||
| 277 | ..Spec::default() | 532 | ..Spec::default() |
| 278 | }, | 533 | }, |
| 279 | ); | 534 | ); |
| 280 | let label = if cfg!(target_os = "macos") { | 535 | match row.control { |
| 281 | "Show in Finder" | 536 | Control::Check(choice) => { |
| 282 | } else { | 537 | let checked = choice(&mut options); |
| 283 | "Open Folder" | 538 | if ui::check_box(&mut self.ui, "check", row.label, *checked).clicked { |
| 284 | }; | 539 | *checked = !*checked; |
| 285 | if ui::button(ui, "reveal", label).clicked { | 540 | } |
| 286 | platform::reveal(cache); | 541 | } |
| 542 | Control::Field(build) => { | ||
| 543 | self.ui.leaf( | ||
| 544 | "label", | ||
| 545 | Spec { | ||
| 546 | size: [px(LABEL), px(row_height(&theme))], | ||
| 547 | text: Some(row.label), | ||
| 548 | ..Spec::default() | ||
| 549 | }, | ||
| 550 | ); | ||
| 551 | build(self, &mut options); | ||
| 552 | } | ||
| 553 | Control::Block { build, .. } => { | ||
| 554 | let titled = titled(&query, section, group, row); | ||
| 555 | build(self, &mut options, if titled { &[] } else { &query }); | ||
| 556 | } | ||
| 287 | } | 557 | } |
| 288 | }); | 558 | self.ui.close(); |
| 559 | } | ||
| 289 | } | 560 | } |
| 561 | self.ui.close(); | ||
| 290 | } | 562 | } |
| 563 | let ui = &mut self.ui; | ||
| 564 | ui.leaf( | ||
| 565 | "end", | ||
| 566 | Spec { | ||
| 567 | size: [fill(), px(6.0)], | ||
| 568 | ..Spec::default() | ||
| 569 | }, | ||
| 570 | ); | ||
| 291 | ui.close(); | 571 | ui.close(); |
| 292 | ui.close(); | 572 | ui.close(); |
| 293 | ui.open( | 573 | ui.open( |
| 294 | "buttons", | 574 | "buttons", |
| 295 | Spec { | 575 | Spec { |
| 296 | size: [fill(), children()], | 576 | size: [fill(), ui::children()], |
| 297 | pad: [10.0, 8.0], | 577 | pad: [10.0, 4.0], |
| 298 | gap: 8.0, | 578 | gap: 8.0, |
| 299 | ..Spec::default() | 579 | ..Spec::default() |
| 300 | }, | 580 | }, |
| ... | @@ -314,56 +594,244 @@ impl State { | ... | @@ -314,56 +594,244 @@ impl State { |
| 314 | if ok && !name.is_empty() { | 594 | if ok && !name.is_empty() { |
| 315 | self.author = name.to_owned(); | 595 | self.author = name.to_owned(); |
| 316 | self.color_scheme = options.color_scheme; | 596 | self.color_scheme = options.color_scheme; |
| 317 | self.light_pages = options.light_pages; | 597 | self.light_pages = !options.pages_match; |
| 598 | self.hide_spelling = options.hide_spelling; | ||
| 318 | self.pen_pressure = options.pen_pressure; | 599 | self.pen_pressure = options.pen_pressure; |
| 600 | self.view.editor.default_font = options.default_font; | ||
| 601 | self.page_tabs_left = options.page_tabs_left; | ||
| 602 | self.navigation_bar_right = !options.navigation_bar_left; | ||
| 319 | self.updates.set_automatic(options.automatic_updates); | 603 | self.updates.set_automatic(options.automatic_updates); |
| 604 | self.show_spelling(); | ||
| 320 | self.follow_color_scheme(); | 605 | self.follow_color_scheme(); |
| 606 | self.install_keymap(options.keyboard.keymap); | ||
| 321 | self.save_settings(); | 607 | self.save_settings(); |
| 322 | } else if !cancel { | 608 | } else if !cancel { |
| 609 | self.options = Some(options); | ||
| 323 | return; | 610 | return; |
| 324 | } | 611 | } |
| 325 | self.ui.close_popup(id()); | 612 | self.ui.close_popup(id()); |
| 326 | self.options = None; | ||
| 327 | } | 613 | } |
| 328 | } | 614 | } |
| 329 | 615 | ||
| 330 | /// A group's heading on a band, as OneNote heads the groups of its Options pages. | 616 | fn section_id(section: &Section) -> Id { |
| 331 | fn heading(ui: &mut Ui, theme: &Theme, text: &str) { | 617 | list().child(section.name) |
| 618 | } | ||
| 619 | |||
| 620 | fn row_height(theme: &Theme) -> f32 { | ||
| 621 | theme.font_size * 2.0 | ||
| 622 | } | ||
| 623 | |||
| 624 | /// A section's name over its groups, tinted where search found it. | ||
| 625 | fn title(ui: &mut Ui, theme: &Theme, text: &str, lit: bool) { | ||
| 332 | ui.leaf( | 626 | ui.leaf( |
| 333 | text, | 627 | "title", |
| 334 | Spec { | 628 | Spec { |
| 335 | size: [fill(), px(theme.font_size * 1.8)], | 629 | size: [fill(), px(theme.font_size * 2.2)], |
| 336 | text: Some(text), | 630 | text: Some(text), |
| 631 | font_size: Some(theme.font_size * 1.25), | ||
| 337 | bold: true, | 632 | bold: true, |
| 338 | fill: Some(ui::mix(theme.panel, theme.chip, 0.4)), | 633 | color: lit.then_some(theme.accent), |
| 339 | radius: 4.0, | ||
| 340 | pad: [8.0, 0.0], | ||
| 341 | role: Some(Role::Heading), | 634 | role: Some(Role::Heading), |
| 342 | ..Spec::default() | 635 | ..Spec::default() |
| 343 | }, | 636 | }, |
| 344 | ); | 637 | ); |
| 345 | } | 638 | } |
| 346 | 639 | ||
| 347 | /// A row of `label` in the labels' column and the controls `build` adds after it. | 640 | /// A group's heading on a band, as OneNote heads the groups of its Options pages. |
| 348 | fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) { | 641 | fn heading(ui: &mut Ui, theme: &Theme, text: &str, lit: bool) { |
| 349 | let row = ui.theme.font_size * 2.0; | 642 | let band = ui::mix(theme.panel, theme.chip, 0.4); |
| 350 | ui.open( | ||
| 351 | label, | ||
| 352 | Spec { | ||
| 353 | size: [fill(), px(row)], | ||
| 354 | pad: [8.0, 0.0], | ||
| 355 | gap: 8.0, | ||
| 356 | ..Spec::default() | ||
| 357 | }, | ||
| 358 | ); | ||
| 359 | ui.leaf( | 643 | ui.leaf( |
| 360 | "label", | 644 | text, |
| 361 | Spec { | 645 | Spec { |
| 362 | size: [px(LABEL), px(row)], | 646 | size: [fill(), px(theme.font_size * 1.8)], |
| 363 | text: Some(label), | 647 | text: Some(text), |
| 648 | bold: true, | ||
| 649 | fill: Some(if lit { | ||
| 650 | ui::mix(band, theme.accent, 0.3) | ||
| 651 | } else { | ||
| 652 | band | ||
| 653 | }), | ||
| 654 | radius: 4.0, | ||
| 655 | pad: [8.0, 0.0], | ||
| 656 | role: Some(Role::Heading), | ||
| 364 | ..Spec::default() | 657 | ..Spec::default() |
| 365 | }, | 658 | }, |
| 366 | ); | 659 | ); |
| 367 | build(ui); | 660 | } |
| 368 | ui.close(); | 661 | |
| 662 | fn appearance(state: &mut State, options: &mut Options) { | ||
| 663 | let ui = &mut state.ui; | ||
| 664 | let combo = ui.id("combo"); | ||
| 665 | let current = SCHEMES | ||
| 666 | .iter() | ||
| 667 | .find(|(scheme, _)| *scheme == options.color_scheme) | ||
| 668 | .map_or("", |(_, name)| name); | ||
| 669 | ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true); | ||
| 670 | let items = SCHEMES.map(|(scheme, name)| Item { | ||
| 671 | text: name, | ||
| 672 | checked: Some(scheme == options.color_scheme), | ||
| 673 | current: scheme == options.color_scheme, | ||
| 674 | ..Item::default() | ||
| 675 | }); | ||
| 676 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | ||
| 677 | if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) { | ||
| 678 | options.color_scheme = SCHEMES[index].0; | ||
| 679 | } | ||
| 680 | } | ||
| 681 | |||
| 682 | fn user_name_field(state: &mut State, options: &mut Options) { | ||
| 683 | let ui = &mut state.ui; | ||
| 684 | let theme = &ui.theme; | ||
| 685 | let width = Extent { | ||
| 686 | size: Size::Pixels(260.0), | ||
| 687 | strictness: 0.0, | ||
| 688 | }; | ||
| 689 | let spec = Spec { | ||
| 690 | size: [width, px(row_height(theme))], | ||
| 691 | fill: Some(theme.base), | ||
| 692 | border: Some(theme.accent), | ||
| 693 | radius: 4.0, | ||
| 694 | pad: [6.0, 0.0], | ||
| 695 | ..Spec::default() | ||
| 696 | }; | ||
| 697 | ui::text_field(ui, user_name(), &mut options.user_name, "", spec); | ||
| 698 | if let Some(node) = ui.access(user_name()) { | ||
| 699 | node.set_label("User name"); | ||
| 700 | } | ||
| 701 | } | ||
| 702 | |||
| 703 | fn font_face(state: &mut State, options: &mut Options) { | ||
| 704 | let State { ui, fonts, .. } = state; | ||
| 705 | let font = &mut options.default_font; | ||
| 706 | let combo = ui.id("combo"); | ||
| 707 | ui::shell::combo(ui, "combo", "Font", &font.face, 200.0, self::fonts(), true); | ||
| 708 | let items: Vec<_> = fonts | ||
| 709 | .iter() | ||
| 710 | .map(|name| Item { | ||
| 711 | text: name, | ||
| 712 | font: Some(name), | ||
| 713 | current: *name == font.face, | ||
| 714 | ..Item::default() | ||
| 715 | }) | ||
| 716 | .collect(); | ||
| 717 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); | ||
| 718 | if let Some(index) = ui::popup::menu(ui, self::fonts(), anchor, &items, Some("Font")) { | ||
| 719 | font.face = fonts[index].clone(); | ||
| 720 | } | ||
| 721 | } | ||
| 722 | |||
| 723 | fn font_size(state: &mut State, options: &mut Options) { | ||
| 724 | let ui = &mut state.ui; | ||
| 725 | let font = &mut options.default_font; | ||
| 726 | let combo = ui.id("combo"); | ||
| 727 | let size = format!("{}", font.size); | ||
| 728 | ui::shell::combo(ui, "combo", "Size", &size, 60.0, sizes(), true); | ||
| 729 | let labels = SIZES.map(|size| format!("{size}")); | ||
| 730 | let items: Vec<_> = labels | ||
| 731 | .iter() | ||
| 732 | .map(|label| Item { | ||
| 733 | text: label, | ||
| 734 | checked: Some(*label == size), | ||
| 735 | current: *label == size, | ||
| 736 | ..Item::default() | ||
| 737 | }) | ||
| 738 | .collect(); | ||
| 739 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); | ||
| 740 | if let Some(index) = ui::popup::menu(ui, sizes(), anchor, &items, None) { | ||
| 741 | font.size = SIZES[index]; | ||
| 742 | } | ||
| 743 | } | ||
| 744 | |||
| 745 | fn font_color(state: &mut State, options: &mut Options) { | ||
| 746 | let ui = &mut state.ui; | ||
| 747 | let font = &mut options.default_font; | ||
| 748 | let combo = ui.id("combo"); | ||
| 749 | let shown = crate::FONT_COLORS | ||
| 750 | .iter() | ||
| 751 | .find(|(color, _)| Some(*color) == font.color) | ||
| 752 | .map_or("Automatic", |(_, name)| name); | ||
| 753 | ui::shell::combo(ui, "combo", "Font color", shown, 200.0, font_colors(), true); | ||
| 754 | let swatches: Vec<_> = crate::FONT_COLORS | ||
| 755 | .iter() | ||
| 756 | .map(|&(color, name)| (canvas::gpu::colorref(color), name)) | ||
| 757 | .collect(); | ||
| 758 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | ||
| 759 | if let Some(chosen) = ui::popup::colors(ui, font_colors(), anchor, "Automatic", &swatches, 10) { | ||
| 760 | font.color = chosen.and_then(|chosen| { | ||
| 761 | let at = swatches.iter().position(|(swatch, _)| *swatch == chosen)?; | ||
| 762 | Some(crate::FONT_COLORS[at].0) | ||
| 763 | }); | ||
| 764 | } | ||
| 765 | } | ||
| 766 | |||
| 767 | /// A path shown dimmed and cut to the row. | ||
| 768 | fn path(ui: &mut Ui, path: &std::path::Path) { | ||
| 769 | let spec = Spec { | ||
| 770 | flags: Flags::CLIP, | ||
| 771 | size: [fill(), px(row_height(&ui.theme))], | ||
| 772 | text: Some(&path.to_string_lossy()), | ||
| 773 | color: Some(ui.theme.text_dim), | ||
| 774 | ..Spec::default() | ||
| 775 | }; | ||
| 776 | ui.leaf("path", spec); | ||
| 777 | } | ||
| 778 | |||
| 779 | fn cache(state: &mut State, _: &mut Options) { | ||
| 780 | path(&mut state.ui, &state.cache); | ||
| 781 | let label = if cfg!(target_os = "macos") { | ||
| 782 | "Show in Finder" | ||
| 783 | } else { | ||
| 784 | "Open Folder" | ||
| 785 | }; | ||
| 786 | if ui::button(&mut state.ui, "reveal", label).clicked { | ||
| 787 | platform::reveal(&state.cache); | ||
| 788 | } | ||
| 789 | } | ||
| 790 | |||
| 791 | fn version(state: &mut State, _: &mut Options) { | ||
| 792 | let ui = &mut state.ui; | ||
| 793 | let spec = Spec { | ||
| 794 | size: [fill(), px(row_height(&ui.theme))], | ||
| 795 | text: Some(&update::describe_running()), | ||
| 796 | ..Spec::default() | ||
| 797 | }; | ||
| 798 | ui.leaf("version", spec); | ||
| 799 | } | ||
| 800 | |||
| 801 | /// Where Install put Snowbound and Uninstall, or Install where it isn't in the app menu. | ||
| 802 | #[cfg(target_os = "linux")] | ||
| 803 | fn installed(state: &mut State, _: &mut Options) { | ||
| 804 | let ui = &mut state.ui; | ||
| 805 | if crate::desktop::uninstallable() { | ||
| 806 | path(ui, &crate::desktop::binary().unwrap_or_default()); | ||
| 807 | if ui::button(ui, "uninstall", "Uninstall…").clicked { | ||
| 808 | crate::desktop::uninstall(); | ||
| 809 | } | ||
| 810 | } else if crate::desktop::installable() { | ||
| 811 | path(ui, std::path::Path::new("Not in the app menu")); | ||
| 812 | if ui::button(ui, "install", "Install").clicked { | ||
| 813 | crate::desktop::install(); | ||
| 814 | } | ||
| 815 | } else { | ||
| 816 | path(ui, std::path::Path::new("By your system's package manager")); | ||
| 817 | } | ||
| 818 | } | ||
| 819 | |||
| 820 | #[cfg(test)] | ||
| 821 | mod tests { | ||
| 822 | use super::found; | ||
| 823 | |||
| 824 | #[test] | ||
| 825 | fn search_words_match_the_starts_of_words() { | ||
| 826 | let query = |text: &str| vec![text.to_owned()]; | ||
| 827 | assert!(found(&query("pen"), &["Use pen pressure sensitivity"])); | ||
| 828 | assert!(found(&query("pen"), &["Pen"])); | ||
| 829 | assert!(!found(&query("pen"), &["Open Notebook"])); | ||
| 830 | assert!(found(&query("⌘b"), &["⌘B"])); | ||
| 831 | assert!(found(&query("storage"), &["Sync & Storage"])); | ||
| 832 | assert!( | ||
| 833 | !found(&["pen".into(), "zzz".into()], &["Pen"]), | ||
| 834 | "every word" | ||
| 835 | ); | ||
| 836 | } | ||
| 369 | } | 837 | } |
crates/snowbound/src/palette.rs+249-73| ... | @@ -1,14 +1,18 @@ | ... | @@ -1,14 +1,18 @@ |
| 1 | //! The palette: the open notebooks, their sections and every page the search index holds to | 1 | //! The palette: the pages and sections shown lately, then the open notebooks, their sections |
| 2 | //! go to, or after a leading `>` the command table's commands, narrowed as typed. | 2 | //! and every page the search index holds to go to, or after a leading `>` the command |
| 3 | //! table's commands, narrowed as typed. ⌘K or a right-click lists what can be done to a row. | ||
| 3 | 4 | ||
| 4 | use crate::{ | 5 | use crate::{ |
| 5 | Command, Library, State, art, | 6 | State, art, |
| 6 | commands::{self, Choice}, | 7 | commands::{self, Choice}, |
| 7 | library, | 8 | library, |
| 9 | menus::{self, Action, Target}, | ||
| 8 | }; | 10 | }; |
| 9 | use onestore::ExGuid; | ||
| 10 | use std::sync::Arc; | 11 | use std::sync::Arc; |
| 11 | use ui::{Id, popup::Item}; | 12 | use ui::{ |
| 13 | Anchor, Id, | ||
| 14 | popup::{Item, Pick}, | ||
| 15 | }; | ||
| 12 | 16 | ||
| 13 | /// What the palette's query starts with to list commands. | 17 | /// What the palette's query starts with to list commands. |
| 14 | pub(crate) const COMMANDS: &str = ">"; | 18 | pub(crate) const COMMANDS: &str = ">"; |
| ... | @@ -17,20 +21,16 @@ pub(crate) fn id() -> Id { | ... | @@ -17,20 +21,16 @@ pub(crate) fn id() -> Id { |
| 17 | Id::ROOT.child("palette") | 21 | Id::ROOT.child("palette") |
| 18 | } | 22 | } |
| 19 | 23 | ||
| 20 | enum Target { | ||
| 21 | Command(commands::Id), | ||
| 22 | Notebook(String), | ||
| 23 | Section(Arc<Library>, String), | ||
| 24 | Page(String, ExGuid), | ||
| 25 | } | ||
| 26 | |||
| 27 | /// A row: its text, the dim text after it, its icon and what it runs; a heading runs nothing. | 24 | /// A row: its text, the dim text after it, its icon and what it runs; a heading runs nothing. |
| 25 | #[derive(Default)] | ||
| 28 | struct Row { | 26 | struct Row { |
| 29 | text: String, | 27 | text: String, |
| 30 | after: String, | 28 | after: String, |
| 31 | icon: Option<&'static [&'static str]>, | 29 | icon: Option<&'static [&'static str]>, |
| 32 | tint: Option<[f32; 4]>, | 30 | tint: Option<[f32; 4]>, |
| 33 | disabled: bool, | 31 | disabled: bool, |
| 32 | /// Repeats a row listed further on, as the recent ones do. | ||
| 33 | repeated: bool, | ||
| 34 | target: Option<Target>, | 34 | target: Option<Target>, |
| 35 | } | 35 | } |
| 36 | 36 | ||
| ... | @@ -38,11 +38,7 @@ impl Row { | ... | @@ -38,11 +38,7 @@ impl Row { |
| 38 | fn heading(text: &str) -> Self { | 38 | fn heading(text: &str) -> Self { |
| 39 | Self { | 39 | Self { |
| 40 | text: text.to_owned(), | 40 | text: text.to_owned(), |
| 41 | after: String::new(), | 41 | ..Self::default() |
| 42 | icon: None, | ||
| 43 | tint: None, | ||
| 44 | disabled: false, | ||
| 45 | target: None, | ||
| 46 | } | 42 | } |
| 47 | } | 43 | } |
| 48 | 44 | ||
| ... | @@ -55,13 +51,15 @@ impl Row { | ... | @@ -55,13 +51,15 @@ impl Row { |
| 55 | disabled: self.disabled, | 51 | disabled: self.disabled, |
| 56 | heading: self.target.is_none(), | 52 | heading: self.target.is_none(), |
| 57 | separated: self.target.is_none(), | 53 | separated: self.target.is_none(), |
| 54 | repeated: self.repeated, | ||
| 58 | ..Item::default() | 55 | ..Item::default() |
| 59 | } | 56 | } |
| 60 | } | 57 | } |
| 61 | } | 58 | } |
| 62 | 59 | ||
| 63 | impl State { | 60 | impl State { |
| 64 | /// Builds the palette while it is open, and runs what is chosen from it. | 61 | /// Builds the palette while it is open, with the actions on a row when asked for, and |
| 62 | /// does what is chosen from either. | ||
| 65 | pub(crate) fn palette(&mut self) { | 63 | pub(crate) fn palette(&mut self) { |
| 66 | if !self.ui.popup_open(id()) { | 64 | if !self.ui.popup_open(id()) { |
| 67 | return; | 65 | return; |
| ... | @@ -74,9 +72,9 @@ impl State { | ... | @@ -74,9 +72,9 @@ impl State { |
| 74 | text: command.title.to_owned(), | 72 | text: command.title.to_owned(), |
| 75 | after: commands::shortcut(command.id), | 73 | after: commands::shortcut(command.id), |
| 76 | icon: crate::artwork(command.id), | 74 | icon: crate::artwork(command.id), |
| 77 | tint: None, | ||
| 78 | disabled: !self.status(&Choice::Command(command.id), &format).enabled, | 75 | disabled: !self.status(&Choice::Command(command.id), &format).enabled, |
| 79 | target: Some(Target::Command(command.id)), | 76 | target: Some(Target::Command(command.id)), |
| 77 | ..Row::default() | ||
| 80 | }) | 78 | }) |
| 81 | .collect(); | 79 | .collect(); |
| 82 | commands.extend(self.tags.iter().enumerate().map(|(place, tag)| { | 80 | commands.extend(self.tags.iter().enumerate().map(|(place, tag)| { |
| ... | @@ -88,41 +86,30 @@ impl State { | ... | @@ -88,41 +86,30 @@ impl State { |
| 88 | tint: Some([1.0; 4]), | 86 | tint: Some([1.0; 4]), |
| 89 | disabled: !self.status(&Choice::Command(id), &format).enabled, | 87 | disabled: !self.status(&Choice::Command(id), &format).enabled, |
| 90 | target: Some(Target::Command(id)), | 88 | target: Some(Target::Command(id)), |
| 89 | ..Row::default() | ||
| 91 | } | 90 | } |
| 92 | })); | 91 | })); |
| 93 | let mut places = vec![Row::heading("Notebooks")]; | 92 | let mut places = self.recent(); |
| 93 | places.push(Row::heading("Notebooks")); | ||
| 94 | places.extend( | 94 | places.extend( |
| 95 | (self.notebooks.iter()) | 95 | (self.notebooks.iter()) |
| 96 | .filter(|library| library.catalog().is_some()) | 96 | .filter(|library| library.catalog().is_some()) |
| 97 | .map(|library| Row { | 97 | .map(|library| Row { |
| 98 | text: library.name.clone(), | 98 | text: library.name.clone(), |
| 99 | after: String::new(), | ||
| 100 | icon: Some(art::NOTEBOOK), | 99 | icon: Some(art::NOTEBOOK), |
| 101 | tint: None, | 100 | target: Some(Target::Notebook(Arc::clone(library))), |
| 102 | disabled: false, | 101 | ..Row::default() |
| 103 | target: Some(Target::Notebook(library.location.clone())), | ||
| 104 | }), | 102 | }), |
| 105 | ); | 103 | ); |
| 106 | places.push(Row::heading("Sections")); | 104 | places.push(Row::heading("Sections")); |
| 107 | let theme = &self.ui.theme; | 105 | let theme = &self.ui.theme; |
| 108 | for library in &self.notebooks { | 106 | for library in &self.notebooks { |
| 109 | // A section opened on its own has no catalog, and lists itself at "". | 107 | // A section opened on its own has no catalog, and lists itself at "". |
| 110 | let mut folders: Vec<_> = library.catalog().into_iter().collect(); | 108 | let folders = menus::folders(library); |
| 111 | let mut at = 0; | ||
| 112 | while let Some(&folder) = folders.get(at) { | ||
| 113 | folders.extend( | ||
| 114 | folder | ||
| 115 | .groups | ||
| 116 | .iter() | ||
| 117 | .filter(|group| !library::recycle_bin(&group.path)), | ||
| 118 | ); | ||
| 119 | at += 1; | ||
| 120 | } | ||
| 121 | let paths = folders.iter().map(|folder| folder.path.as_str()); | ||
| 122 | let paths: Vec<&str> = if folders.is_empty() { | 109 | let paths: Vec<&str> = if folders.is_empty() { |
| 123 | vec![""] | 110 | vec![""] |
| 124 | } else { | 111 | } else { |
| 125 | paths.collect() | 112 | folders.iter().map(|folder| folder.path.as_str()).collect() |
| 126 | }; | 113 | }; |
| 127 | for path in paths { | 114 | for path in paths { |
| 128 | places.extend(library.tabs(path).into_iter().map(|tab| Row { | 115 | places.extend(library.tabs(path).into_iter().map(|tab| Row { |
| ... | @@ -130,8 +117,11 @@ impl State { | ... | @@ -130,8 +117,11 @@ impl State { |
| 130 | after: library.name.clone(), | 117 | after: library.name.clone(), |
| 131 | icon: Some(art::SECTION), | 118 | icon: Some(art::SECTION), |
| 132 | tint: Some(theme.section(crate::section_color(tab.color)).accent), | 119 | tint: Some(theme.section(crate::section_color(tab.color)).accent), |
| 133 | disabled: false, | 120 | target: Some(Target::Section { |
| 134 | target: Some(Target::Section(Arc::clone(library), tab.path)), | 121 | library: Arc::clone(library), |
| 122 | path: tab.path, | ||
| 123 | }), | ||
| 124 | ..Row::default() | ||
| 135 | })); | 125 | })); |
| 136 | } | 126 | } |
| 137 | } | 127 | } |
| ... | @@ -141,57 +131,243 @@ impl State { | ... | @@ -141,57 +131,243 @@ impl State { |
| 141 | .index | 131 | .index |
| 142 | .lock() | 132 | .lock() |
| 143 | .unwrap_or_else(|poison| poison.into_inner()); | 133 | .unwrap_or_else(|poison| poison.into_inner()); |
| 144 | places.extend(index.entries().iter().map(|entry| { | 134 | places.extend(index.entries().iter().filter_map(|entry| { |
| 145 | let (location, path) = entry.section.split_once('\n').unwrap_or_default(); | 135 | let (location, path) = entry.section.split_once('\n').unwrap_or_default(); |
| 136 | let library = self | ||
| 137 | .notebooks | ||
| 138 | .iter() | ||
| 139 | .find(|library| library.location == location)?; | ||
| 146 | let mut section = library::section_name(path, &None); | 140 | let mut section = library::section_name(path, &None); |
| 147 | if section.is_empty() { | 141 | if section.is_empty() { |
| 148 | section = (self.notebooks.iter()) | 142 | section = library.name.clone(); |
| 149 | .find(|library| library.location == location) | ||
| 150 | .map_or_else(String::new, |library| library.name.clone()); | ||
| 151 | } | 143 | } |
| 152 | Row { | 144 | Some(Row { |
| 153 | text: if entry.title.is_empty() { | 145 | text: title(&entry.title), |
| 154 | "Untitled page".to_owned() | ||
| 155 | } else { | ||
| 156 | entry.title.clone() | ||
| 157 | }, | ||
| 158 | after: section, | 146 | after: section, |
| 159 | icon: Some(art::PAGE), | 147 | icon: Some(art::PAGE), |
| 160 | tint: None, | 148 | target: Some(Target::Page { |
| 161 | disabled: false, | 149 | library: Arc::clone(library), |
| 162 | target: Some(Target::Page(entry.section.clone(), entry.space)), | 150 | path: path.to_owned(), |
| 163 | } | 151 | space: entry.space, |
| 152 | }), | ||
| 153 | ..Row::default() | ||
| 154 | }) | ||
| 164 | })); | 155 | })); |
| 165 | drop(index); | 156 | drop(index); |
| 166 | let items = [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>()); | 157 | let mut items = |
| 167 | let chosen = ui::popup::palette( | 158 | [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>()); |
| 159 | // The latest page or section starts highlighted, so Enter goes back to it. | ||
| 160 | if let Some(latest) = items[1].get_mut(1).filter(|item| item.repeated) { | ||
| 161 | latest.current = true; | ||
| 162 | } | ||
| 163 | let picked = ui::popup::palette( | ||
| 168 | &mut self.ui, | 164 | &mut self.ui, |
| 169 | id(), | 165 | id(), |
| 170 | &[(COMMANDS, &items[0]), ("", &items[1])], | 166 | &[(COMMANDS, &items[0]), ("", &items[1])], |
| 171 | "Search pages, sections and notebooks (type > for commands)", | 167 | "Search pages, sections and notebooks (type > for commands)", |
| 172 | ); | 168 | ); |
| 173 | let mut rows = [commands, places]; | 169 | let mut rows = [commands, places]; |
| 174 | match chosen.and_then(|(mode, index)| rows[mode].swap_remove(index).target) { | 170 | match picked { |
| 175 | Some(Target::Command(command)) => self.choose(Choice::Command(command)), | 171 | Some(Pick::Run(mode, index)) => { |
| 176 | Some(Target::Notebook(location)) => self.open_notebook(location, None), | 172 | if let Some(target) = rows[mode].swap_remove(index).target { |
| 177 | Some(Target::Section(library, path)) => { | 173 | let action = match target { |
| 178 | self.commands.push(Command::OpenSection(library, path)); | 174 | Target::Command(_) => Action::Run, |
| 179 | } | 175 | _ => Action::Open, |
| 180 | Some(Target::Page(section, space)) => { | 176 | }; |
| 181 | let open = (self.session.as_ref()) | 177 | self.act_on(target, action); |
| 182 | .map(|session| session.library.key(&session.tabs[session.tab].path)); | ||
| 183 | let (location, path) = section.split_once('\n').unwrap_or_default(); | ||
| 184 | if open.as_ref() == Some(&section) { | ||
| 185 | self.commands.push(Command::OpenPage(space)); | ||
| 186 | } else if let Some(library) = | ||
| 187 | (self.notebooks.iter()).find(|library| library.location == location) | ||
| 188 | { | ||
| 189 | self.commands | ||
| 190 | .push(Command::OpenSection(Arc::clone(library), path.to_owned())); | ||
| 191 | self.last_pages.insert(section, space); | ||
| 192 | } | 178 | } |
| 193 | } | 179 | } |
| 180 | Some(Pick::Actions(mode, index)) => { | ||
| 181 | self.actions = rows[mode].swap_remove(index).target; | ||
| 182 | } | ||
| 194 | None => {} | 183 | None => {} |
| 195 | } | 184 | } |
| 185 | let panel = ui::popup::actions(id()); | ||
| 186 | let Some(target) = self.actions.clone().filter(|_| self.ui.popup_open(panel)) else { | ||
| 187 | self.actions = None; | ||
| 188 | return; | ||
| 189 | }; | ||
| 190 | // Run or Open leads, as Enter on the row does. | ||
| 191 | let (keys, first) = match target { | ||
| 192 | Target::Command(id) => ( | ||
| 193 | commands::shortcut(id), | ||
| 194 | ( | ||
| 195 | Action::Run, | ||
| 196 | Item { | ||
| 197 | text: "Run", | ||
| 198 | disabled: !self.status(&Choice::Command(id), &format).enabled, | ||
| 199 | ..Item::default() | ||
| 200 | }, | ||
| 201 | ), | ||
| 202 | ), | ||
| 203 | _ => ( | ||
| 204 | String::new(), | ||
| 205 | ( | ||
| 206 | Action::Open, | ||
| 207 | Item { | ||
| 208 | text: "Open", | ||
| 209 | ..Item::default() | ||
| 210 | }, | ||
| 211 | ), | ||
| 212 | ), | ||
| 213 | }; | ||
| 214 | let first = ( | ||
| 215 | first.0, | ||
| 216 | Item { | ||
| 217 | shortcut: &keys, | ||
| 218 | ..first.1 | ||
| 219 | }, | ||
| 220 | ); | ||
| 221 | let mut actions = vec![first]; | ||
| 222 | actions.extend(self.actions(&target).into_iter().enumerate().map( | ||
| 223 | |(at, (action, item))| { | ||
| 224 | let separated = item.separated || at == 0; | ||
| 225 | (action, Item { separated, ..item }) | ||
| 226 | }, | ||
| 227 | )); | ||
| 228 | let anchor = Anchor::Right(self.ui.rect(id()).unwrap_or_default()); | ||
| 229 | if let Some(action) = | ||
| 230 | self.action_menu(panel, anchor, Some("Search actions"), &target, &actions) | ||
| 231 | { | ||
| 232 | self.actions = None; | ||
| 233 | self.act_on(target, action); | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | /// Under a heading, the pages shown lately but the one shown, their sections but the one | ||
| 238 | /// open, the notebooks of those since closed, and the servers saved to reconnect to; | ||
| 239 | /// latest first, and none before any. Pages and sections since gone are forgotten. | ||
| 240 | fn recent(&mut self) -> Vec<Row> { | ||
| 241 | let index = self | ||
| 242 | .search | ||
| 243 | .index | ||
| 244 | .lock() | ||
| 245 | .unwrap_or_else(|poison| poison.into_inner()); | ||
| 246 | let notebooks = &self.notebooks; | ||
| 247 | let library = |location: &str| { | ||
| 248 | (notebooks.iter()) | ||
| 249 | .find(|library| library.location == location) | ||
| 250 | .map(Arc::clone) | ||
| 251 | }; | ||
| 252 | // A closed notebook keeps its places to open it again, an unreadable one until it reads. | ||
| 253 | let pruned = self.trail.prune(&index, |place| { | ||
| 254 | library(&place.notebook) | ||
| 255 | .is_none_or(|library| library.notebook.is_err() || library.contains(&place.section)) | ||
| 256 | }); | ||
| 257 | let shown = self.session.as_ref().map(|session| { | ||
| 258 | ( | ||
| 259 | session.library.location.as_str(), | ||
| 260 | session.tabs[session.tab].path.as_str(), | ||
| 261 | session.space, | ||
| 262 | ) | ||
| 263 | }); | ||
| 264 | let theme = &self.ui.theme; | ||
| 265 | let [mut pages, mut sections, mut closed] = [(); 3].map(|()| Vec::<Row>::new()); | ||
| 266 | let listed = |rows: &[Row], target: &Target| { | ||
| 267 | rows.iter().any(|row| match (&row.target, target) { | ||
| 268 | ( | ||
| 269 | Some(Target::Section { library, path }), | ||
| 270 | Target::Section { | ||
| 271 | library: other, | ||
| 272 | path: at, | ||
| 273 | }, | ||
| 274 | ) => library.location == other.location && path == at, | ||
| 275 | (Some(Target::Closed(location)), Target::Closed(other)) => location == other, | ||
| 276 | _ => false, | ||
| 277 | }) | ||
| 278 | }; | ||
| 279 | for place in &self.trail.recent { | ||
| 280 | let Some(library) = library(&place.notebook) else { | ||
| 281 | let target = Target::Closed(place.notebook.clone()); | ||
| 282 | if !listed(&closed, &target) { | ||
| 283 | closed.push(Row { | ||
| 284 | text: (place.notebook.rsplit(['/', '\\'])) | ||
| 285 | .find(|name| !name.is_empty()) | ||
| 286 | .unwrap_or(&place.notebook) | ||
| 287 | .to_owned(), | ||
| 288 | after: "Closed".to_owned(), | ||
| 289 | icon: Some(art::NOTEBOOK), | ||
| 290 | target: Some(target), | ||
| 291 | ..Row::default() | ||
| 292 | }); | ||
| 293 | } | ||
| 294 | continue; | ||
| 295 | }; | ||
| 296 | if library.notebook.is_err() { | ||
| 297 | continue; | ||
| 298 | } | ||
| 299 | let here = (place.notebook.as_str(), place.section.as_str()); | ||
| 300 | let section = Target::Section { | ||
| 301 | library: Arc::clone(&library), | ||
| 302 | path: place.section.clone(), | ||
| 303 | }; | ||
| 304 | if shown.is_none_or(|(location, path, _)| (location, path) != here) | ||
| 305 | && !listed(&sections, &section) | ||
| 306 | && let Some(tab) = (library.tabs(&menus::folder(&place.section)).into_iter()) | ||
| 307 | .find(|tab| tab.path == place.section) | ||
| 308 | { | ||
| 309 | sections.push(Row { | ||
| 310 | text: tab.name, | ||
| 311 | after: library.name.clone(), | ||
| 312 | icon: Some(art::SECTION), | ||
| 313 | tint: Some(theme.section(crate::section_color(tab.color)).accent), | ||
| 314 | repeated: true, | ||
| 315 | target: Some(section), | ||
| 316 | ..Row::default() | ||
| 317 | }); | ||
| 318 | } | ||
| 319 | let key = library.key(&place.section); | ||
| 320 | // A page the index hasn't read yet waits for its title. | ||
| 321 | let Some(entry) = (index.entries().iter()) | ||
| 322 | .find(|entry| entry.space == place.page && entry.section == key) | ||
| 323 | .filter(|_| shown != Some((here.0, here.1, place.page))) | ||
| 324 | else { | ||
| 325 | continue; | ||
| 326 | }; | ||
| 327 | pages.push(Row { | ||
| 328 | text: title(&entry.title), | ||
| 329 | after: library::section_name(&place.section, &None), | ||
| 330 | icon: Some(art::PAGE), | ||
| 331 | repeated: true, | ||
| 332 | target: Some(Target::Page { | ||
| 333 | library, | ||
| 334 | path: place.section.clone(), | ||
| 335 | space: place.page, | ||
| 336 | }), | ||
| 337 | ..Row::default() | ||
| 338 | }); | ||
| 339 | } | ||
| 340 | drop(index); | ||
| 341 | if pruned { | ||
| 342 | self.save_settings(); | ||
| 343 | } | ||
| 344 | let servers = self.servers.iter().map(|address| Row { | ||
| 345 | text: address.strip_prefix("smb://").unwrap_or(address).to_owned(), | ||
| 346 | after: "Server".to_owned(), | ||
| 347 | icon: Some(art::SERVER), | ||
| 348 | target: Some(Target::Server(address.clone())), | ||
| 349 | ..Row::default() | ||
| 350 | }); | ||
| 351 | let rows: Vec<Row> = pages | ||
| 352 | .into_iter() | ||
| 353 | .chain(sections) | ||
| 354 | .chain(closed) | ||
| 355 | .chain(servers) | ||
| 356 | .collect(); | ||
| 357 | if rows.is_empty() { | ||
| 358 | return rows; | ||
| 359 | } | ||
| 360 | std::iter::once(Row::heading("Recent")) | ||
| 361 | .chain(rows) | ||
| 362 | .collect() | ||
| 363 | } | ||
| 364 | } | ||
| 365 | |||
| 366 | /// A page's title as the palette lists it. | ||
| 367 | fn title(title: &str) -> String { | ||
| 368 | if title.is_empty() { | ||
| 369 | "Untitled page".to_owned() | ||
| 370 | } else { | ||
| 371 | title.to_owned() | ||
| 196 | } | 372 | } |
| 197 | } | 373 | } |
crates/snowbound/src/paste.rs+572-42| ... | @@ -1,13 +1,21 @@ | ... | @@ -1,13 +1,21 @@ |
| 1 | //! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them. | 1 | //! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them. |
| 2 | 2 | ||
| 3 | use crate::{State, platform}; | 3 | use crate::{State, platform}; |
| 4 | use std::{error::Error, path::PathBuf}; | 4 | use canvas::editor::Piece; |
| 5 | use std::{ | ||
| 6 | error::Error, | ||
| 7 | path::{Path, PathBuf}, | ||
| 8 | time::Duration, | ||
| 9 | }; | ||
| 5 | 10 | ||
| 6 | /// What Paste takes from the clipboard. Files come first, as Finder also offers their names | 11 | /// What Paste takes from the clipboard. Files come first, as Finder also offers their names |
| 7 | /// as text and their icons as pictures; text before a picture, as Office and browsers offer | 12 | /// as text and their icons as pictures; then a page holding pictures, as its text alone |
| 8 | /// a picture of copied text beside it. | 13 | /// would lose them; text before a picture, as Office and browsers offer a picture of copied |
| 14 | /// text beside it. | ||
| 9 | pub(crate) enum Pasted { | 15 | pub(crate) enum Pasted { |
| 10 | Files(Vec<PathBuf>), | 16 | Files(Vec<PathBuf>), |
| 17 | /// HTML holding a picture. | ||
| 18 | Page(String), | ||
| 11 | Text(String), | 19 | Text(String), |
| 12 | Picture(Vec<u8>), | 20 | Picture(Vec<u8>), |
| 13 | } | 21 | } |
| ... | @@ -17,13 +25,39 @@ impl State { | ... | @@ -17,13 +25,39 @@ impl State { |
| 17 | match self.clipboard.pasted() { | 25 | match self.clipboard.pasted() { |
| 18 | Some(Pasted::Files(paths)) => { | 26 | Some(Pasted::Files(paths)) => { |
| 19 | for path in paths { | 27 | for path in paths { |
| 20 | // OneNote 2010 pastes a picture file as the picture (lab, 2026-09-30). | 28 | self.place_file(&path, None)?; |
| 21 | match std::fs::read(&path) { | 29 | } |
| 22 | Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { | 30 | } |
| 23 | self.insert_picture(bytes)? | 31 | Some(Pasted::Page(html)) => { |
| 24 | } | 32 | // Web pictures come later, so the window never waits on the network. |
| 25 | _ => self.attach(&path, None)?, | 33 | let mut fetched = Vec::new(); |
| 34 | let pieces = html_pieces(&html, |source, css| { | ||
| 35 | if source.starts_with("https://") || source.starts_with("http://") { | ||
| 36 | fetched.push((source.to_owned(), css)); | ||
| 37 | return Some(Piece::Awaited); | ||
| 26 | } | 38 | } |
| 39 | let bytes = load(source)?; | ||
| 40 | let size = sized(&bytes, css)?; | ||
| 41 | Some(Piece::Picture(bytes, size)) | ||
| 42 | }); | ||
| 43 | let language = canvas::language::lcid(&platform::input_language()); | ||
| 44 | let (places, response) = self.view.paste_pieces(pieces, language)?; | ||
| 45 | self.respond(response); | ||
| 46 | for (place, (source, css)) in places.into_iter().zip(fetched) { | ||
| 47 | let proxy = self.proxy.clone(); | ||
| 48 | std::thread::spawn(move || { | ||
| 49 | let Some(bytes) = fetch(&source) else { | ||
| 50 | return; | ||
| 51 | }; | ||
| 52 | let Some(size) = sized(&bytes, css) else { | ||
| 53 | return; | ||
| 54 | }; | ||
| 55 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { | ||
| 56 | let response = state.view.insert_awaited(place, bytes, size)?; | ||
| 57 | state.respond(response); | ||
| 58 | Ok(()) | ||
| 59 | }))); | ||
| 60 | }); | ||
| 27 | } | 61 | } |
| 28 | } | 62 | } |
| 29 | Some(Pasted::Text(text)) => { | 63 | Some(Pasted::Text(text)) => { |
| ... | @@ -31,15 +65,34 @@ impl State { | ... | @@ -31,15 +65,34 @@ impl State { |
| 31 | let response = self.view.paste(&text, language)?; | 65 | let response = self.view.paste(&text, language)?; |
| 32 | self.respond(response); | 66 | self.respond(response); |
| 33 | } | 67 | } |
| 34 | Some(Pasted::Picture(bytes)) => self.insert_picture(bytes)?, | 68 | Some(Pasted::Picture(bytes)) => self.insert_picture(bytes, None)?, |
| 35 | None => {} | 69 | None => {} |
| 36 | } | 70 | } |
| 37 | Ok(()) | 71 | Ok(()) |
| 38 | } | 72 | } |
| 39 | 73 | ||
| 40 | /// Puts an encoded picture at the caret at the size its resolution gives it, as OneNote | 74 | /// A pasted file, or one dropped at `at`, a window point: a picture file as its picture, |
| 41 | /// 2010 inserts and pastes one. | 75 | /// as OneNote 2010 pastes one (lab, 2026-09-30), and any other file attached. |
| 42 | pub(crate) fn insert_picture(&mut self, bytes: Vec<u8>) -> Result<(), Box<dyn Error>> { | 76 | pub(crate) fn place_file( |
| 77 | &mut self, | ||
| 78 | path: &Path, | ||
| 79 | at: Option<[f32; 2]>, | ||
| 80 | ) -> Result<(), Box<dyn Error>> { | ||
| 81 | match std::fs::read(path) { | ||
| 82 | Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { | ||
| 83 | self.insert_picture(bytes, at) | ||
| 84 | } | ||
| 85 | _ => self.attach(path, at), | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | /// Puts an encoded picture at the caret, or where it is dropped at `at`, a window point, | ||
| 90 | /// at the size its resolution gives it, as OneNote 2010 inserts and pastes one. | ||
| 91 | pub(crate) fn insert_picture( | ||
| 92 | &mut self, | ||
| 93 | bytes: Vec<u8>, | ||
| 94 | at: Option<[f32; 2]>, | ||
| 95 | ) -> Result<(), Box<dyn Error>> { | ||
| 43 | let Ok(pixels) = draw::RasterImage::measure(&bytes) else { | 96 | let Ok(pixels) = draw::RasterImage::measure(&bytes) else { |
| 44 | platform::alert( | 97 | platform::alert( |
| 45 | "Couldn't insert the picture", | 98 | "Couldn't insert the picture", |
| ... | @@ -48,12 +101,289 @@ impl State { | ... | @@ -48,12 +101,289 @@ impl State { |
| 48 | return Ok(()); | 101 | return Ok(()); |
| 49 | }; | 102 | }; |
| 50 | let size = picture_size(&bytes, pixels); | 103 | let size = picture_size(&bytes, pixels); |
| 51 | let response = self.view.insert_picture(bytes, size)?; | 104 | let response = match at { |
| 105 | Some(point) => self | ||
| 106 | .view | ||
| 107 | .drop_picture(self.page_point(point), bytes, size)?, | ||
| 108 | None => self.view.insert_picture(bytes, size)?, | ||
| 109 | }; | ||
| 52 | self.respond(response); | 110 | self.respond(response); |
| 53 | Ok(()) | 111 | Ok(()) |
| 54 | } | 112 | } |
| 55 | } | 113 | } |
| 56 | 114 | ||
| 115 | /// A web page fragment's text, a line to each block, and its pictures in order, as OneNote | ||
| 116 | /// 2010 pastes them (lab, 2026-09-30). `load` makes a picture's piece from its source and | ||
| 117 | /// its `width` and `height` in CSS pixels; one it can't is left out. | ||
| 118 | fn html_pieces( | ||
| 119 | html: &str, | ||
| 120 | mut load: impl FnMut(&str, [Option<f32>; 2]) -> Option<Piece>, | ||
| 121 | ) -> Vec<Piece> { | ||
| 122 | let html = html | ||
| 123 | .split_once("<!--StartFragment-->") | ||
| 124 | .map_or(html, |(_, fragment)| fragment); | ||
| 125 | let html = html | ||
| 126 | .split_once("<!--EndFragment-->") | ||
| 127 | .map_or(html, |(fragment, _)| fragment); | ||
| 128 | let mut pieces = Vec::new(); | ||
| 129 | let mut text = String::new(); | ||
| 130 | let flush = |text: &mut String, pieces: &mut Vec<Piece>| { | ||
| 131 | let trimmed = text.trim_matches(|c: char| c.is_whitespace()); | ||
| 132 | if !trimmed.is_empty() { | ||
| 133 | pieces.push(Piece::Text(trimmed.to_owned())); | ||
| 134 | } | ||
| 135 | text.clear(); | ||
| 136 | }; | ||
| 137 | let line = |text: &mut String| { | ||
| 138 | text.truncate(text.trim_end_matches(' ').len()); | ||
| 139 | if !text.is_empty() && !text.ends_with('\n') { | ||
| 140 | text.push('\n'); | ||
| 141 | } | ||
| 142 | }; | ||
| 143 | // Elements whose content is not text: script, style, head and title. | ||
| 144 | let mut hidden: Option<String> = None; | ||
| 145 | let mut rest = html; | ||
| 146 | while !rest.is_empty() { | ||
| 147 | if let Some(after) = rest.strip_prefix("<!--") { | ||
| 148 | rest = after.split_once("-->").map_or("", |(_, after)| after); | ||
| 149 | continue; | ||
| 150 | } | ||
| 151 | let Some(after) = rest.strip_prefix('<') else { | ||
| 152 | let end = rest.find('<').unwrap_or(rest.len()); | ||
| 153 | if hidden.is_none() { | ||
| 154 | for c in decode_entities(&rest[..end]).chars() { | ||
| 155 | if c.is_whitespace() && c != '\u{a0}' { | ||
| 156 | if !text.is_empty() && !text.ends_with([' ', '\n']) { | ||
| 157 | text.push(' '); | ||
| 158 | } | ||
| 159 | } else { | ||
| 160 | text.push(if c == '\u{a0}' { ' ' } else { c }); | ||
| 161 | } | ||
| 162 | } | ||
| 163 | } | ||
| 164 | rest = &rest[end..]; | ||
| 165 | continue; | ||
| 166 | }; | ||
| 167 | let end = tag_end(after); | ||
| 168 | let tag = &after[..end]; | ||
| 169 | rest = after.get(end + 1..).unwrap_or(""); | ||
| 170 | let (closing, tag) = match tag.strip_prefix('/') { | ||
| 171 | Some(tag) => (true, tag), | ||
| 172 | None => (false, tag), | ||
| 173 | }; | ||
| 174 | let name = tag | ||
| 175 | .split(|c: char| c.is_whitespace() || c == '/') | ||
| 176 | .next() | ||
| 177 | .unwrap_or_default() | ||
| 178 | .to_ascii_lowercase(); | ||
| 179 | if let Some(until) = &hidden { | ||
| 180 | if closing && name == *until { | ||
| 181 | hidden = None; | ||
| 182 | } | ||
| 183 | continue; | ||
| 184 | } | ||
| 185 | match name.as_str() { | ||
| 186 | "script" | "style" | "head" | "title" if !closing => hidden = Some(name), | ||
| 187 | "br" => { | ||
| 188 | text.truncate(text.trim_end_matches(' ').len()); | ||
| 189 | text.push('\n'); | ||
| 190 | } | ||
| 191 | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | ||
| 192 | | "pre" | "table" | "ul" | "ol" | "dt" | "dd" | "section" | "article" | "header" | ||
| 193 | | "footer" | "figure" | "figcaption" => line(&mut text), | ||
| 194 | "img" if !closing => { | ||
| 195 | let css = |name| { | ||
| 196 | attribute(tag, name) | ||
| 197 | .and_then(|value| value.trim_end_matches("px").parse::<f32>().ok()) | ||
| 198 | .filter(|pixels| *pixels > 0.0) | ||
| 199 | }; | ||
| 200 | let Some(picture) = attribute(tag, "src").and_then(|source| { | ||
| 201 | load(&decode_entities(source), [css("width"), css("height")]) | ||
| 202 | }) else { | ||
| 203 | continue; | ||
| 204 | }; | ||
| 205 | flush(&mut text, &mut pieces); | ||
| 206 | pieces.push(picture); | ||
| 207 | } | ||
| 208 | _ => {} | ||
| 209 | } | ||
| 210 | } | ||
| 211 | flush(&mut text, &mut pieces); | ||
| 212 | pieces | ||
| 213 | } | ||
| 214 | |||
| 215 | /// Where a tag's text ends, before its `>`, outside quoted attribute values. | ||
| 216 | fn tag_end(tag: &str) -> usize { | ||
| 217 | let mut quote = None; | ||
| 218 | for (at, c) in tag.char_indices() { | ||
| 219 | match (quote, c) { | ||
| 220 | (None, '"' | '\'') => quote = Some(c), | ||
| 221 | (Some(open), _) if c == open => quote = None, | ||
| 222 | (None, '>') => return at, | ||
| 223 | _ => {} | ||
| 224 | } | ||
| 225 | } | ||
| 226 | tag.len() | ||
| 227 | } | ||
| 228 | |||
| 229 | /// The value of attribute `name` in `tag`, entities still encoded. | ||
| 230 | fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> { | ||
| 231 | let mut rest = tag; | ||
| 232 | while let Some(at) = rest.find('=') { | ||
| 233 | let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?; | ||
| 234 | let value = rest[at + 1..].trim_start(); | ||
| 235 | let (value, after) = match value.chars().next()? { | ||
| 236 | quote @ ('"' | '\'') => value[1..].split_once(quote)?, | ||
| 237 | _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())), | ||
| 238 | }; | ||
| 239 | if key.eq_ignore_ascii_case(name) { | ||
| 240 | return Some(value); | ||
| 241 | } | ||
| 242 | rest = after; | ||
| 243 | } | ||
| 244 | None | ||
| 245 | } | ||
| 246 | |||
| 247 | fn decode_entities(text: &str) -> String { | ||
| 248 | let mut decoded = String::with_capacity(text.len()); | ||
| 249 | let mut rest = text; | ||
| 250 | while let Some(at) = rest.find('&') { | ||
| 251 | decoded.push_str(&rest[..at]); | ||
| 252 | rest = &rest[at..]; | ||
| 253 | let entity = rest[1..] | ||
| 254 | .find(';') | ||
| 255 | .filter(|end| *end <= 10) | ||
| 256 | .map(|end| &rest[1..end + 1]); | ||
| 257 | let c = entity.and_then(|entity| match entity { | ||
| 258 | "amp" => Some('&'), | ||
| 259 | "lt" => Some('<'), | ||
| 260 | "gt" => Some('>'), | ||
| 261 | "quot" => Some('"'), | ||
| 262 | "apos" => Some('\''), | ||
| 263 | "nbsp" => Some('\u{a0}'), | ||
| 264 | _ => { | ||
| 265 | let number = entity.strip_prefix('#')?; | ||
| 266 | let code = match number.strip_prefix(['x', 'X']) { | ||
| 267 | Some(hex) => u32::from_str_radix(hex, 16).ok()?, | ||
| 268 | None => number.parse().ok()?, | ||
| 269 | }; | ||
| 270 | char::from_u32(code) | ||
| 271 | } | ||
| 272 | }); | ||
| 273 | match (c, entity) { | ||
| 274 | (Some(c), Some(entity)) => { | ||
| 275 | decoded.push(c); | ||
| 276 | rest = &rest[entity.len() + 2..]; | ||
| 277 | } | ||
| 278 | _ => { | ||
| 279 | decoded.push('&'); | ||
| 280 | rest = &rest[1..]; | ||
| 281 | } | ||
| 282 | } | ||
| 283 | } | ||
| 284 | decoded.push_str(rest); | ||
| 285 | decoded | ||
| 286 | } | ||
| 287 | |||
| 288 | /// A picture's size in points from its `width` and `height` in CSS pixels, either giving | ||
| 289 | /// the other by its proportions, or else from its resolution; none where it doesn't decode. | ||
| 290 | fn sized(bytes: &[u8], css: [Option<f32>; 2]) -> Option<[f32; 2]> { | ||
| 291 | let natural = picture_size(bytes, draw::RasterImage::measure(bytes).ok()?); | ||
| 292 | Some(match css { | ||
| 293 | [Some(width), Some(height)] => [width * 0.75, height * 0.75], | ||
| 294 | [Some(width), None] => [width * 0.75, natural[1] * width * 0.75 / natural[0]], | ||
| 295 | [None, Some(height)] => [natural[0] * height * 0.75 / natural[1], height * 0.75], | ||
| 296 | [None, None] => natural, | ||
| 297 | }) | ||
| 298 | } | ||
| 299 | |||
| 300 | /// A picture's bytes from a `data:` address or a local file; none from a relative address, | ||
| 301 | /// which has no page to resolve against. | ||
| 302 | fn load(source: &str) -> Option<Vec<u8>> { | ||
| 303 | if let Some(data) = source.strip_prefix("data:") { | ||
| 304 | let (kind, payload) = data.split_once(',')?; | ||
| 305 | return if kind.ends_with(";base64") { | ||
| 306 | base64(payload) | ||
| 307 | } else { | ||
| 308 | Some(percent_decode(payload)) | ||
| 309 | }; | ||
| 310 | } | ||
| 311 | if let Some(path) = source.strip_prefix("file://") { | ||
| 312 | let path = String::from_utf8(percent_decode(path)).ok()?; | ||
| 313 | // file:///C:/… on Windows. | ||
| 314 | let path = match path.strip_prefix('/') { | ||
| 315 | Some(drive) if cfg!(windows) => drive.to_owned(), | ||
| 316 | _ => path, | ||
| 317 | }; | ||
| 318 | return std::fs::read(path).ok(); | ||
| 319 | } | ||
| 320 | None | ||
| 321 | } | ||
| 322 | |||
| 323 | /// A web picture's bytes, on a thread of its own. | ||
| 324 | fn fetch(source: &str) -> Option<Vec<u8>> { | ||
| 325 | let agent: ureq::Agent = ureq::Agent::config_builder() | ||
| 326 | .timeout_global(Some(Duration::from_secs(10))) | ||
| 327 | .build() | ||
| 328 | .into(); | ||
| 329 | agent | ||
| 330 | .get(source) | ||
| 331 | .call() | ||
| 332 | .ok()? | ||
| 333 | .body_mut() | ||
| 334 | .with_config() | ||
| 335 | .limit(64 << 20) | ||
| 336 | .read_to_vec() | ||
| 337 | .ok() | ||
| 338 | } | ||
| 339 | |||
| 340 | fn percent_decode(text: &str) -> Vec<u8> { | ||
| 341 | let bytes = text.as_bytes(); | ||
| 342 | let mut decoded = Vec::with_capacity(bytes.len()); | ||
| 343 | let mut at = 0; | ||
| 344 | while at < bytes.len() { | ||
| 345 | let hex = bytes | ||
| 346 | .get(at + 1..at + 3) | ||
| 347 | .and_then(|hex| u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()); | ||
| 348 | match (bytes[at], hex) { | ||
| 349 | (b'%', Some(byte)) => { | ||
| 350 | decoded.push(byte); | ||
| 351 | at += 3; | ||
| 352 | } | ||
| 353 | (byte, _) => { | ||
| 354 | decoded.push(byte); | ||
| 355 | at += 1; | ||
| 356 | } | ||
| 357 | } | ||
| 358 | } | ||
| 359 | decoded | ||
| 360 | } | ||
| 361 | |||
| 362 | fn base64(text: &str) -> Option<Vec<u8>> { | ||
| 363 | let mut decoded = Vec::with_capacity(text.len() * 3 / 4); | ||
| 364 | let (mut bits, mut count) = (0_u32, 0); | ||
| 365 | for c in text | ||
| 366 | .bytes() | ||
| 367 | .filter(|c| !c.is_ascii_whitespace() && *c != b'=') | ||
| 368 | { | ||
| 369 | let value = match c { | ||
| 370 | b'A'..=b'Z' => c - b'A', | ||
| 371 | b'a'..=b'z' => c - b'a' + 26, | ||
| 372 | b'0'..=b'9' => c - b'0' + 52, | ||
| 373 | b'+' | b'-' => 62, | ||
| 374 | b'/' | b'_' => 63, | ||
| 375 | _ => return None, | ||
| 376 | }; | ||
| 377 | bits = bits << 6 | u32::from(value); | ||
| 378 | count += 6; | ||
| 379 | if count >= 8 { | ||
| 380 | count -= 8; | ||
| 381 | decoded.push((bits >> count) as u8); | ||
| 382 | } | ||
| 383 | } | ||
| 384 | Some(decoded) | ||
| 385 | } | ||
| 386 | |||
| 57 | /// A picture's size in points: its pixels at the resolution it declares, or at 96 dpi | 387 | /// A picture's size in points: its pixels at the resolution it declares, or at 96 dpi |
| 58 | /// without one, as OneNote 2010 sizes inserted and pasted pictures and pasted bitmaps | 388 | /// without one, as OneNote 2010 sizes inserted and pasted pictures and pasted bitmaps |
| 59 | /// (lab, 2026-09-30). | 389 | /// (lab, 2026-09-30). |
| ... | @@ -62,39 +392,122 @@ fn picture_size(encoded: &[u8], pixels: [u32; 2]) -> [f32; 2] { | ... | @@ -62,39 +392,122 @@ fn picture_size(encoded: &[u8], pixels: [u32; 2]) -> [f32; 2] { |
| 62 | [0, 1].map(|axis| pixels[axis] as f32 * 72.0 / dpi[axis]) | 392 | [0, 1].map(|axis| pixels[axis] as f32 * 72.0 / dpi[axis]) |
| 63 | } | 393 | } |
| 64 | 394 | ||
| 65 | /// Dots per inch from a PNG's `pHYs` or a JPEG's JFIF density, where they give one. | 395 | /// Dots per inch from a PNG's `pHYs`, or a JPEG's JFIF density or else its Exif |
| 396 | /// resolution, where they give one. | ||
| 66 | fn resolution(encoded: &[u8]) -> Option<[f32; 2]> { | 397 | fn resolution(encoded: &[u8]) -> Option<[f32; 2]> { |
| 67 | let dpi = if let Some(mut chunks) = encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") { | 398 | let dpi = match encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") { |
| 68 | loop { | 399 | Some(chunks) => png_resolution(chunks)?, |
| 69 | let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize; | 400 | None => jpeg_resolution(encoded.strip_prefix(b"\xff\xd8")?)?, |
| 70 | let kind = chunks.get(4..8)?; | 401 | }; |
| 71 | let data = chunks.get(8..8 + length)?; | 402 | dpi.iter().all(|dpi| *dpi > 0.0).then_some(dpi) |
| 72 | if kind == b"IDAT" { | 403 | } |
| 73 | return None; | 404 | |
| 74 | } | 405 | fn png_resolution(mut chunks: &[u8]) -> Option<[f32; 2]> { |
| 75 | // Unit 1 is the metre. | 406 | loop { |
| 76 | if kind == b"pHYs" && data.len() == 9 && data[8] == 1 { | 407 | let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize; |
| 77 | let per_metre = | 408 | let kind = chunks.get(4..8)?; |
| 78 | |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap()); | 409 | let data = chunks.get(8..8 + length)?; |
| 79 | break [per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254); | 410 | if kind == b"IDAT" { |
| 80 | } | ||
| 81 | chunks = chunks.get(12 + length..)?; | ||
| 82 | } | ||
| 83 | } else { | ||
| 84 | // Length, identifier, version, units, then the densities. | ||
| 85 | let app0 = encoded.strip_prefix(b"\xff\xd8\xff\xe0")?.get(..14)?; | ||
| 86 | if &app0[2..7] != b"JFIF\0" { | ||
| 87 | return None; | 411 | return None; |
| 88 | } | 412 | } |
| 89 | let density = |at: usize| u16::from_be_bytes([app0[at], app0[at + 1]]) as f32; | 413 | // Unit 1 is the metre. |
| 90 | let dots = [density(10), density(12)]; | 414 | if kind == b"pHYs" && data.len() == 9 && data[8] == 1 { |
| 91 | match app0[9] { | 415 | let per_metre = |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap()); |
| 92 | 1 => dots, | 416 | return Some([per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254)); |
| 93 | 2 => dots.map(|per_cm| per_cm * 2.54), | 417 | } |
| 94 | _ => return None, | 418 | chunks = chunks.get(12 + length..)?; |
| 419 | } | ||
| 420 | } | ||
| 421 | |||
| 422 | /// `segments` follow the start-of-image marker. | ||
| 423 | fn jpeg_resolution(mut segments: &[u8]) -> Option<[f32; 2]> { | ||
| 424 | let mut exif = None; | ||
| 425 | while let [0xff, marker, high, low, rest @ ..] = segments { | ||
| 426 | let length = usize::from(u16::from_be_bytes([*high, *low])).checked_sub(2)?; | ||
| 427 | let data = rest.get(..length)?; | ||
| 428 | match (*marker, data) { | ||
| 429 | ( | ||
| 430 | 0xe0, | ||
| 431 | [ | ||
| 432 | b'J', | ||
| 433 | b'F', | ||
| 434 | b'I', | ||
| 435 | b'F', | ||
| 436 | 0, | ||
| 437 | _, | ||
| 438 | _, | ||
| 439 | units @ (1 | 2), | ||
| 440 | x0, | ||
| 441 | x1, | ||
| 442 | y0, | ||
| 443 | y1, | ||
| 444 | .., | ||
| 445 | ], | ||
| 446 | ) => { | ||
| 447 | let dots = [[*x0, *x1], [*y0, *y1]].map(|dots| f32::from(u16::from_be_bytes(dots))); | ||
| 448 | if dots.iter().all(|dots| *dots > 0.0) { | ||
| 449 | return Some(if *units == 1 { | ||
| 450 | dots | ||
| 451 | } else { | ||
| 452 | dots.map(|per_cm| per_cm * 2.54) | ||
| 453 | }); | ||
| 454 | } | ||
| 455 | } | ||
| 456 | (0xe1, [b'E', b'x', b'i', b'f', 0, 0, tiff @ ..]) => exif = exif_resolution(tiff), | ||
| 457 | // The scan follows. | ||
| 458 | (0xda, _) => break, | ||
| 459 | _ => {} | ||
| 95 | } | 460 | } |
| 461 | segments = &rest[length..]; | ||
| 462 | } | ||
| 463 | exif | ||
| 464 | } | ||
| 465 | |||
| 466 | /// XResolution and YResolution from an Exif block's first directory, in their unit. | ||
| 467 | fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> { | ||
| 468 | let big = match tiff.get(..2)? { | ||
| 469 | b"MM" => true, | ||
| 470 | b"II" => false, | ||
| 471 | _ => return None, | ||
| 96 | }; | 472 | }; |
| 97 | dpi.iter().all(|dpi| *dpi > 0.0).then_some(dpi) | 473 | let u16_at = |at: usize| -> Option<u16> { |
| 474 | let bytes = tiff.get(at..at + 2)?.try_into().ok()?; | ||
| 475 | Some(if big { | ||
| 476 | u16::from_be_bytes(bytes) | ||
| 477 | } else { | ||
| 478 | u16::from_le_bytes(bytes) | ||
| 479 | }) | ||
| 480 | }; | ||
| 481 | let u32_at = |at: usize| -> Option<u32> { | ||
| 482 | let bytes = tiff.get(at..at + 4)?.try_into().ok()?; | ||
| 483 | Some(if big { | ||
| 484 | u32::from_be_bytes(bytes) | ||
| 485 | } else { | ||
| 486 | u32::from_le_bytes(bytes) | ||
| 487 | }) | ||
| 488 | }; | ||
| 489 | let directory = u32_at(4)? as usize; | ||
| 490 | let (mut x, mut y, mut unit) = (None, None, 2); | ||
| 491 | for entry in 0..usize::from(u16_at(directory)?) { | ||
| 492 | let at = directory + 2 + entry * 12; | ||
| 493 | let rational = || { | ||
| 494 | let offset = u32_at(at + 8)? as usize; | ||
| 495 | let [numerator, denominator] = [u32_at(offset)?, u32_at(offset + 4)?]; | ||
| 496 | (denominator > 0).then(|| numerator as f32 / denominator as f32) | ||
| 497 | }; | ||
| 498 | match u16_at(at)? { | ||
| 499 | 0x011a => x = rational(), | ||
| 500 | 0x011b => y = rational(), | ||
| 501 | 0x0128 => unit = u16_at(at + 8)?, | ||
| 502 | _ => {} | ||
| 503 | } | ||
| 504 | } | ||
| 505 | let dots = [x?, y?]; | ||
| 506 | match unit { | ||
| 507 | 2 => Some(dots), | ||
| 508 | 3 => Some(dots.map(|per_cm| per_cm * 2.54)), | ||
| 509 | _ => None, | ||
| 510 | } | ||
| 98 | } | 511 | } |
| 99 | 512 | ||
| 100 | /// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes | 513 | /// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes |
| ... | @@ -148,6 +561,120 @@ mod tests { | ... | @@ -148,6 +561,120 @@ mod tests { |
| 148 | bytes | 561 | bytes |
| 149 | } | 562 | } |
| 150 | 563 | ||
| 564 | /// A JPEG header whose JFIF segment gives no density and whose Exif block gives `dpi` | ||
| 565 | /// in `unit`, little-endian. | ||
| 566 | fn exif(dpi: u32, unit: u16) -> Vec<u8> { | ||
| 567 | let mut tiff = b"II*\0\x08\0\0\0".to_vec(); | ||
| 568 | tiff.extend(3u16.to_le_bytes()); | ||
| 569 | let values = 8 + 2 + 3 * 12 + 4; | ||
| 570 | for (tag, kind, value) in [ | ||
| 571 | (0x011a_u16, 5_u16, values), | ||
| 572 | (0x011b, 5, values + 8), | ||
| 573 | (0x0128, 3, u32::from(unit)), | ||
| 574 | ] { | ||
| 575 | tiff.extend(tag.to_le_bytes()); | ||
| 576 | tiff.extend(kind.to_le_bytes()); | ||
| 577 | tiff.extend(1u32.to_le_bytes()); | ||
| 578 | tiff.extend(value.to_le_bytes()); | ||
| 579 | } | ||
| 580 | tiff.extend(0u32.to_le_bytes()); | ||
| 581 | for _ in 0..2 { | ||
| 582 | tiff.extend(dpi.to_le_bytes()); | ||
| 583 | tiff.extend(1u32.to_le_bytes()); | ||
| 584 | } | ||
| 585 | let mut bytes = jfif(0, 1); | ||
| 586 | bytes.extend([0xff, 0xe1]); | ||
| 587 | bytes.extend(u16::try_from(2 + 6 + tiff.len()).unwrap().to_be_bytes()); | ||
| 588 | bytes.extend(b"Exif\0\0"); | ||
| 589 | bytes.extend(tiff); | ||
| 590 | bytes.extend([0xff, 0xda, 0, 2]); | ||
| 591 | bytes | ||
| 592 | } | ||
| 593 | |||
| 594 | fn shown(pieces: &[Piece]) -> Vec<String> { | ||
| 595 | pieces | ||
| 596 | .iter() | ||
| 597 | .map(|piece| match piece { | ||
| 598 | Piece::Text(text) => text.clone(), | ||
| 599 | Piece::Picture(bytes, size) => format!("[{} {size:?}]", bytes.len()), | ||
| 600 | Piece::Awaited => "[awaited]".to_owned(), | ||
| 601 | }) | ||
| 602 | .collect() | ||
| 603 | } | ||
| 604 | |||
| 605 | /// As OneNote 2010 pasted `<p>before <img> after</p>` (lab, 2026-09-30): the text on | ||
| 606 | /// either side of the picture, the picture at the size its attributes give. | ||
| 607 | #[test] | ||
| 608 | fn a_pasted_page_gives_its_text_and_pictures_in_order() { | ||
| 609 | let html = "Version:0.9\r\n<html><head><style>p{}</style><title>T</title></head>\ | ||
| 610 | <body><!--StartFragment--><p>before <img alt=\"a > b\" \ | ||
| 611 | src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after</p>\ | ||
| 612 | <p>Fish &amp; chips&nbsp;&#x2014;&#8212;</p><ul><li>one<li>two<br>three</ul>\ | ||
| 613 | <img src=\"missing.png\"><IMG SRC='https://example.invalid/a.png' width=40>\ | ||
| 614 | <!--EndFragment--></body></html>"; | ||
| 615 | let mut asked = Vec::new(); | ||
| 616 | let pieces = html_pieces(html, |source, css| { | ||
| 617 | asked.push((source.to_owned(), css)); | ||
| 618 | match source { | ||
| 619 | "missing.png" => None, | ||
| 620 | "https://example.invalid/a.png" => Some(Piece::Awaited), | ||
| 621 | _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])), | ||
| 622 | } | ||
| 623 | }); | ||
| 624 | assert_eq!( | ||
| 625 | asked, | ||
| 626 | [ | ||
| 627 | ( | ||
| 628 | "file:///C:/work/pic.png".to_owned(), | ||
| 629 | [Some(200.0), Some(100.0)] | ||
| 630 | ), | ||
| 631 | ("missing.png".to_owned(), [None, None]), | ||
| 632 | ( | ||
| 633 | "https://example.invalid/a.png".to_owned(), | ||
| 634 | [Some(40.0), None] | ||
| 635 | ), | ||
| 636 | ] | ||
| 637 | ); | ||
| 638 | assert_eq!( | ||
| 639 | shown(&pieces), | ||
| 640 | [ | ||
| 641 | "before", | ||
| 642 | "[3 [150.0, 75.0]]", | ||
| 643 | "after\nFish & chips \u{2014}\u{2014}\none\ntwo\nthree", | ||
| 644 | "[awaited]", | ||
| 645 | ] | ||
| 646 | ); | ||
| 647 | } | ||
| 648 | |||
| 649 | /// Attributes size a picture, the missing one by its proportions. | ||
| 650 | #[test] | ||
| 651 | fn page_pictures_take_the_size_their_attributes_give() { | ||
| 652 | let picture = png_at(None); | ||
| 653 | assert_eq!(sized(&picture, [None, None]), Some([150.0, 75.0])); | ||
| 654 | assert_eq!(sized(&picture, [Some(100.0), None]), Some([75.0, 37.5])); | ||
| 655 | assert_eq!(sized(&picture, [None, Some(50.0)]), Some([75.0, 37.5])); | ||
| 656 | assert_eq!(sized(&picture, [Some(8.0), Some(4.0)]), Some([6.0, 3.0])); | ||
| 657 | assert_eq!(sized(b"not a picture", [None, None]), None); | ||
| 658 | } | ||
| 659 | |||
| 660 | #[test] | ||
| 661 | fn picture_sources_decode() { | ||
| 662 | assert_eq!( | ||
| 663 | load("data:image/png;base64,iVBORw0KGgo=").unwrap(), | ||
| 664 | b"\x89PNG\r\n\x1a\n" | ||
| 665 | ); | ||
| 666 | assert_eq!(load("data:text/plain,a%20b").unwrap(), b"a b"); | ||
| 667 | assert_eq!(load("relative/pic.png"), None); | ||
| 668 | let directory = | ||
| 669 | std::env::temp_dir().join(format!("snowbound-paste-{}", std::process::id())); | ||
| 670 | std::fs::create_dir_all(&directory).unwrap(); | ||
| 671 | let file = directory.join("a picture.png"); | ||
| 672 | std::fs::write(&file, b"bytes").unwrap(); | ||
| 673 | let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20")); | ||
| 674 | assert_eq!(load(&address).unwrap(), b"bytes"); | ||
| 675 | std::fs::remove_dir_all(&directory).unwrap(); | ||
| 676 | } | ||
| 677 | |||
| 151 | fn close(size: [f32; 2], expected: [f32; 2]) -> bool { | 678 | fn close(size: [f32; 2], expected: [f32; 2]) -> bool { |
| 152 | (0..2).all(|axis| (size[axis] - expected[axis]).abs() < 0.01) | 679 | (0..2).all(|axis| (size[axis] - expected[axis]).abs() < 0.01) |
| 153 | } | 680 | } |
| ... | @@ -166,6 +693,9 @@ mod tests { | ... | @@ -166,6 +693,9 @@ mod tests { |
| 166 | assert!(close(size(&jfif(0, 1)), [150.0, 75.0])); | 693 | assert!(close(size(&jfif(0, 1)), [150.0, 75.0])); |
| 167 | assert!(close(size(&jfif(1, 0)), [150.0, 75.0])); | 694 | assert!(close(size(&jfif(1, 0)), [150.0, 75.0])); |
| 168 | assert!(close(size(b"GIF89a"), [150.0, 75.0])); | 695 | assert!(close(size(b"GIF89a"), [150.0, 75.0])); |
| 696 | assert!(close(size(&exif(72, 2)), [200.0, 100.0])); | ||
| 697 | assert!(close(size(&exif(20, 3)), [283.4646, 141.7323])); | ||
| 698 | assert!(close(size(&exif(72, 1)), [150.0, 75.0])); | ||
| 169 | } | 699 | } |
| 170 | 700 | ||
| 171 | #[test] | 701 | #[test] |
crates/snowbound/src/prefetch.rs created+376| ... | @@ -0,0 +1,376 @@ | ||
| 1 | //! Sections and pages made ready before they are shown, and pages kept once left, so | ||
| 2 | //! switching to one shows it at once. A section kept open is its notebook's to hand out | ||
| 3 | //! (`Library::open`); pages are kept as scenes, whose pictures are what takes long to | ||
| 4 | //! draw, and every page shown is read and laid out afresh around them. | ||
| 5 | |||
| 6 | use super::*; | ||
| 7 | use std::collections::VecDeque; | ||
| 8 | |||
| 9 | /// Most recently used first, at most `most` of them. | ||
| 10 | pub struct Recent<K, V> { | ||
| 11 | entries: VecDeque<(K, V)>, | ||
| 12 | most: usize, | ||
| 13 | } | ||
| 14 | |||
| 15 | impl<K: PartialEq, V> Recent<K, V> { | ||
| 16 | pub const fn new(most: usize) -> Self { | ||
| 17 | Self { | ||
| 18 | entries: VecDeque::new(), | ||
| 19 | most, | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | /// Keeps `value` as the most recent, returning what `key` held before and what no | ||
| 24 | /// longer fits. | ||
| 25 | pub fn put(&mut self, key: K, value: V) -> Vec<V> { | ||
| 26 | let mut gone: Vec<V> = self.take(&key).into_iter().collect(); | ||
| 27 | self.entries.push_front((key, value)); | ||
| 28 | gone.extend(self.trim(u64::MAX, |_| 0)); | ||
| 29 | gone | ||
| 30 | } | ||
| 31 | |||
| 32 | pub fn take(&mut self, key: &K) -> Option<V> { | ||
| 33 | let at = self.entries.iter().position(|(held, _)| held == key)?; | ||
| 34 | self.entries.remove(at).map(|(_, value)| value) | ||
| 35 | } | ||
| 36 | |||
| 37 | pub fn contains(&self, key: &K) -> bool { | ||
| 38 | self.entries.iter().any(|(held, _)| held == key) | ||
| 39 | } | ||
| 40 | |||
| 41 | pub fn values_mut(&mut self) -> impl Iterator<Item = &mut V> { | ||
| 42 | self.entries.iter_mut().map(|(_, value)| value) | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Lets go of the least recent past `most`, and past `budget` as `weigh` counts them. | ||
| 46 | pub fn trim(&mut self, budget: u64, weigh: impl Fn(&V) -> u64) -> Vec<V> { | ||
| 47 | let mut kept = 0u64; | ||
| 48 | let fits = self | ||
| 49 | .entries | ||
| 50 | .iter() | ||
| 51 | .take(self.most) | ||
| 52 | .take_while(|(_, value)| { | ||
| 53 | kept = kept.saturating_add(weigh(value)); | ||
| 54 | kept <= budget | ||
| 55 | }) | ||
| 56 | .count(); | ||
| 57 | self.entries.drain(fits..).map(|(_, value)| value).collect() | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// Pages kept: a section's `Library::key` and the page's space. | ||
| 62 | type Key = (String, ExGuid); | ||
| 63 | |||
| 64 | /// How many pages are kept laid out, and the decoded pictures they may hold between them, | ||
| 65 | /// besides the page shown. | ||
| 66 | const SCENES: usize = 32; | ||
| 67 | const SCENE_BYTES: u64 = 128 << 20; | ||
| 68 | |||
| 69 | /// A page kept for showing again: its scene, at the offset `PageView::open` takes, and, while | ||
| 70 | /// its pictures are still drawing, the editor that places them. | ||
| 71 | pub struct Kept { | ||
| 72 | scene: (PageScene, [f32; 2]), | ||
| 73 | editor: Option<CanvasEditor>, | ||
| 74 | } | ||
| 75 | |||
| 76 | pub type Scenes = Arc<Mutex<Recent<Key, Kept>>>; | ||
| 77 | |||
| 78 | /// Work done ahead on its own thread, one piece at a time. | ||
| 79 | enum Job { | ||
| 80 | /// Reads a page of the open section. | ||
| 81 | Page( | ||
| 82 | Key, | ||
| 83 | Box<dyn FnOnce() -> Result<Page, Box<dyn Error>> + Send>, | ||
| 84 | ), | ||
| 85 | /// Opens a section for its notebook to keep, then reads the page it would show. | ||
| 86 | Section { | ||
| 87 | library: Arc<Library>, | ||
| 88 | path: String, | ||
| 89 | last: Option<ExGuid>, | ||
| 90 | notify: Box<dyn Fn() + Send>, | ||
| 91 | }, | ||
| 92 | } | ||
| 93 | |||
| 94 | pub struct Prefetch { | ||
| 95 | pub scenes: Scenes, | ||
| 96 | jobs: mpsc::Sender<Job>, | ||
| 97 | /// What was asked for lately, by section key and page (none for a section), so a hover | ||
| 98 | /// held over many frames asks once. | ||
| 99 | asked: Recent<(String, Option<ExGuid>), ()>, | ||
| 100 | } | ||
| 101 | |||
| 102 | impl Prefetch { | ||
| 103 | pub fn new(layouts: Arc<Mutex<TextEngine>>, redraw: std::task::Waker) -> Self { | ||
| 104 | let scenes = Scenes::new(Mutex::new(Recent::new(SCENES))); | ||
| 105 | let (jobs, queue) = mpsc::channel(); | ||
| 106 | let kept = Arc::clone(&scenes); | ||
| 107 | std::thread::Builder::new() | ||
| 108 | .name("prefetch".into()) | ||
| 109 | .spawn(move || { | ||
| 110 | for job in queue { | ||
| 111 | // What fails ahead fails again, and says so, once it is asked for. | ||
| 112 | if prepare(job, &layouts, &kept).is_ok() { | ||
| 113 | redraw.wake_by_ref(); | ||
| 114 | } | ||
| 115 | } | ||
| 116 | }) | ||
| 117 | .expect("The prefetch thread starts"); | ||
| 118 | Self { | ||
| 119 | scenes, | ||
| 120 | jobs, | ||
| 121 | asked: Recent::new(32), | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | /// The scene kept for page `space` of the section `section` names, if any. | ||
| 126 | pub fn take(scenes: &Scenes, section: &str, space: ExGuid) -> Option<PageScene> { | ||
| 127 | let kept = scenes.lock().ok()?.take(&(section.to_owned(), space))?; | ||
| 128 | Some(kept.scene.0) | ||
| 129 | } | ||
| 130 | |||
| 131 | /// Keeps the scene of a page left, its pictures drawn. | ||
| 132 | pub fn keep(&mut self, section: String, space: ExGuid, scene: (PageScene, [f32; 2])) { | ||
| 133 | self.asked.take(&(section.clone(), Some(space))); | ||
| 134 | if let Ok(mut scenes) = self.scenes.lock() { | ||
| 135 | scenes.put( | ||
| 136 | (section, space), | ||
| 137 | Kept { | ||
| 138 | scene, | ||
| 139 | editor: None, | ||
| 140 | }, | ||
| 141 | ); | ||
| 142 | scenes.trim(SCENE_BYTES, weigh); | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | /// Whether `target` is new since it was last asked for, noting it. | ||
| 147 | fn ask(&mut self, target: (String, Option<ExGuid>)) -> bool { | ||
| 148 | let new = !self.asked.contains(&target); | ||
| 149 | self.asked.put(target, ()); | ||
| 150 | new | ||
| 151 | } | ||
| 152 | |||
| 153 | /// Draws the pictures kept pages show first, as `view` would open them, letting go of | ||
| 154 | /// each editor once they are drawn. | ||
| 155 | pub fn draw(&self, view: &PageView, paper: canvas::gpu::Paper, waker: &std::task::Waker) { | ||
| 156 | let Ok(mut scenes) = self.scenes.lock() else { | ||
| 157 | return; | ||
| 158 | }; | ||
| 159 | for kept in scenes.values_mut() { | ||
| 160 | if let Some(editor) = &kept.editor | ||
| 161 | && view.prepare( | ||
| 162 | &mut kept.scene, | ||
| 163 | editor, | ||
| 164 | paper.colored(editor.page_color()), | ||
| 165 | waker, | ||
| 166 | ) | ||
| 167 | { | ||
| 168 | kept.editor = None; | ||
| 169 | } | ||
| 170 | } | ||
| 171 | scenes.trim(SCENE_BYTES, weigh); | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | fn weigh(kept: &Kept) -> u64 { | ||
| 176 | kept.scene.0.raster_bytes() | ||
| 177 | } | ||
| 178 | |||
| 179 | fn prepare(job: Job, layouts: &Mutex<TextEngine>, scenes: &Scenes) -> Result<(), Box<dyn Error>> { | ||
| 180 | let (key, page) = match job { | ||
| 181 | Job::Page(key, read) => { | ||
| 182 | if scenes.lock().map_err(|_| "Prefetch failed")?.contains(&key) { | ||
| 183 | return Ok(()); | ||
| 184 | } | ||
| 185 | let page = read()?; | ||
| 186 | (key, page) | ||
| 187 | } | ||
| 188 | Job::Section { | ||
| 189 | library, | ||
| 190 | path, | ||
| 191 | last, | ||
| 192 | notify, | ||
| 193 | } => { | ||
| 194 | let replica = library.prefetch(&path, notify)?; | ||
| 195 | let pages = replica.pages()?; | ||
| 196 | let space = last | ||
| 197 | .filter(|space| pages.iter().any(|(listed, ..)| listed == space)) | ||
| 198 | .or(pages.first().map(|(space, ..)| *space)) | ||
| 199 | .ok_or("The section has no pages")?; | ||
| 200 | let key = (library.key(&path), space); | ||
| 201 | if scenes.lock().map_err(|_| "Prefetch failed")?.contains(&key) { | ||
| 202 | return Ok(()); | ||
| 203 | } | ||
| 204 | (key, replica.page(space)?) | ||
| 205 | } | ||
| 206 | }; | ||
| 207 | let (scene, editor) = { | ||
| 208 | let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; | ||
| 209 | PageScene::from_page(page, &mut engine)? | ||
| 210 | }; | ||
| 211 | let mut scenes = scenes.lock().map_err(|_| "Prefetch failed")?; | ||
| 212 | scenes.put( | ||
| 213 | key, | ||
| 214 | Kept { | ||
| 215 | scene: (scene, [0.0; 2]), | ||
| 216 | editor: Some(editor), | ||
| 217 | }, | ||
| 218 | ); | ||
| 219 | scenes.trim(SCENE_BYTES, weigh); | ||
| 220 | Ok(()) | ||
| 221 | } | ||
| 222 | |||
| 223 | impl Session { | ||
| 224 | /// The section's `Library::key`. | ||
| 225 | pub fn key(&self) -> String { | ||
| 226 | self.library.key(&self.tabs[self.tab].path) | ||
| 227 | } | ||
| 228 | } | ||
| 229 | |||
| 230 | /// How long a page may take to open before its area shows that it is opening, so a page | ||
| 231 | /// ready in a frame or two never flashes the placeholder. | ||
| 232 | const PATIENCE: std::time::Duration = std::time::Duration::from_millis(80); | ||
| 233 | |||
| 234 | /// A page's outline while it opens, in points from the page area's corner: its title, its | ||
| 235 | /// date and some lines of text, breathing in `paper`'s ink. | ||
| 236 | pub fn skeleton(paper: canvas::gpu::Paper, since: Instant) -> Vec<draw::Primitive<'static>> { | ||
| 237 | let breath = 0.5 - 0.5 * (since.elapsed().as_secs_f32() * std::f32::consts::TAU / 1.6).cos(); | ||
| 238 | let [red, green, blue, _] = paper.ink; | ||
| 239 | let color = [red, green, blue, 0.06 + 0.06 * breath]; | ||
| 240 | let bar = |x: f32, y: f32, width: f32, height: f32| draw::Primitive::RoundedRect { | ||
| 241 | rect: [x, y, x + width, y + height], | ||
| 242 | radius: [height / 3.0; 2], | ||
| 243 | stroke: None, | ||
| 244 | color, | ||
| 245 | }; | ||
| 246 | let mut bars = vec![bar(48.0, 40.0, 280.0, 24.0), bar(48.0, 74.0, 150.0, 10.0)]; | ||
| 247 | let lines = [420.0, 380.0, 440.0, 300.0, 0.0, 400.0, 360.0, 220.0]; | ||
| 248 | for (line, width) in lines.into_iter().enumerate() { | ||
| 249 | if width > 0.0 { | ||
| 250 | bars.push(bar(64.0, 120.0 + 24.0 * line as f32, width, 11.0)); | ||
| 251 | } | ||
| 252 | } | ||
| 253 | bars | ||
| 254 | } | ||
| 255 | |||
| 256 | impl State { | ||
| 257 | /// The section tab shown open: one being opened, from the moment it is asked for. | ||
| 258 | pub fn open_tab(&self) -> Option<usize> { | ||
| 259 | let session = self.session.as_ref()?; | ||
| 260 | let opening = self.switching.and_then(|(tab, _)| tab); | ||
| 261 | Some( | ||
| 262 | opening | ||
| 263 | .filter(|tab| *tab < session.tabs.len()) | ||
| 264 | .unwrap_or(session.tab), | ||
| 265 | ) | ||
| 266 | } | ||
| 267 | |||
| 268 | /// Since when the page area shows that a page is opening, once it has taken a while. | ||
| 269 | pub fn loading(&self) -> Option<Instant> { | ||
| 270 | let (_, since) = self.switching?; | ||
| 271 | (since.elapsed() >= PATIENCE).then_some(since) | ||
| 272 | } | ||
| 273 | |||
| 274 | /// Leaves the page shown as it is, dropping whatever was opening in its place. | ||
| 275 | pub fn stop_loading(&mut self) { | ||
| 276 | self.loading += 1; | ||
| 277 | self.opening = None; | ||
| 278 | self.switching = None; | ||
| 279 | } | ||
| 280 | |||
| 281 | /// Readies the pages beside the open one and the sections beside the open section. | ||
| 282 | pub fn prefetch_around(&mut self) { | ||
| 283 | let Some(session) = &self.session else { | ||
| 284 | return; | ||
| 285 | }; | ||
| 286 | let at = session | ||
| 287 | .pages | ||
| 288 | .iter() | ||
| 289 | .position(|(space, ..)| *space == session.space); | ||
| 290 | let pages: Vec<ExGuid> = at | ||
| 291 | .into_iter() | ||
| 292 | .flat_map(|at| [at + 1, at.wrapping_sub(1)]) | ||
| 293 | .filter_map(|at| session.pages.get(at).map(|(space, ..)| *space)) | ||
| 294 | .collect(); | ||
| 295 | let tabs: Vec<usize> = [session.tab + 1, session.tab.wrapping_sub(1)] | ||
| 296 | .into_iter() | ||
| 297 | .filter(|tab| *tab < session.tabs.len()) | ||
| 298 | .collect(); | ||
| 299 | for space in pages { | ||
| 300 | self.prefetch_page(space); | ||
| 301 | } | ||
| 302 | for tab in tabs { | ||
| 303 | self.prefetch_section(tab); | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | /// Readies page `space` of the open section. | ||
| 308 | pub fn prefetch_page(&mut self, space: ExGuid) { | ||
| 309 | let Some(session) = &self.session else { | ||
| 310 | return; | ||
| 311 | }; | ||
| 312 | let key = session.key(); | ||
| 313 | if space == session.space || !self.prefetch.ask((key.clone(), Some(space))) { | ||
| 314 | return; | ||
| 315 | } | ||
| 316 | let read = session.reader(space); | ||
| 317 | let _ = self | ||
| 318 | .prefetch | ||
| 319 | .jobs | ||
| 320 | .send(Job::Page((key, space), Box::new(read))); | ||
| 321 | } | ||
| 322 | |||
| 323 | /// Readies section tab `tab` of the open section's folder. | ||
| 324 | pub fn prefetch_section(&mut self, tab: usize) { | ||
| 325 | let Some(session) = &self.session else { | ||
| 326 | return; | ||
| 327 | }; | ||
| 328 | let path = session.tabs[tab].path.clone(); | ||
| 329 | let key = session.library.key(&path); | ||
| 330 | if tab == session.tab || !self.prefetch.ask((key.clone(), None)) { | ||
| 331 | return; | ||
| 332 | } | ||
| 333 | let _ = self.prefetch.jobs.send(Job::Section { | ||
| 334 | library: Arc::clone(&session.library), | ||
| 335 | path, | ||
| 336 | last: self.last_pages.get(&key).copied(), | ||
| 337 | notify: Box::new(notify(self.proxy.clone())), | ||
| 338 | }); | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | #[cfg(test)] | ||
| 343 | mod tests { | ||
| 344 | use super::*; | ||
| 345 | |||
| 346 | #[test] | ||
| 347 | fn the_least_recent_go_first_past_the_count() { | ||
| 348 | let mut recent = Recent::new(2); | ||
| 349 | assert!(recent.put("a", 1).is_empty()); | ||
| 350 | assert!(recent.put("b", 2).is_empty()); | ||
| 351 | // Putting again makes it the most recent and gives back what it held. | ||
| 352 | assert_eq!(recent.put("a", 3), vec![1]); | ||
| 353 | assert_eq!(recent.put("c", 4), vec![2]); | ||
| 354 | assert!(!recent.contains(&"b")); | ||
| 355 | assert_eq!(recent.take(&"a"), Some(3)); | ||
| 356 | assert_eq!(recent.take(&"a"), None); | ||
| 357 | assert_eq!( | ||
| 358 | recent.values_mut().map(|value| *value).collect::<Vec<_>>(), | ||
| 359 | [4] | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | |||
| 363 | #[test] | ||
| 364 | fn the_least_recent_go_first_past_the_budget() { | ||
| 365 | let mut recent = Recent::new(8); | ||
| 366 | for (key, bytes) in [("a", 40), ("b", 30), ("c", 20), ("d", 10)] { | ||
| 367 | recent.put(key, bytes); | ||
| 368 | } | ||
| 369 | // Most recent first: d, c, b fit in 60; a does not. | ||
| 370 | assert_eq!(recent.trim(60, |bytes| *bytes), vec![40]); | ||
| 371 | assert_eq!(recent.trim(30, |bytes| *bytes), vec![30]); | ||
| 372 | assert!(recent.contains(&"c") && recent.contains(&"d")); | ||
| 373 | // One entry over the budget on its own goes too. | ||
| 374 | assert_eq!(recent.trim(5, |bytes| *bytes), vec![10, 20]); | ||
| 375 | } | ||
| 376 | } | ||
crates/snowbound/src/print.rs+389-18| ... | @@ -1,31 +1,319 @@ | ... | @@ -1,31 +1,319 @@ |
| 1 | //! File, Print… and Export as PDF…: the open page, or the whole section, laid on paper as | 1 | //! File, Print… and Export as PDF…: OneNote 2010's Print Preview and Settings, less the |
| 2 | //! OneNote 2010 prints it (`canvas::print`), then handed to the system's printing or saved. | 2 | //! preview, then the chosen pages laid on paper as OneNote prints them (`canvas::print`), |
| 3 | //! handed to the system's printing or saved. | ||
| 3 | 4 | ||
| 4 | use crate::{State, UserEvent, platform, printer}; | 5 | use crate::{State, UserEvent, library::Library, platform, printer}; |
| 5 | use onestore::page::Page; | 6 | use accesskit::Role; |
| 7 | use canvas::print::Footer; | ||
| 8 | use notebook::discover::Folder; | ||
| 9 | use onestore::{ExGuid, page::Page}; | ||
| 6 | use std::{error::Error, path::PathBuf, sync::Arc}; | 10 | use std::{error::Error, path::PathBuf, sync::Arc}; |
| 11 | use ui::{Anchor, Axis, Id, Spec, Ui, children, fill, popup::Item, px}; | ||
| 12 | use winit::keyboard::NamedKey; | ||
| 7 | 13 | ||
| 8 | #[derive(Clone, Copy, PartialEq, Eq)] | 14 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 9 | pub enum Scope { | 15 | pub enum Scope { |
| 10 | Page, | 16 | Page, |
| 17 | /// The page with the subpages of its group. | ||
| 18 | PageGroup, | ||
| 11 | Section, | 19 | Section, |
| 20 | /// Every section of the notebook, which only Export as PDF offers, as OneNote's Save As. | ||
| 21 | Notebook, | ||
| 22 | } | ||
| 23 | |||
| 24 | const SCOPES: [(Scope, &str); 4] = [ | ||
| 25 | (Scope::Page, "Current Page"), | ||
| 26 | (Scope::PageGroup, "Page Group"), | ||
| 27 | (Scope::Section, "Current Section"), | ||
| 28 | (Scope::Notebook, "Current Notebook"), | ||
| 29 | ]; | ||
| 30 | |||
| 31 | /// Print Preview's paper sizes, upright, in points. | ||
| 32 | const PAPERS: [(&str, [f32; 2]); 8] = [ | ||
| 33 | ("Letter", canvas::print::LETTER), | ||
| 34 | ("Tabloid", [792.0, 1224.0]), | ||
| 35 | ("Legal", [612.0, 1008.0]), | ||
| 36 | ("Executive", [522.0, 756.0]), | ||
| 37 | ("A3", [841.89, 1190.55]), | ||
| 38 | ("A4", canvas::print::A4), | ||
| 39 | ("B4 (JIS)", [728.5, 1031.81]), | ||
| 40 | ("B5 (JIS)", [515.91, 728.5]), | ||
| 41 | ]; | ||
| 42 | |||
| 43 | const FOOTERS: [Footer; 4] = [ | ||
| 44 | Footer::SectionAndPage, | ||
| 45 | Footer::Page, | ||
| 46 | Footer::Section, | ||
| 47 | Footer::None, | ||
| 48 | ]; | ||
| 49 | |||
| 50 | /// macOS's print panel chooses the paper, and the sheets are fitted to it. | ||
| 51 | const PRINT_PAPER: bool = !cfg!(target_os = "macos"); | ||
| 52 | |||
| 53 | /// What to print and how, as Print Preview and Settings offers it. | ||
| 54 | #[derive(Clone, Copy)] | ||
| 55 | pub struct Setup { | ||
| 56 | scope: Scope, | ||
| 57 | /// Upright width and height. | ||
| 58 | paper: [f32; 2], | ||
| 59 | landscape: bool, | ||
| 60 | fit_width: bool, | ||
| 61 | footer: Footer, | ||
| 62 | } | ||
| 63 | |||
| 64 | /// The choices last made, and the dialog's while it is open. | ||
| 65 | #[derive(Default)] | ||
| 66 | pub struct Printing { | ||
| 67 | last: Option<Setup>, | ||
| 68 | dialog: Option<Dialog>, | ||
| 69 | } | ||
| 70 | |||
| 71 | struct Dialog { | ||
| 72 | export: bool, | ||
| 73 | setup: Setup, | ||
| 74 | } | ||
| 75 | |||
| 76 | impl Printing { | ||
| 77 | pub fn open(&self) -> bool { | ||
| 78 | self.dialog.is_some() | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | fn id() -> Id { | ||
| 83 | Id::ROOT.child("print") | ||
| 84 | } | ||
| 85 | |||
| 86 | /// A combo's value and the menu listing `names`, choosing among them. | ||
| 87 | fn choose(ui: &mut Ui, name: &str, names: &[&str], current: usize) -> Option<usize> { | ||
| 88 | let menu = id().child(name); | ||
| 89 | let combo = ui.id(name); | ||
| 90 | let shown = names.get(current).copied().unwrap_or("Other"); | ||
| 91 | ui::shell::combo(ui, name, name, shown, 180.0, menu, true); | ||
| 92 | let items: Vec<_> = names | ||
| 93 | .iter() | ||
| 94 | .enumerate() | ||
| 95 | .map(|(at, text)| Item { | ||
| 96 | text, | ||
| 97 | checked: Some(at == current), | ||
| 98 | current: at == current, | ||
| 99 | ..Item::default() | ||
| 100 | }) | ||
| 101 | .collect(); | ||
| 102 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | ||
| 103 | ui::popup::menu(ui, menu, anchor, &items, None) | ||
| 104 | } | ||
| 105 | |||
| 106 | /// A row of `label` and the controls `build` adds after it. | ||
| 107 | fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) { | ||
| 108 | let row = ui.theme.font_size * 2.0; | ||
| 109 | ui.open( | ||
| 110 | label, | ||
| 111 | Spec { | ||
| 112 | size: [fill(), px(row)], | ||
| 113 | gap: 8.0, | ||
| 114 | ..Spec::default() | ||
| 115 | }, | ||
| 116 | ); | ||
| 117 | ui.leaf( | ||
| 118 | "label", | ||
| 119 | Spec { | ||
| 120 | size: [px(110.0), px(row)], | ||
| 121 | text: Some(label), | ||
| 122 | ..Spec::default() | ||
| 123 | }, | ||
| 124 | ); | ||
| 125 | build(ui); | ||
| 126 | ui.close(); | ||
| 12 | } | 127 | } |
| 13 | 128 | ||
| 14 | impl State { | 129 | impl State { |
| 15 | /// Prints `scope` through the system's print dialog, or with `export` saves it as a PDF | 130 | /// Opens Print Preview and Settings for Print…, or with `export` for Export as PDF…. |
| 16 | /// where the user chooses. Pages are laid out on a thread of their own. | 131 | pub(crate) fn open_print(&mut self, export: bool) { |
| 17 | pub(crate) fn print(&mut self, scope: Scope, export: bool) -> Result<(), Box<dyn Error>> { | 132 | let mut setup = self.printing.last.unwrap_or_else(|| Setup { |
| 133 | scope: Scope::Page, | ||
| 134 | paper: printer::paper(), | ||
| 135 | landscape: false, | ||
| 136 | fit_width: true, | ||
| 137 | footer: Footer::SectionAndPage, | ||
| 138 | }); | ||
| 139 | if !export && setup.scope == Scope::Notebook { | ||
| 140 | setup.scope = Scope::Page; | ||
| 141 | } | ||
| 142 | if !export && !PRINT_PAPER { | ||
| 143 | setup.paper = printer::paper(); | ||
| 144 | } | ||
| 145 | self.printing.dialog = Some(Dialog { export, setup }); | ||
| 146 | self.ui.open_popup(id()); | ||
| 147 | } | ||
| 148 | |||
| 149 | /// Builds the dialog while it is open; Print or Export goes on with its choices, which | ||
| 150 | /// the next opening starts from. | ||
| 151 | pub(crate) fn print_dialog(&mut self) { | ||
| 152 | let Some(dialog) = &mut self.printing.dialog else { | ||
| 153 | return; | ||
| 154 | }; | ||
| 155 | let ui = &mut self.ui; | ||
| 156 | if !ui.popup_open(id()) { | ||
| 157 | self.printing.dialog = None; | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | let section = self.session.as_ref().map_or(String::new(), |session| { | ||
| 161 | session.tabs[session.tab].name.clone() | ||
| 162 | }); | ||
| 163 | let theme = ui.theme.clone(); | ||
| 164 | let entered = ui::popup::navigation(ui, &[], &[NamedKey::Enter]).contains(&NamedKey::Enter); | ||
| 165 | let title = if dialog.export { | ||
| 166 | "Export as PDF" | ||
| 167 | } else { | ||
| 168 | "Print" | ||
| 169 | }; | ||
| 170 | ui.open_as( | ||
| 171 | id(), | ||
| 172 | Spec { | ||
| 173 | axis: Axis::Y, | ||
| 174 | size: [px(340.0), children()], | ||
| 175 | fill: Some(theme.popup), | ||
| 176 | border: Some(theme.chip), | ||
| 177 | shadow: Some(theme.shadow), | ||
| 178 | radius: 8.0, | ||
| 179 | pad: [16.0, 12.0], | ||
| 180 | gap: 6.0, | ||
| 181 | anchor: Some(Anchor::Dialog), | ||
| 182 | role: Some(Role::Dialog), | ||
| 183 | ..Spec::default() | ||
| 184 | }, | ||
| 185 | ); | ||
| 186 | if let Some(node) = ui.access(id()) { | ||
| 187 | node.set_label(title); | ||
| 188 | } | ||
| 189 | ui.leaf( | ||
| 190 | "title", | ||
| 191 | Spec { | ||
| 192 | size: [fill(), px(theme.font_size * 2.0)], | ||
| 193 | text: Some(title), | ||
| 194 | bold: true, | ||
| 195 | role: Some(Role::Heading), | ||
| 196 | ..Spec::default() | ||
| 197 | }, | ||
| 198 | ); | ||
| 199 | let setup = &mut dialog.setup; | ||
| 200 | let scopes = if dialog.export { 4 } else { 3 }; | ||
| 201 | let names: Vec<&str> = SCOPES[..scopes].iter().map(|(_, name)| *name).collect(); | ||
| 202 | field(ui, "Print range:", |ui| { | ||
| 203 | let current = SCOPES.iter().position(|(scope, _)| *scope == setup.scope); | ||
| 204 | if let Some(at) = choose(ui, "Print range", &names, current.unwrap_or(0)) { | ||
| 205 | setup.scope = SCOPES[at].0; | ||
| 206 | } | ||
| 207 | }); | ||
| 208 | if dialog.export || PRINT_PAPER { | ||
| 209 | let names = PAPERS.map(|(name, _)| name); | ||
| 210 | field(ui, "Paper size:", |ui| { | ||
| 211 | let current = PAPERS.iter().position(|(_, size)| { | ||
| 212 | size.iter() | ||
| 213 | .zip(setup.paper) | ||
| 214 | .all(|(a, b)| (a - b).abs() < 1.0) | ||
| 215 | }); | ||
| 216 | if let Some(at) = choose(ui, "Paper size", &names, current.unwrap_or(usize::MAX)) { | ||
| 217 | setup.paper = PAPERS[at].1; | ||
| 218 | } | ||
| 219 | }); | ||
| 220 | } | ||
| 221 | field(ui, "Orientation:", |ui| { | ||
| 222 | let names = ["Portrait", "Landscape"]; | ||
| 223 | if let Some(at) = choose(ui, "Orientation", &names, usize::from(setup.landscape)) { | ||
| 224 | setup.landscape = at == 1; | ||
| 225 | } | ||
| 226 | }); | ||
| 227 | if ui::check_box( | ||
| 228 | ui, | ||
| 229 | "fit-width", | ||
| 230 | "Scale content to paper width", | ||
| 231 | setup.fit_width, | ||
| 232 | ) | ||
| 233 | .clicked | ||
| 234 | { | ||
| 235 | setup.fit_width = !setup.fit_width; | ||
| 236 | } | ||
| 237 | let footers = [ | ||
| 238 | format!("{section} Page (number)"), | ||
| 239 | "Page (number)".to_owned(), | ||
| 240 | section.clone(), | ||
| 241 | "(none)".to_owned(), | ||
| 242 | ]; | ||
| 243 | let names: Vec<&str> = footers.iter().map(String::as_str).collect(); | ||
| 244 | field(ui, "Footer:", |ui| { | ||
| 245 | let current = FOOTERS.iter().position(|footer| *footer == setup.footer); | ||
| 246 | if let Some(at) = choose(ui, "Footer", &names, current.unwrap_or(0)) { | ||
| 247 | setup.footer = FOOTERS[at]; | ||
| 248 | } | ||
| 249 | }); | ||
| 250 | ui.open( | ||
| 251 | "buttons", | ||
| 252 | Spec { | ||
| 253 | size: [fill(), children()], | ||
| 254 | pad: [0.0, 8.0], | ||
| 255 | gap: 8.0, | ||
| 256 | ..Spec::default() | ||
| 257 | }, | ||
| 258 | ); | ||
| 259 | ui.leaf( | ||
| 260 | "space", | ||
| 261 | Spec { | ||
| 262 | size: [fill(), px(1.0)], | ||
| 263 | ..Spec::default() | ||
| 264 | }, | ||
| 265 | ); | ||
| 266 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | ||
| 267 | let action = if dialog.export { | ||
| 268 | "Export…" | ||
| 269 | } else { | ||
| 270 | "Print…" | ||
| 271 | }; | ||
| 272 | let go = ui::button(ui, "go", action).clicked || entered; | ||
| 273 | ui.close(); | ||
| 274 | ui.close(); | ||
| 275 | if !go && !cancel { | ||
| 276 | return; | ||
| 277 | } | ||
| 278 | let Dialog { export, setup } = self.printing.dialog.take().expect("The dialog is open"); | ||
| 279 | self.ui.close_popup(id()); | ||
| 280 | if go { | ||
| 281 | self.printing.last = Some(setup); | ||
| 282 | if let Err(error) = self.print(setup, export) { | ||
| 283 | platform::alert("Couldn't print", &error.to_string()); | ||
| 284 | } | ||
| 285 | } | ||
| 286 | } | ||
| 287 | |||
| 288 | /// Prints `setup`'s pages through the system's print dialog, or with `export` saves | ||
| 289 | /// them as a PDF where the user chooses. Pages are read and laid out on a thread of | ||
| 290 | /// their own. | ||
| 291 | fn print(&mut self, setup: Setup, export: bool) -> Result<(), Box<dyn Error>> { | ||
| 18 | let session = self.session.as_ref().ok_or("No section is open")?; | 292 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 19 | let section = session.tabs[session.tab].name.clone(); | 293 | let tab = &session.tabs[session.tab]; |
| 20 | // The open page as the editor holds it; the section's others as stored. | 294 | // The open page as the editor holds it; the others as stored. |
| 21 | let open = (session.space, self.view.editor.page()?); | 295 | let open = (session.space, self.view.editor.page()?); |
| 22 | let spaces: Vec<_> = match scope { | 296 | let at = session |
| 23 | Scope::Page => vec![session.space], | 297 | .pages |
| 24 | Scope::Section => session.pages.iter().map(|(space, ..)| *space).collect(), | 298 | .iter() |
| 299 | .position(|(space, ..)| *space == session.space); | ||
| 300 | let spaces: Vec<ExGuid> = match (setup.scope, at) { | ||
| 301 | (Scope::PageGroup, Some(at)) => { | ||
| 302 | let levels: Vec<u32> = session.pages.iter().map(|(.., level)| *level).collect(); | ||
| 303 | session.pages[group(&levels, at)] | ||
| 304 | .iter() | ||
| 305 | .map(|(space, ..)| *space) | ||
| 306 | .collect() | ||
| 307 | } | ||
| 308 | (Scope::Section | Scope::Notebook, _) => { | ||
| 309 | session.pages.iter().map(|(space, ..)| *space).collect() | ||
| 310 | } | ||
| 311 | _ => vec![session.space], | ||
| 25 | }; | 312 | }; |
| 26 | let title = match scope { | 313 | let title = match setup.scope { |
| 27 | Scope::Page => open.1.title.clone(), | 314 | Scope::Page | Scope::PageGroup => open.1.title.clone(), |
| 28 | Scope::Section => section.clone(), | 315 | Scope::Section => tab.name.clone(), |
| 316 | Scope::Notebook => session.library.name.clone(), | ||
| 29 | }; | 317 | }; |
| 30 | let destination = if export { | 318 | let destination = if export { |
| 31 | let name = format!("{}.pdf", file_name(&title)); | 319 | let name = format!("{}.pdf", file_name(&title)); |
| ... | @@ -39,22 +327,62 @@ impl State { | ... | @@ -39,22 +327,62 @@ impl State { |
| 39 | } else { | 327 | } else { |
| 40 | None | 328 | None |
| 41 | }; | 329 | }; |
| 330 | // Other sections are opened on the thread, in the notebook's order. | ||
| 331 | let others = match setup.scope { | ||
| 332 | Scope::Notebook => notebook_tabs(&session.library), | ||
| 333 | _ => Vec::new(), | ||
| 334 | }; | ||
| 335 | let (open_tab, library) = ( | ||
| 336 | (tab.path.clone(), tab.name.clone()), | ||
| 337 | Arc::clone(&session.library), | ||
| 338 | ); | ||
| 42 | let replica = Arc::clone(session.section.replica()); | 339 | let replica = Arc::clone(session.section.replica()); |
| 43 | let layouts = Arc::clone(&self.layouts); | 340 | let layouts = Arc::clone(&self.layouts); |
| 44 | let proxy = self.proxy.clone(); | 341 | let proxy = self.proxy.clone(); |
| 45 | let paper = printer::paper(); | 342 | let paper = if setup.landscape { |
| 343 | [setup.paper[1], setup.paper[0]] | ||
| 344 | } else { | ||
| 345 | setup.paper | ||
| 346 | }; | ||
| 347 | let layout = canvas::print::Setup { | ||
| 348 | paper, | ||
| 349 | fit_width: setup.fit_width, | ||
| 350 | footer: setup.footer, | ||
| 351 | }; | ||
| 46 | std::thread::spawn(move || { | 352 | std::thread::spawn(move || { |
| 47 | let made = (|| -> Result<Vec<u8>, Box<dyn Error>> { | 353 | let made = (|| -> Result<Vec<u8>, Box<dyn Error>> { |
| 48 | let mut open = Some(open); | 354 | let mut open = Some(open); |
| 49 | let pages = spaces | 355 | let this = spaces |
| 50 | .into_iter() | 356 | .into_iter() |
| 51 | .map(|space| match open.take_if(|(shown, _)| *shown == space) { | 357 | .map(|space| match open.take_if(|(shown, _)| *shown == space) { |
| 52 | Some((_, page)) => Ok(page), | 358 | Some((_, page)) => Ok(page), |
| 53 | None => replica.page(space).map_err(Into::into), | 359 | None => replica.page(space).map_err(Into::into), |
| 54 | }) | 360 | }) |
| 55 | .collect::<Result<Vec<Page>, Box<dyn Error>>>()?; | 361 | .collect::<Result<Vec<Page>, Box<dyn Error>>>()?; |
| 362 | let mut this = Some((open_tab.1.clone(), this)); | ||
| 363 | let sections = if others.is_empty() { | ||
| 364 | this.into_iter().collect() | ||
| 365 | } else { | ||
| 366 | others | ||
| 367 | .into_iter() | ||
| 368 | .map(|(path, name)| { | ||
| 369 | if path == open_tab.0 | ||
| 370 | && let Some(open) = this.take() | ||
| 371 | { | ||
| 372 | return Ok(open); | ||
| 373 | } | ||
| 374 | let section = library.open(&path, || {})?; | ||
| 375 | let pages = section | ||
| 376 | .pages()? | ||
| 377 | .into_iter() | ||
| 378 | .map(|(space, ..)| section.page(space)) | ||
| 379 | .collect::<Result<Vec<_>, _>>()?; | ||
| 380 | Ok((name, pages)) | ||
| 381 | }) | ||
| 382 | .collect::<Result<Vec<_>, Box<dyn Error>>>()? | ||
| 383 | }; | ||
| 56 | let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; | 384 | let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; |
| 57 | Ok(canvas::print::pdf(pages, &mut engine, paper, &section)?) | 385 | Ok(canvas::print::pdf(sections, &mut engine, &layout)?) |
| 58 | })(); | 386 | })(); |
| 59 | let done = made.and_then(|pdf| match &destination { | 387 | let done = made.and_then(|pdf| match &destination { |
| 60 | Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into), | 388 | Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into), |
| ... | @@ -79,6 +407,40 @@ impl State { | ... | @@ -79,6 +407,40 @@ impl State { |
| 79 | } | 407 | } |
| 80 | } | 408 | } |
| 81 | 409 | ||
| 410 | /// The pages of the group the page at `at` is in, by the pages' outline `levels`: the top | ||
| 411 | /// page before it, or itself, and the subpages after that. | ||
| 412 | fn group(levels: &[u32], at: usize) -> std::ops::Range<usize> { | ||
| 413 | let top = |level: &u32| *level <= 1; | ||
| 414 | let start = levels[..=at].iter().rposition(top).unwrap_or(at); | ||
| 415 | let end = levels[start + 1..] | ||
| 416 | .iter() | ||
| 417 | .position(top) | ||
| 418 | .map_or(levels.len(), |end| start + 1 + end); | ||
| 419 | start..end | ||
| 420 | } | ||
| 421 | |||
| 422 | /// Every readable section of `library`, catalog path and name, a folder's sections before | ||
| 423 | /// its groups', as OneNote orders them. | ||
| 424 | fn notebook_tabs(library: &Library) -> Vec<(String, String)> { | ||
| 425 | fn walk(library: &Library, folder: &Folder, tabs: &mut Vec<(String, String)>) { | ||
| 426 | tabs.extend( | ||
| 427 | library | ||
| 428 | .tabs(&folder.path) | ||
| 429 | .into_iter() | ||
| 430 | .map(|tab| (tab.path, tab.name)), | ||
| 431 | ); | ||
| 432 | for group in &folder.groups { | ||
| 433 | walk(library, group, tabs); | ||
| 434 | } | ||
| 435 | } | ||
| 436 | let mut tabs = Vec::new(); | ||
| 437 | match library.catalog() { | ||
| 438 | Some(catalog) => walk(library, catalog, &mut tabs), | ||
| 439 | None => tabs.extend(library.tabs("").into_iter().map(|tab| (tab.path, tab.name))), | ||
| 440 | } | ||
| 441 | tabs | ||
| 442 | } | ||
| 443 | |||
| 82 | /// `title` as a file name: without the characters Windows and macOS refuse in one. | 444 | /// `title` as a file name: without the characters Windows and macOS refuse in one. |
| 83 | fn file_name(title: &str) -> String { | 445 | fn file_name(title: &str) -> String { |
| 84 | let name: String = title | 446 | let name: String = title |
| ... | @@ -115,4 +477,13 @@ mod tests { | ... | @@ -115,4 +477,13 @@ mod tests { |
| 115 | assert_eq!(file_name("Notes: 3/4"), "Notes- 3-4"); | 477 | assert_eq!(file_name("Notes: 3/4"), "Notes- 3-4"); |
| 116 | assert_eq!(file_name(" "), "Untitled"); | 478 | assert_eq!(file_name(" "), "Untitled"); |
| 117 | } | 479 | } |
| 480 | |||
| 481 | #[test] | ||
| 482 | fn a_page_group_is_its_top_page_and_subpages() { | ||
| 483 | let levels = [1, 2, 3, 1, 1, 2]; | ||
| 484 | assert_eq!(group(&levels, 2), 0..3); | ||
| 485 | assert_eq!(group(&levels, 0), 0..3); | ||
| 486 | assert_eq!(group(&levels, 3), 3..4); | ||
| 487 | assert_eq!(group(&levels, 5), 4..6); | ||
| 488 | } | ||
| 118 | } | 489 | } |
crates/snowbound/src/properties.rs created+196| ... | @@ -0,0 +1,196 @@ | ||
| 1 | //! Notebook Properties, as OneNote 2010's: the notebook's display name on this computer, its | ||
| 2 | //! colour, and where it lives. | ||
| 3 | |||
| 4 | use crate::{Command, Library, State, art, commands, manage::Structure, menus::SECTION_COLORS}; | ||
| 5 | use std::sync::Arc; | ||
| 6 | use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px}; | ||
| 7 | use winit::keyboard::NamedKey; | ||
| 8 | |||
| 9 | const WIDTH: f32 = 380.0; | ||
| 10 | const TITLE: &str = "Notebook Properties"; | ||
| 11 | |||
| 12 | /// The dialog's fields until OK keeps them. | ||
| 13 | pub struct Properties { | ||
| 14 | library: Arc<Library>, | ||
| 15 | name: String, | ||
| 16 | /// A colour picked, COLORREF. | ||
| 17 | color: Option<u32>, | ||
| 18 | } | ||
| 19 | |||
| 20 | fn id() -> Id { | ||
| 21 | Id::ROOT.child("notebook-properties") | ||
| 22 | } | ||
| 23 | |||
| 24 | fn name_field() -> Id { | ||
| 25 | id().child("name") | ||
| 26 | } | ||
| 27 | |||
| 28 | fn colors() -> Id { | ||
| 29 | id().child("colors") | ||
| 30 | } | ||
| 31 | |||
| 32 | impl State { | ||
| 33 | /// Opens Notebook Properties on `library`, its display name selected, as OneNote's Rename… | ||
| 34 | /// and Properties… both open it. | ||
| 35 | pub(crate) fn open_properties(&mut self, library: Arc<Library>) { | ||
| 36 | self.properties = Some(Properties { | ||
| 37 | name: library.name.clone(), | ||
| 38 | library, | ||
| 39 | color: None, | ||
| 40 | }); | ||
| 41 | self.ui.open_popup(id()); | ||
| 42 | self.ui.focus_all(name_field()); | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Builds Notebook Properties while it is open. OK, or Enter in the name, keeps a | ||
| 46 | /// non-empty name and a colour picked. | ||
| 47 | pub(crate) fn properties_dialog(&mut self) { | ||
| 48 | let Some(dialog) = &mut self.properties else { | ||
| 49 | return; | ||
| 50 | }; | ||
| 51 | let ui = &mut self.ui; | ||
| 52 | if !ui.popup_open(id()) { | ||
| 53 | self.properties = None; | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | let theme = ui.theme.clone(); | ||
| 57 | let row = theme.font_size * 2.0; | ||
| 58 | let entered = ui::popup::navigation(ui, &[name_field()], &[NamedKey::Enter]) | ||
| 59 | .contains(&NamedKey::Enter); | ||
| 60 | ui.open_as( | ||
| 61 | id(), | ||
| 62 | Spec { | ||
| 63 | axis: Axis::Y, | ||
| 64 | size: [px(WIDTH), children()], | ||
| 65 | fill: Some(theme.popup), | ||
| 66 | border: Some(theme.chip), | ||
| 67 | shadow: Some(theme.shadow), | ||
| 68 | radius: 8.0, | ||
| 69 | pad: [16.0, 12.0], | ||
| 70 | gap: 4.0, | ||
| 71 | anchor: Some(Anchor::Dialog), | ||
| 72 | role: Some(accesskit::Role::Dialog), | ||
| 73 | ..Spec::default() | ||
| 74 | }, | ||
| 75 | ); | ||
| 76 | if let Some(node) = ui.access(id()) { | ||
| 77 | node.set_label(TITLE); | ||
| 78 | } | ||
| 79 | ui.leaf( | ||
| 80 | "title", | ||
| 81 | Spec { | ||
| 82 | size: [fill(), px(row)], | ||
| 83 | text: Some(TITLE), | ||
| 84 | bold: true, | ||
| 85 | role: Some(accesskit::Role::Heading), | ||
| 86 | ..Spec::default() | ||
| 87 | }, | ||
| 88 | ); | ||
| 89 | let label = |ui: &mut ui::Ui, text: &str| { | ||
| 90 | ui.leaf( | ||
| 91 | text, | ||
| 92 | Spec { | ||
| 93 | size: [fill(), px(row * 0.8)], | ||
| 94 | text: Some(text), | ||
| 95 | ..Spec::default() | ||
| 96 | }, | ||
| 97 | ); | ||
| 98 | }; | ||
| 99 | let dim = |ui: &mut ui::Ui, key: &str, text: &str| { | ||
| 100 | ui.leaf( | ||
| 101 | key, | ||
| 102 | Spec { | ||
| 103 | flags: ui::Flags::CLIP, | ||
| 104 | size: [fill(), px(row * 0.8)], | ||
| 105 | text: Some(text), | ||
| 106 | color: Some(theme.text_dim), | ||
| 107 | ..Spec::default() | ||
| 108 | }, | ||
| 109 | ); | ||
| 110 | }; | ||
| 111 | label(ui, "Display name:"); | ||
| 112 | ui::text_field( | ||
| 113 | ui, | ||
| 114 | name_field(), | ||
| 115 | &mut dialog.name, | ||
| 116 | "", | ||
| 117 | Spec { | ||
| 118 | size: [fill(), px(row)], | ||
| 119 | fill: Some(theme.base), | ||
| 120 | border: Some(theme.accent), | ||
| 121 | radius: 4.0, | ||
| 122 | pad: [6.0, 0.0], | ||
| 123 | ..Spec::default() | ||
| 124 | }, | ||
| 125 | ); | ||
| 126 | if let Some(node) = ui.access(name_field()) { | ||
| 127 | node.set_label("Display name"); | ||
| 128 | } | ||
| 129 | dim(ui, "hint", "Doesn’t change the notebook’s folder name"); | ||
| 130 | label(ui, "Color:"); | ||
| 131 | let shown = dialog.color.or(dialog.library.color()); | ||
| 132 | let named = SECTION_COLORS | ||
| 133 | .iter() | ||
| 134 | .find(|(color, _)| Some(*color) == shown) | ||
| 135 | .map_or("", |(_, name)| name); | ||
| 136 | let combo = ui.id("combo"); | ||
| 137 | ui::shell::combo(ui, "combo", "Color", named, 160.0, colors(), true); | ||
| 138 | let items: Vec<Item> = SECTION_COLORS | ||
| 139 | .iter() | ||
| 140 | .map(|&(color, text)| Item { | ||
| 141 | text, | ||
| 142 | icon: Some(art::SECTION), | ||
| 143 | tint: Some(theme.section(crate::section_color(Some(color))).accent), | ||
| 144 | current: Some(color) == shown, | ||
| 145 | ..Item::default() | ||
| 146 | }) | ||
| 147 | .collect(); | ||
| 148 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | ||
| 149 | if let Some(index) = ui::popup::menu(ui, colors(), anchor, &items, None) { | ||
| 150 | dialog.color = Some(SECTION_COLORS[index].0); | ||
| 151 | } | ||
| 152 | label(ui, "Location:"); | ||
| 153 | dim(ui, "location", &dialog.library.location); | ||
| 154 | let folder = dialog.library.folder().map(std::path::Path::to_owned); | ||
| 155 | let reveal = commands::command(commands::Id::ShowNotebook).title; | ||
| 156 | if folder.is_some() && ui::button(ui, "reveal", reveal).clicked { | ||
| 157 | crate::platform::reveal(&dialog.library.location); | ||
| 158 | } | ||
| 159 | ui.open( | ||
| 160 | "buttons", | ||
| 161 | Spec { | ||
| 162 | size: [fill(), children()], | ||
| 163 | pad: [0.0, 8.0], | ||
| 164 | gap: 8.0, | ||
| 165 | ..Spec::default() | ||
| 166 | }, | ||
| 167 | ); | ||
| 168 | ui.leaf( | ||
| 169 | "space", | ||
| 170 | Spec { | ||
| 171 | size: [fill(), px(1.0)], | ||
| 172 | ..Spec::default() | ||
| 173 | }, | ||
| 174 | ); | ||
| 175 | let ok = ui::button(ui, "ok", "OK").clicked || entered; | ||
| 176 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | ||
| 177 | ui.close(); | ||
| 178 | ui.close(); | ||
| 179 | let name = dialog.name.trim(); | ||
| 180 | if ok && !name.is_empty() { | ||
| 181 | if name != dialog.library.name || dialog.color.is_some() { | ||
| 182 | self.commands.push(Command::Structure( | ||
| 183 | Arc::clone(&dialog.library), | ||
| 184 | Structure::Properties { | ||
| 185 | name: name.to_owned(), | ||
| 186 | color: dialog.color, | ||
| 187 | }, | ||
| 188 | )); | ||
| 189 | } | ||
| 190 | } else if !cancel { | ||
| 191 | return; | ||
| 192 | } | ||
| 193 | self.ui.close_popup(id()); | ||
| 194 | self.properties = None; | ||
| 195 | } | ||
| 196 | } | ||
crates/snowbound/src/recording.rs+2| ... | @@ -204,6 +204,7 @@ impl State { | ... | @@ -204,6 +204,7 @@ impl State { |
| 204 | kind: if video { 2 } else { 1 }, | 204 | kind: if video { 2 } else { 1 }, |
| 205 | duration_ms, | 205 | duration_ms, |
| 206 | }), | 206 | }), |
| 207 | tags: Vec::new(), | ||
| 207 | }; | 208 | }; |
| 208 | let response = self.view.finish_recording(file)?; | 209 | let response = self.view.finish_recording(file)?; |
| 209 | self.respond(response); | 210 | self.respond(response); |
| ... | @@ -983,6 +984,7 @@ pub(crate) mod tests { | ... | @@ -983,6 +984,7 @@ pub(crate) mod tests { |
| 983 | kind, | 984 | kind, |
| 984 | duration_ms: Some(duration), | 985 | duration_ms: Some(duration), |
| 985 | }), | 986 | }), |
| 987 | tags: Vec::new(), | ||
| 986 | }; | 988 | }; |
| 987 | editor.finish_recording(&mut engine, file).unwrap(); | 989 | editor.finish_recording(&mut engine, file).unwrap(); |
| 988 | store(&mut editor); | 990 | store(&mut editor); |
crates/snowbound/src/rename.rs+34-18| ... | @@ -6,7 +6,7 @@ use crate::{Command, Library, State, manage::Structure}; | ... | @@ -6,7 +6,7 @@ use crate::{Command, Library, State, manage::Structure}; |
| 6 | use onestore::{ | 6 | use onestore::{ |
| 7 | ExGuid, | 7 | ExGuid, |
| 8 | op::{Edit, Op, PageOp}, | 8 | op::{Edit, Op, PageOp}, |
| 9 | page::PageObject, | 9 | page::{Page, PageObject}, |
| 10 | }; | 10 | }; |
| 11 | use std::{error::Error, sync::Arc}; | 11 | use std::{error::Error, sync::Arc}; |
| 12 | use ui::{Event, Id, Spec, Theme, Ui, fill, px}; | 12 | use ui::{Event, Id, Spec, Theme, Ui, fill, px}; |
| ... | @@ -170,32 +170,48 @@ impl State { | ... | @@ -170,32 +170,48 @@ impl State { |
| 170 | fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box<dyn Error>> { | 170 | fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box<dyn Error>> { |
| 171 | self.persist()?; | 171 | self.persist()?; |
| 172 | let session = self.session.as_mut().ok_or("No section is open")?; | 172 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 173 | let page = session.section.page(space)?; | 173 | let op = retitled(&session.section.page(space)?, space, name.clone())?; |
| 174 | let title = page | 174 | let old = session |
| 175 | .objects | 175 | .pages |
| 176 | .iter() | 176 | .iter() |
| 177 | .find_map(|object| match object { | 177 | .find(|(page, ..)| *page == space) |
| 178 | PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(), | 178 | .map(|(_, title, _)| title.clone()) |
| 179 | _ => None, | 179 | .unwrap_or_default(); |
| 180 | }) | ||
| 181 | .ok_or("The page has no title to rename")?; | ||
| 182 | let length = title.text.utf16_offset(title.text.text().len())?; | ||
| 183 | session.section.apply( | 180 | session.section.apply( |
| 184 | &self.author, | 181 | &self.author, |
| 185 | Edit { | 182 | Edit { |
| 186 | at: crate::filetime(), | 183 | at: crate::filetime(), |
| 187 | ops: vec![Op::Page { | 184 | ops: vec![op], |
| 188 | space, | ||
| 189 | op: PageOp::Text { | ||
| 190 | text: title.id, | ||
| 191 | range: 0..length, | ||
| 192 | with: name, | ||
| 193 | }, | ||
| 194 | }], | ||
| 195 | }, | 185 | }, |
| 196 | )?; | 186 | )?; |
| 197 | session.pages = session.section.pages()?; | 187 | session.pages = session.section.pages()?; |
| 188 | self.made(crate::undo::Change::Retitle { | ||
| 189 | page: space, | ||
| 190 | from: name, | ||
| 191 | to: old, | ||
| 192 | }); | ||
| 198 | self.edited(vec![space]); | 193 | self.edited(vec![space]); |
| 199 | self.refresh() | 194 | self.refresh() |
| 200 | } | 195 | } |
| 201 | } | 196 | } |
| 197 | |||
| 198 | /// The op titling `page`, in `space`, `name`. | ||
| 199 | pub fn retitled(page: &Page, space: ExGuid, name: String) -> Result<Op, Box<dyn Error>> { | ||
| 200 | let title = page | ||
| 201 | .objects | ||
| 202 | .iter() | ||
| 203 | .find_map(|object| match object { | ||
| 204 | PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(), | ||
| 205 | _ => None, | ||
| 206 | }) | ||
| 207 | .ok_or("The page has no title to rename")?; | ||
| 208 | let length = title.text.utf16_offset(title.text.text().len())?; | ||
| 209 | Ok(Op::Page { | ||
| 210 | space, | ||
| 211 | op: PageOp::Text { | ||
| 212 | text: title.id, | ||
| 213 | range: 0..length, | ||
| 214 | with: name, | ||
| 215 | }, | ||
| 216 | }) | ||
| 217 | } |
crates/snowbound/src/server.rs+123-2| ... | @@ -343,6 +343,76 @@ fn id() -> Id { | ... | @@ -343,6 +343,76 @@ fn id() -> Id { |
| 343 | Id::ROOT.child("server") | 343 | Id::ROOT.child("server") |
| 344 | } | 344 | } |
| 345 | 345 | ||
| 346 | /// The address a sign-in to `mount` is saved by: its server and account, without share or | ||
| 347 | /// folder, and never a password. | ||
| 348 | fn saved_address(mount: &Mount) -> String { | ||
| 349 | Mount { | ||
| 350 | share: String::new(), | ||
| 351 | root: String::new(), | ||
| 352 | ..mount.clone() | ||
| 353 | } | ||
| 354 | .url() | ||
| 355 | } | ||
| 356 | |||
| 357 | /// The saved servers under "Recent Servers", each a button that reconnects to it and one that | ||
| 358 | /// removes it; returns the one picked and whether to remove it. | ||
| 359 | pub(crate) fn saved_servers(ui: &mut Ui, servers: &[String]) -> Option<(usize, bool)> { | ||
| 360 | if servers.is_empty() { | ||
| 361 | return None; | ||
| 362 | } | ||
| 363 | let theme = ui.theme.clone(); | ||
| 364 | let row = theme.font_size * 2.0; | ||
| 365 | ui.leaf( | ||
| 366 | "recent", | ||
| 367 | Spec { | ||
| 368 | size: [fill(), px(row)], | ||
| 369 | text: Some("Recent Servers"), | ||
| 370 | color: Some(theme.text_dim), | ||
| 371 | role: Some(Role::Heading), | ||
| 372 | ..Spec::default() | ||
| 373 | }, | ||
| 374 | ); | ||
| 375 | let mut picked = None; | ||
| 376 | for (index, address) in servers.iter().enumerate() { | ||
| 377 | ui.open( | ||
| 378 | ("saved", address), | ||
| 379 | Spec { | ||
| 380 | size: [fill(), px(row)], | ||
| 381 | gap: 4.0, | ||
| 382 | ..Spec::default() | ||
| 383 | }, | ||
| 384 | ); | ||
| 385 | let label = address.strip_prefix("smb://").unwrap_or(address); | ||
| 386 | if ui | ||
| 387 | .leaf( | ||
| 388 | "connect", | ||
| 389 | Spec { | ||
| 390 | flags: Flags::CLICKABLE | Flags::CLIP, | ||
| 391 | size: [fill(), px(row)], | ||
| 392 | icon: Some(art::SERVER), | ||
| 393 | text: Some(label), | ||
| 394 | hover_fill: Some(theme.hover()), | ||
| 395 | radius: 4.0, | ||
| 396 | pad: [8.0, 0.0], | ||
| 397 | gap: 6.0, | ||
| 398 | role: Some(Role::Button), | ||
| 399 | ..Spec::default() | ||
| 400 | }, | ||
| 401 | ) | ||
| 402 | .clicked | ||
| 403 | { | ||
| 404 | picked = Some((index, false)); | ||
| 405 | } | ||
| 406 | let remove = ui.id("remove"); | ||
| 407 | if ui::shell::tool_button(ui, "remove", art::CLOSE, theme.text_dim, None).clicked { | ||
| 408 | picked = Some((index, true)); | ||
| 409 | } | ||
| 410 | name(ui, remove, &format!("Remove {label}")); | ||
| 411 | ui.close(); | ||
| 412 | } | ||
| 413 | picked | ||
| 414 | } | ||
| 415 | |||
| 346 | fn address_field() -> Id { | 416 | fn address_field() -> Id { |
| 347 | id().child("address") | 417 | id().child("address") |
| 348 | } | 418 | } |
| ... | @@ -409,6 +479,33 @@ impl State { | ... | @@ -409,6 +479,33 @@ impl State { |
| 409 | self.ui.focus_all(focus); | 479 | self.ui.focus_all(focus); |
| 410 | } | 480 | } |
| 411 | 481 | ||
| 482 | /// Opens the dialog at saved server `address`, signing in with any password the keychain | ||
| 483 | /// keeps, to browse its shares. | ||
| 484 | pub(crate) fn reconnect(&mut self, address: &str) { | ||
| 485 | self.open_server(Some(address)); | ||
| 486 | if let Some(connect) = &mut self.server { | ||
| 487 | connect.reopen = false; | ||
| 488 | } | ||
| 489 | } | ||
| 490 | |||
| 491 | /// Saves `address` first among the recent servers. | ||
| 492 | fn remember_server(&mut self, address: String) { | ||
| 493 | self.servers.retain(|saved| *saved != address); | ||
| 494 | self.servers.insert(0, address); | ||
| 495 | self.save_settings(); | ||
| 496 | } | ||
| 497 | |||
| 498 | /// Picks from, or removes from, the recent servers `saved_servers` lists. | ||
| 499 | pub(crate) fn saved_server(&mut self, (index, remove): (usize, bool)) { | ||
| 500 | if remove { | ||
| 501 | self.servers.remove(index); | ||
| 502 | self.save_settings(); | ||
| 503 | } else { | ||
| 504 | let address = self.servers[index].clone(); | ||
| 505 | self.reconnect(&address); | ||
| 506 | } | ||
| 507 | } | ||
| 508 | |||
| 412 | /// Runs `request` off the frame, which wakes when it is answered. | 509 | /// Runs `request` off the frame, which wakes when it is answered. |
| 413 | fn ask(&mut self, request: Request) { | 510 | fn ask(&mut self, request: Request) { |
| 414 | let Some(connect) = &self.server else { | 511 | let Some(connect) = &self.server else { |
| ... | @@ -452,9 +549,13 @@ impl State { | ... | @@ -452,9 +549,13 @@ impl State { |
| 452 | return; | 549 | return; |
| 453 | } | 550 | } |
| 454 | let replies: Vec<Reply> = connect.replies.1.try_iter().collect(); | 551 | let replies: Vec<Reply> = connect.replies.1.try_iter().collect(); |
| 552 | let mut signed_in = None; | ||
| 455 | for reply in replies { | 553 | for reply in replies { |
| 456 | if connect.receive(reply) | 554 | if !connect.receive(reply) { |
| 457 | && connect.remember == Some(true) | 555 | continue; |
| 556 | } | ||
| 557 | signed_in = connect.mount.as_ref().map(saved_address); | ||
| 558 | if connect.remember == Some(true) | ||
| 458 | && !connect.guest | 559 | && !connect.guest |
| 459 | && let (Some(mount), login) = (&connect.mount, connect.login()) | 560 | && let (Some(mount), login) = (&connect.mount, connect.login()) |
| 460 | && let Err(error) = platform::save_login(mount, &login) | 561 | && let Err(error) = platform::save_login(mount, &login) |
| ... | @@ -462,6 +563,12 @@ impl State { | ... | @@ -462,6 +563,12 @@ impl State { |
| 462 | eprintln!("Keeping the password for {}: {error}", mount.host()); | 563 | eprintln!("Keeping the password for {}: {error}", mount.host()); |
| 463 | } | 564 | } |
| 464 | } | 565 | } |
| 566 | if let Some(address) = signed_in { | ||
| 567 | self.remember_server(address); | ||
| 568 | } | ||
| 569 | let Some(connect) = &mut self.server else { | ||
| 570 | return; | ||
| 571 | }; | ||
| 465 | if connect.reopen | 572 | if connect.reopen |
| 466 | && let Some((mount, login)) = connect.opens() | 573 | && let Some((mount, login)) = connect.opens() |
| 467 | { | 574 | { |
| ... | @@ -529,6 +636,7 @@ impl State { | ... | @@ -529,6 +636,7 @@ impl State { |
| 529 | }; | 636 | }; |
| 530 | let mut chosen = None; | 637 | let mut chosen = None; |
| 531 | let mut back = false; | 638 | let mut back = false; |
| 639 | let mut saved = None; | ||
| 532 | match connect.step { | 640 | match connect.step { |
| 533 | Step::Address => { | 641 | Step::Address => { |
| 534 | labelled(ui, "Server Address:", |ui| { | 642 | labelled(ui, "Server Address:", |ui| { |
| ... | @@ -541,6 +649,7 @@ impl State { | ... | @@ -541,6 +649,7 @@ impl State { |
| 541 | ); | 649 | ); |
| 542 | name(ui, address_field(), "Server Address"); | 650 | name(ui, address_field(), "Server Address"); |
| 543 | }); | 651 | }); |
| 652 | saved = saved_servers(ui, &self.servers); | ||
| 544 | } | 653 | } |
| 545 | Step::SignIn => { | 654 | Step::SignIn => { |
| 546 | if ui::check_box(ui, "guest", "Connect as guest", connect.guest).clicked { | 655 | if ui::check_box(ui, "guest", "Connect as guest", connect.guest).clicked { |
| ... | @@ -744,6 +853,9 @@ impl State { | ... | @@ -744,6 +853,9 @@ impl State { |
| 744 | self.server = None; | 853 | self.server = None; |
| 745 | return; | 854 | return; |
| 746 | } | 855 | } |
| 856 | if let Some(saved) = saved { | ||
| 857 | return self.saved_server(saved); | ||
| 858 | } | ||
| 747 | let connect = self.server.as_mut().expect("the dialog is open"); | 859 | let connect = self.server.as_mut().expect("the dialog is open"); |
| 748 | let request = if let Some(index) = chosen { | 860 | let request = if let Some(index) = chosen { |
| 749 | connect.enter(index) | 861 | connect.enter(index) |
| ... | @@ -1032,6 +1144,15 @@ mod tests { | ... | @@ -1032,6 +1144,15 @@ mod tests { |
| 1032 | assert_eq!(connect.listing.folders, ["Notes", "public"]); | 1144 | assert_eq!(connect.listing.folders, ["Notes", "public"]); |
| 1033 | } | 1145 | } |
| 1034 | 1146 | ||
| 1147 | #[test] | ||
| 1148 | fn a_saved_server_keeps_its_account_but_not_its_share_or_password() { | ||
| 1149 | let mount = | ||
| 1150 | Mount::from_address("smb://WORK;clover:secret@nas:1445/Notes/My Garden").unwrap(); | ||
| 1151 | assert_eq!(saved_address(&mount), "smb://WORK;clover@nas:1445"); | ||
| 1152 | let guest = Mount::from_address("nas/public").unwrap(); | ||
| 1153 | assert_eq!(saved_address(&guest), "smb://nas"); | ||
| 1154 | } | ||
| 1155 | |||
| 1035 | #[test] | 1156 | #[test] |
| 1036 | fn guests_sign_in_without_an_account() { | 1157 | fn guests_sign_in_without_an_account() { |
| 1037 | let mut connect = Connect::new("nas/Notes".into(), None); | 1158 | let mut connect = Connect::new("nas/Notes".into(), None); |
crates/snowbound/src/settings.rs+19-21| ... | @@ -36,7 +36,7 @@ pub struct Settings { | ... | @@ -36,7 +36,7 @@ pub struct Settings { |
| 36 | /// Places and moves things where they are dropped, as OneNote's Snap To Grid off. | 36 | /// Places and moves things where they are dropped, as OneNote's Snap To Grid off. |
| 37 | pub ignore_grid: bool, | 37 | pub ignore_grid: bool, |
| 38 | /// Options' Default font: new text's font and size, and new titles' font. | 38 | /// Options' Default font: new text's font and size, and new titles' font. |
| 39 | pub default_font: DefaultFont, | 39 | pub default_font: canvas::editor::DefaultFont, |
| 40 | /// OneNote's "Page tabs appear on the left". | 40 | /// OneNote's "Page tabs appear on the left". |
| 41 | pub page_tabs_left: bool, | 41 | pub page_tabs_left: bool, |
| 42 | /// OneNote's "Navigation bar appears on the left" turned off: the notebooks on the right. | 42 | /// OneNote's "Navigation bar appears on the left" turned off: the notebooks on the right. |
| ... | @@ -44,24 +44,10 @@ pub struct Settings { | ... | @@ -44,24 +44,10 @@ pub struct Settings { |
| 44 | /// Servers Open Notebook from Server signed in to, latest first, as addresses without a | 44 | /// Servers Open Notebook from Server signed in to, latest first, as addresses without a |
| 45 | /// password. | 45 | /// password. |
| 46 | pub servers: Vec<String>, | 46 | pub servers: Vec<String>, |
| 47 | } | 47 | /// Chords set in Options' Keyboard, by command, each list in place of its defaults. |
| 48 | 48 | pub keys: std::collections::BTreeMap<String, Vec<String>>, | |
| 49 | /// The font and size OneNote's Default font gives new text. | 49 | /// Pages shown lately, latest first, which the palette lists first. |
| 50 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | 50 | pub recent: Vec<crate::navigation::Place>, |
| 51 | #[serde(default)] | ||
| 52 | pub struct DefaultFont { | ||
| 53 | pub face: String, | ||
| 54 | pub size: f32, | ||
| 55 | } | ||
| 56 | |||
| 57 | impl Default for DefaultFont { | ||
| 58 | /// OneNote 2010's: Calibri 11. | ||
| 59 | fn default() -> Self { | ||
| 60 | Self { | ||
| 61 | face: "Calibri".into(), | ||
| 62 | size: 11.0, | ||
| 63 | } | ||
| 64 | } | ||
| 65 | } | 51 | } |
| 66 | 52 | ||
| 67 | /// What the toolbar's buttons apply from their menus' last picks. | 53 | /// What the toolbar's buttons apply from their menus' last picks. |
| ... | @@ -174,10 +160,12 @@ impl crate::State { | ... | @@ -174,10 +160,12 @@ impl crate::State { |
| 174 | manual_updates: !self.updates.automatic(), | 160 | manual_updates: !self.updates.automatic(), |
| 175 | ignore_pen_pressure: !self.pen_pressure, | 161 | ignore_pen_pressure: !self.pen_pressure, |
| 176 | ignore_grid: !self.view.snap_to_grid, | 162 | ignore_grid: !self.view.snap_to_grid, |
| 177 | default_font: self.default_font.clone(), | 163 | default_font: self.view.editor.default_font.clone(), |
| 178 | page_tabs_left: self.page_tabs_left, | 164 | page_tabs_left: self.page_tabs_left, |
| 179 | navigation_bar_right: self.navigation_bar_right, | 165 | navigation_bar_right: self.navigation_bar_right, |
| 180 | servers: self.servers.clone(), | 166 | servers: self.servers.clone(), |
| 167 | keys: crate::commands::Keymap::current().saved(), | ||
| 168 | recent: self.trail.recent.clone(), | ||
| 181 | }; | 169 | }; |
| 182 | if let Err(error) = settings.save(path) { | 170 | if let Err(error) = settings.save(path) { |
| 183 | eprintln!("Cannot save the settings in {}: {error}", path.display()); | 171 | eprintln!("Cannot save the settings in {}: {error}", path.display()); |
| ... | @@ -239,13 +227,23 @@ mod tests { | ... | @@ -239,13 +227,23 @@ mod tests { |
| 239 | manual_updates: true, | 227 | manual_updates: true, |
| 240 | ignore_pen_pressure: true, | 228 | ignore_pen_pressure: true, |
| 241 | ignore_grid: true, | 229 | ignore_grid: true, |
| 242 | default_font: DefaultFont { | 230 | default_font: canvas::editor::DefaultFont { |
| 243 | face: "Georgia".into(), | 231 | face: "Georgia".into(), |
| 244 | size: 14.0, | 232 | size: 14.0, |
| 233 | color: Some(0xc07000), | ||
| 245 | }, | 234 | }, |
| 246 | page_tabs_left: true, | 235 | page_tabs_left: true, |
| 247 | navigation_bar_right: true, | 236 | navigation_bar_right: true, |
| 248 | servers: vec!["smb://clover@nas/Notes".into()], | 237 | servers: vec!["smb://clover@nas/Notes".into()], |
| 238 | keys: [("Sidebar".into(), vec!["Command+Shift+L".into()])].into(), | ||
| 239 | recent: vec![crate::navigation::Place { | ||
| 240 | notebook: "/notebooks/Personal".into(), | ||
| 241 | section: "To Do.one".into(), | ||
| 242 | page: onestore::ExGuid { | ||
| 243 | guid: [7; 16], | ||
| 244 | n: 1, | ||
| 245 | }, | ||
| 246 | }], | ||
| 249 | }; | 247 | }; |
| 250 | settings.save(&path).unwrap(); | 248 | settings.save(&path).unwrap(); |
| 251 | assert_eq!(Settings::load(&path), settings); | 249 | assert_eq!(Settings::load(&path), settings); |
crates/snowbound/src/sidebar.rs+101-37| ... | @@ -31,6 +31,7 @@ pub enum Action { | ... | @@ -31,6 +31,7 @@ pub enum Action { |
| 31 | /// Folds or unfolds a notebook's or section group's rows, by `fold_key`. | 31 | /// Folds or unfolds a notebook's or section group's rows, by `fold_key`. |
| 32 | Fold(String), | 32 | Fold(String), |
| 33 | NewNotebook, | 33 | NewNotebook, |
| 34 | NewICloudNotebook, | ||
| 34 | OpenNotebook, | 35 | OpenNotebook, |
| 35 | /// Signs in again to the notebook opened from its server at this location. | 36 | /// Signs in again to the notebook opened from its server at this location. |
| 36 | SignIn(String), | 37 | SignIn(String), |
| ... | @@ -137,7 +138,8 @@ pub fn header() -> Id { | ... | @@ -137,7 +138,8 @@ pub fn header() -> Id { |
| 137 | } | 138 | } |
| 138 | 139 | ||
| 139 | /// The sidebar's header row, `header` tall and dragging the window when `drags`, then | 140 | /// The sidebar's header row, `header` tall and dragging the window when `drags`, then |
| 140 | /// with `rows` the tree of `notebooks` with the open section marked. | 141 | /// with `rows` the tree of `notebooks` with the open section marked. The notebook button |
| 142 | /// floats over the header's end at the window's edge, the `right` one or the left. | ||
| 141 | fn sidebar( | 143 | fn sidebar( |
| 142 | ui: &mut Ui, | 144 | ui: &mut Ui, |
| 143 | tree: &mut Tree, | 145 | tree: &mut Tree, |
| ... | @@ -145,6 +147,7 @@ fn sidebar( | ... | @@ -145,6 +147,7 @@ fn sidebar( |
| 145 | header: f32, | 147 | header: f32, |
| 146 | drags: bool, | 148 | drags: bool, |
| 147 | rows: bool, | 149 | rows: bool, |
| 150 | right: bool, | ||
| 148 | ) { | 151 | ) { |
| 149 | let (theme, folded) = (tree.theme, tree.folded); | 152 | let (theme, folded) = (tree.theme, tree.folded); |
| 150 | ui.open_as( | 153 | ui.open_as( |
| ... | @@ -162,13 +165,18 @@ fn sidebar( | ... | @@ -162,13 +165,18 @@ fn sidebar( |
| 162 | }, | 165 | }, |
| 163 | ); | 166 | ); |
| 164 | // The notebook button's square, which floats over this place. | 167 | // The notebook button's square, which floats over this place. |
| 165 | ui.leaf( | 168 | let toggle = |ui: &mut Ui| { |
| 166 | "toggle", | 169 | ui.leaf( |
| 167 | Spec { | 170 | "toggle", |
| 168 | size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)], | 171 | Spec { |
| 169 | ..Spec::default() | 172 | size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)], |
| 170 | }, | 173 | ..Spec::default() |
| 171 | ); | 174 | }, |
| 175 | ); | ||
| 176 | }; | ||
| 177 | if !right { | ||
| 178 | toggle(ui); | ||
| 179 | } | ||
| 172 | ui.leaf( | 180 | ui.leaf( |
| 173 | "title", | 181 | "title", |
| 174 | Spec { | 182 | Spec { |
| ... | @@ -178,6 +186,9 @@ fn sidebar( | ... | @@ -178,6 +186,9 @@ fn sidebar( |
| 178 | ..Spec::default() | 186 | ..Spec::default() |
| 179 | }, | 187 | }, |
| 180 | ); | 188 | ); |
| 189 | if right { | ||
| 190 | toggle(ui); | ||
| 191 | } | ||
| 181 | ui.close(); | 192 | ui.close(); |
| 182 | if !rows { | 193 | if !rows { |
| 183 | return; | 194 | return; |
| ... | @@ -242,12 +253,21 @@ fn sidebar( | ... | @@ -242,12 +253,21 @@ fn sidebar( |
| 242 | }, | 253 | }, |
| 243 | ); | 254 | ); |
| 244 | let options = ("options", art::OPTIONS, "Options", Action::Options); | 255 | let options = ("options", art::OPTIONS, "Options", Action::Options); |
| 245 | for (part, icon, label, chosen) in [ | 256 | let icloud = crate::icloud::folder().map(|_| { |
| 246 | ("new", art::PLUS, "New Notebook", Action::NewNotebook), | 257 | ( |
| 247 | ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook), | 258 | "new icloud", |
| 248 | ] | 259 | art::ICLOUD, |
| 249 | .into_iter() | 260 | "New iCloud Notebook", |
| 250 | .chain((!cfg!(target_os = "macos")).then_some(options)) | 261 | Action::NewICloudNotebook, |
| 262 | ) | ||
| 263 | }); | ||
| 264 | for (part, icon, label, chosen) in icloud | ||
| 265 | .into_iter() | ||
| 266 | .chain([ | ||
| 267 | ("new", art::PLUS, "New Notebook", Action::NewNotebook), | ||
| 268 | ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook), | ||
| 269 | ]) | ||
| 270 | .chain((!cfg!(target_os = "macos")).then_some(options)) | ||
| 251 | { | 271 | { |
| 252 | let (row, _) = tree_row( | 272 | let (row, _) = tree_row( |
| 253 | ui, | 273 | ui, |
| ... | @@ -395,10 +415,10 @@ fn folder( | ... | @@ -395,10 +415,10 @@ fn folder( |
| 395 | ui.id(("unavailable", notebook, &entry.path)), | 415 | ui.id(("unavailable", notebook, &entry.path)), |
| 396 | Row { | 416 | Row { |
| 397 | label: &name, | 417 | label: &name, |
| 398 | icon: if entry.group { | 418 | icon: match entry.reason { |
| 399 | Leading::Icon(art::SECTION_GROUP) | 419 | _ if entry.group => Leading::Icon(art::SECTION_GROUP), |
| 400 | } else { | 420 | Reason::Evicted => Leading::Icon(art::ICLOUD), |
| 401 | Leading::Section(section_color(None)) | 421 | _ => Leading::Section(section_color(None)), |
| 402 | }, | 422 | }, |
| 403 | depth, | 423 | depth, |
| 404 | dim: true, | 424 | dim: true, |
| ... | @@ -413,6 +433,10 @@ fn folder( | ... | @@ -413,6 +433,10 @@ fn folder( |
| 413 | format!("Can't read \u{201c}{name}\u{201d}"), | 433 | format!("Can't read \u{201c}{name}\u{201d}"), |
| 414 | format!("{}\n\nIt appears here once you have access.", entry.error), | 434 | format!("{}\n\nIt appears here once you have access.", entry.error), |
| 415 | ), | 435 | ), |
| 436 | Reason::Evicted => Action::Unavailable( | ||
| 437 | format!("Downloading \u{201c}{name}\u{201d}"), | ||
| 438 | "It opens once iCloud Drive brings it to this computer.".into(), | ||
| 439 | ), | ||
| 416 | _ => Action::Unavailable( | 440 | _ => Action::Unavailable( |
| 417 | format!("Can't open \u{201c}{name}\u{201d}"), | 441 | format!("Can't open \u{201c}{name}\u{201d}"), |
| 418 | format!("{}.", entry.error), | 442 | format!("{}.", entry.error), |
| ... | @@ -663,19 +687,27 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { | ... | @@ -663,19 +687,27 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { |
| 663 | } | 687 | } |
| 664 | 688 | ||
| 665 | impl crate::State { | 689 | impl crate::State { |
| 666 | /// The sidebar left of the section tabs, easing open and shut; returns its width. | 690 | /// How wide the sidebar stands this frame as it eases open and shut. |
| 667 | pub(crate) fn sidebar(&mut self, theme: &Theme) -> f32 { | 691 | pub(crate) fn sidebar_width(&mut self) -> f32 { |
| 668 | if self.temporary { | 692 | if self.temporary { |
| 669 | return 0.0; | 693 | return 0.0; |
| 670 | } | 694 | } |
| 671 | let width = self.ui.animate( | 695 | self.ui.animate( |
| 672 | self.ui.id("sidebar"), | 696 | self.ui.id("sidebar"), |
| 673 | if self.sidebar && !self.full_page { | 697 | if self.sidebar && !self.full_page { |
| 674 | WIDTH | 698 | WIDTH |
| 675 | } else { | 699 | } else { |
| 676 | 0.0 | 700 | 0.0 |
| 677 | }, | 701 | }, |
| 678 | ); | 702 | ) |
| 703 | } | ||
| 704 | |||
| 705 | /// The sidebar `width` wide beside the section tabs, on the left, or the right as | ||
| 706 | /// OneNote's "Navigation bar appears on the left" turned off puts it. | ||
| 707 | pub(crate) fn sidebar(&mut self, theme: &Theme, width: f32) { | ||
| 708 | if self.temporary { | ||
| 709 | return; | ||
| 710 | } | ||
| 679 | self.ui.open( | 711 | self.ui.open( |
| 680 | "sidebar", | 712 | "sidebar", |
| 681 | Spec { | 713 | Spec { |
| ... | @@ -721,6 +753,7 @@ impl crate::State { | ... | @@ -721,6 +753,7 @@ impl crate::State { |
| 721 | crate::TAB_ROW, | 753 | crate::TAB_ROW, |
| 722 | drags, | 754 | drags, |
| 723 | width > 0.5, | 755 | width > 0.5, |
| 756 | self.navigation_bar_right, | ||
| 724 | ); | 757 | ); |
| 725 | let Tree { | 758 | let Tree { |
| 726 | action, | 759 | action, |
| ... | @@ -754,6 +787,9 @@ impl crate::State { | ... | @@ -754,6 +787,9 @@ impl crate::State { |
| 754 | } | 787 | } |
| 755 | Some(Action::Renamed(keep)) => self.finish_renaming(keep), | 788 | Some(Action::Renamed(keep)) => self.finish_renaming(keep), |
| 756 | Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), | 789 | Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), |
| 790 | Some(Action::NewICloudNotebook) => { | ||
| 791 | self.commands.push(crate::Command::NewICloudNotebook) | ||
| 792 | } | ||
| 757 | Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook), | 793 | Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook), |
| 758 | Some(Action::SignIn(location)) => self | 794 | Some(Action::SignIn(location)) => self |
| 759 | .commands | 795 | .commands |
| ... | @@ -762,7 +798,6 @@ impl crate::State { | ... | @@ -762,7 +798,6 @@ impl crate::State { |
| 762 | Some(Action::Unavailable(title, message)) => crate::platform::alert(&title, &message), | 798 | Some(Action::Unavailable(title, message)) => crate::platform::alert(&title, &message), |
| 763 | Some(Action::Open { .. }) | None => {} | 799 | Some(Action::Open { .. }) | None => {} |
| 764 | } | 800 | } |
| 765 | width | ||
| 766 | } | 801 | } |
| 767 | 802 | ||
| 768 | /// The sidebar's button in its square at the window's edge, and the room the section | 803 | /// The sidebar's button in its square at the window's edge, and the room the section |
| ... | @@ -774,11 +809,20 @@ impl crate::State { | ... | @@ -774,11 +809,20 @@ impl crate::State { |
| 774 | if self.temporary { | 809 | if self.temporary { |
| 775 | return; | 810 | return; |
| 776 | } | 811 | } |
| 812 | // On the right it stands at the body's far edge, as laid out last frame. | ||
| 813 | let x = match self.navigation_bar_right { | ||
| 814 | true => self | ||
| 815 | .ui | ||
| 816 | .rect(self.ui.current()) | ||
| 817 | .map_or(0.0, |[left, _, right, _]| right - left - RAIL), | ||
| 818 | false => 0.0, | ||
| 819 | }; | ||
| 777 | self.ui.open( | 820 | self.ui.open( |
| 778 | "toggle", | 821 | "toggle", |
| 779 | Spec { | 822 | Spec { |
| 780 | flags: Flags::FLOAT | Flags::CLIP, | 823 | flags: Flags::FLOAT | Flags::CLIP, |
| 781 | size: [px(RAIL), px(height)], | 824 | size: [px(RAIL), px(height)], |
| 825 | position: [x, 0.0], | ||
| 782 | pad: [ | 826 | pad: [ |
| 783 | (RAIL - ui::shell::TOOL) / 2.0, | 827 | (RAIL - ui::shell::TOOL) / 2.0, |
| 784 | (RAIL - ui::shell::TOOL) / 2.0 + DROP, | 828 | (RAIL - ui::shell::TOOL) / 2.0 + DROP, |
| ... | @@ -1058,15 +1102,25 @@ impl crate::State { | ... | @@ -1058,15 +1102,25 @@ impl crate::State { |
| 1058 | self.load(move || { | 1102 | self.load(move || { |
| 1059 | let library = match open { | 1103 | let library = match open { |
| 1060 | Some(library) => library, | 1104 | Some(library) => library, |
| 1061 | None => Arc::new(read(&location, &cache)?), | 1105 | None => { |
| 1106 | let library = Arc::new(read(&location, &cache)?); | ||
| 1107 | library.purge_recycle_bin(); | ||
| 1108 | library | ||
| 1109 | } | ||
| 1062 | }; | 1110 | }; |
| 1063 | if let Err(error) = &library.notebook { | 1111 | if let Err(error) = &library.notebook { |
| 1064 | return Err(error.clone().into()); | 1112 | return Err(error.clone().into()); |
| 1065 | } | 1113 | } |
| 1066 | let path = section | 1114 | let Some(path) = section |
| 1067 | .filter(|path| library.contains(path)) | 1115 | .filter(|path| library.contains(path)) |
| 1068 | .or_else(|| library.first_section()) | 1116 | .or_else(|| library.first_section()) |
| 1069 | .ok_or("This folder holds no notebook sections.")?; | 1117 | else { |
| 1118 | // Shown once its sections arrive. | ||
| 1119 | if library.downloading() { | ||
| 1120 | return Ok(crate::Loaded::Library(library, None)); | ||
| 1121 | } | ||
| 1122 | return Err("This folder holds no notebook sections.".into()); | ||
| 1123 | }; | ||
| 1070 | let section = library.open(&path, notify)?; | 1124 | let section = library.open(&path, notify)?; |
| 1071 | let (session, page) = crate::read_session(section, library, path, None)?; | 1125 | let (session, page) = crate::read_session(section, library, path, None)?; |
| 1072 | Ok(crate::Loaded::Section(Box::new(session), page)) | 1126 | Ok(crate::Loaded::Section(Box::new(session), page)) |
| ... | @@ -1086,18 +1140,20 @@ impl crate::State { | ... | @@ -1086,18 +1140,20 @@ impl crate::State { |
| 1086 | )); | 1140 | )); |
| 1087 | #[cfg(not(target_os = "linux"))] | 1141 | #[cfg(not(target_os = "linux"))] |
| 1088 | let install = None; | 1142 | let install = None; |
| 1143 | let servers = self.servers.clone(); | ||
| 1089 | self.notice( | 1144 | self.notice( |
| 1090 | theme, | 1145 | theme, |
| 1091 | id, | 1146 | id, |
| 1092 | theme.base, | 1147 | theme.base, |
| 1093 | "No notebooks open", | 1148 | "No notebooks open", |
| 1149 | &servers, | ||
| 1094 | crate::icloud::folder() | 1150 | crate::icloud::folder() |
| 1095 | .map(|_| { | 1151 | .map(|_| { |
| 1096 | ( | 1152 | ( |
| 1097 | "icloud", | 1153 | "icloud", |
| 1098 | art::ICLOUD, | 1154 | art::ICLOUD, |
| 1099 | "Use iCloud Drive", | 1155 | "New iCloud Notebook", |
| 1100 | crate::Command::UseICloud, | 1156 | crate::Command::NewICloudNotebook, |
| 1101 | ) | 1157 | ) |
| 1102 | }) | 1158 | }) |
| 1103 | .into_iter() | 1159 | .into_iter() |
| ... | @@ -1133,8 +1189,9 @@ impl crate::State { | ... | @@ -1133,8 +1189,9 @@ impl crate::State { |
| 1133 | } | 1189 | } |
| 1134 | 1190 | ||
| 1135 | /// The page's place while notebook `library` has no sections, as OneNote 2010 shows | 1191 | /// The page's place while notebook `library` has no sections, as OneNote 2010 shows |
| 1136 | /// it, with a button to add one. | 1192 | /// it, with a button to add one; or while none is on this computer yet. |
| 1137 | pub(crate) fn no_sections(&mut self, theme: &Theme, library: Arc<Library>) { | 1193 | pub(crate) fn no_sections(&mut self, theme: &Theme, library: Arc<Library>) { |
| 1194 | let downloading = library.downloading(); | ||
| 1138 | let new = crate::Command::Structure( | 1195 | let new = crate::Command::Structure( |
| 1139 | library, | 1196 | library, |
| 1140 | crate::manage::Structure::NewSection { | 1197 | crate::manage::Structure::NewSection { |
| ... | @@ -1142,23 +1199,26 @@ impl crate::State { | ... | @@ -1142,23 +1199,26 @@ impl crate::State { |
| 1142 | }, | 1199 | }, |
| 1143 | ); | 1200 | ); |
| 1144 | let id = self.ui.id("no sections"); | 1201 | let id = self.ui.id("no sections"); |
| 1145 | self.notice( | 1202 | let (title, buttons) = if downloading { |
| 1146 | theme, | 1203 | ("Downloading from iCloud Drive…", Vec::new()) |
| 1147 | id, | 1204 | } else { |
| 1148 | theme.strip, | 1205 | ( |
| 1149 | "No sections in this notebook", | 1206 | "No sections in this notebook", |
| 1150 | vec![("new", art::PLUS, "New Section", new)], | 1207 | vec![("new", art::PLUS, "New Section", new)], |
| 1151 | ); | 1208 | ) |
| 1209 | }; | ||
| 1210 | self.notice(theme, id, theme.strip, title, &[], buttons); | ||
| 1152 | } | 1211 | } |
| 1153 | 1212 | ||
| 1154 | /// A box `id` filled with `background`, showing `title` above `buttons` in its middle: each | 1213 | /// A box `id` filled with `background`, showing `title` above `buttons` in its middle: each |
| 1155 | /// a part, an icon, a label and what it does. | 1214 | /// a part, an icon, a label and what it does; then the saved `servers` to reconnect to. |
| 1156 | fn notice( | 1215 | fn notice( |
| 1157 | &mut self, | 1216 | &mut self, |
| 1158 | theme: &Theme, | 1217 | theme: &Theme, |
| 1159 | id: ui::Id, | 1218 | id: ui::Id, |
| 1160 | background: [f32; 4], | 1219 | background: [f32; 4], |
| 1161 | title: &str, | 1220 | title: &str, |
| 1221 | servers: &[String], | ||
| 1162 | buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>, | 1222 | buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>, |
| 1163 | ) { | 1223 | ) { |
| 1164 | const BUTTON: [f32; 2] = [240.0, 32.0]; | 1224 | const BUTTON: [f32; 2] = [240.0, 32.0]; |
| ... | @@ -1217,8 +1277,12 @@ impl crate::State { | ... | @@ -1217,8 +1277,12 @@ impl crate::State { |
| 1217 | } | 1277 | } |
| 1218 | } | 1278 | } |
| 1219 | self.commands.extend(chosen); | 1279 | self.commands.extend(chosen); |
| 1280 | let saved = crate::server::saved_servers(&mut self.ui, servers); | ||
| 1220 | self.ui.close(); | 1281 | self.ui.close(); |
| 1221 | self.ui.close(); | 1282 | self.ui.close(); |
| 1283 | if let Some(saved) = saved { | ||
| 1284 | self.saved_server(saved); | ||
| 1285 | } | ||
| 1222 | } | 1286 | } |
| 1223 | } | 1287 | } |
| 1224 | 1288 |
crates/snowbound/src/spell_windows.rs+2-1| ... | @@ -45,7 +45,8 @@ type Out = *mut *mut *const usize; | ... | @@ -45,7 +45,8 @@ type Out = *mut *mut *const usize; |
| 45 | 45 | ||
| 46 | /// The object a method answered through `out`, where it answered one. | 46 | /// The object a method answered through `out`, where it answered one. |
| 47 | fn answered(result: i32, out: *mut *const usize) -> Option<Object> { | 47 | fn answered(result: i32, out: *mut *const usize) -> Option<Object> { |
| 48 | (result >= 0 && !out.is_null()).then_some(Object(out)) | 48 | // Lazily: an `Object` made for a failed call would release a null pointer when dropped. |
| 49 | (result >= 0 && !out.is_null()).then(|| Object(out)) | ||
| 49 | } | 50 | } |
| 50 | 51 | ||
| 51 | /// The strings an IEnumString holds, freed as they are read. | 52 | /// The strings an IEnumString holds, freed as they are read. |
crates/snowbound/src/surface_windows.rs+48-8| ... | @@ -73,23 +73,42 @@ impl Surface { | ... | @@ -73,23 +73,42 @@ impl Surface { |
| 73 | unreachable!() | 73 | unreachable!() |
| 74 | }; | 74 | }; |
| 75 | let device = unsafe { GetDC(handle.hwnd.get() as HWND) }; | 75 | let device = unsafe { GetDC(handle.hwnd.get() as HWND) }; |
| 76 | let alpha = if backdrop { 8 } else { 0 }; | 76 | let request = |alpha| gl::PIXELFORMATDESCRIPTOR { |
| 77 | let format = gl::PIXELFORMATDESCRIPTOR { | ||
| 78 | nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16, | 77 | nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16, |
| 79 | nVersion: 1, | 78 | nVersion: 1, |
| 80 | // PFD_SUPPORT_COMPOSITION: the desktop composes the window's alpha. | ||
| 81 | dwFlags: gl::PFD_DRAW_TO_WINDOW | 79 | dwFlags: gl::PFD_DRAW_TO_WINDOW |
| 82 | | gl::PFD_SUPPORT_OPENGL | 80 | | gl::PFD_SUPPORT_OPENGL |
| 83 | | gl::PFD_DOUBLEBUFFER | 81 | | gl::PFD_DOUBLEBUFFER |
| 84 | | 0x8000, | 82 | | gl::PFD_SUPPORT_COMPOSITION, |
| 85 | iPixelType: gl::PFD_TYPE_RGBA, | 83 | iPixelType: gl::PFD_TYPE_RGBA, |
| 86 | cColorBits: 32, | 84 | cColorBits: 32, |
| 87 | cAlphaBits: alpha, | 85 | cAlphaBits: alpha, |
| 88 | iLayerType: gl::PFD_MAIN_PLANE as u8, | 86 | iLayerType: gl::PFD_MAIN_PLANE as u8, |
| 89 | ..unsafe { std::mem::zeroed() } | 87 | ..unsafe { std::mem::zeroed() } |
| 90 | }; | 88 | }; |
| 89 | // A driver with no accelerated format that has alpha answers with Windows' own | ||
| 90 | // OpenGL 1.1; frames then go without the glass behind them. | ||
| 91 | let accelerated = |format: &gl::PIXELFORMATDESCRIPTOR| unsafe { | ||
| 92 | let chosen = gl::ChoosePixelFormat(device, format); | ||
| 93 | let mut found = std::mem::zeroed(); | ||
| 94 | let size = size_of::<gl::PIXELFORMATDESCRIPTOR>() as u32; | ||
| 95 | (chosen != 0 | ||
| 96 | && gl::DescribePixelFormat(device, chosen, size, &mut found) != 0 | ||
| 97 | && (found.dwFlags & gl::PFD_GENERIC_FORMAT == 0 | ||
| 98 | || found.dwFlags & gl::PFD_GENERIC_ACCELERATED != 0)) | ||
| 99 | .then_some(chosen) | ||
| 100 | }; | ||
| 101 | let mut format = request(if backdrop { 8 } else { 0 }); | ||
| 102 | let mut translucent = backdrop; | ||
| 103 | let mut chosen = accelerated(&format); | ||
| 104 | if chosen.is_none() && backdrop { | ||
| 105 | eprintln!("No accelerated OpenGL format with alpha; drawing opaque"); | ||
| 106 | format = request(0); | ||
| 107 | translucent = false; | ||
| 108 | chosen = accelerated(&format); | ||
| 109 | } | ||
| 91 | unsafe { | 110 | unsafe { |
| 92 | let chosen = gl::ChoosePixelFormat(device, &format); | 111 | let chosen = chosen.unwrap_or_else(|| gl::ChoosePixelFormat(device, &format)); |
| 93 | if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 { | 112 | if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 { |
| 94 | return Err("No OpenGL pixel format".into()); | 113 | return Err("No OpenGL pixel format".into()); |
| 95 | } | 114 | } |
| ... | @@ -105,15 +124,36 @@ impl Surface { | ... | @@ -105,15 +124,36 @@ impl Surface { |
| 105 | ); | 124 | ); |
| 106 | } | 125 | } |
| 107 | } | 126 | } |
| 108 | let renderer = Renderer::opengl()?; | 127 | let string = |name| unsafe { |
| 109 | eprintln!("Canvas GPU: OpenGL"); | 128 | let text = gl::glGetString(name); |
| 129 | if text.is_null() { | ||
| 130 | String::new() | ||
| 131 | } else { | ||
| 132 | std::ffi::CStr::from_ptr(text.cast()) | ||
| 133 | .to_string_lossy() | ||
| 134 | .into_owned() | ||
| 135 | } | ||
| 136 | }; | ||
| 137 | let driver = format!( | ||
| 138 | "OpenGL {} ({}, {})", | ||
| 139 | string(gl::GL_VERSION), | ||
| 140 | string(gl::GL_RENDERER), | ||
| 141 | string(gl::GL_VENDOR) | ||
| 142 | ); | ||
| 143 | eprintln!("Canvas GPU: {driver}"); | ||
| 144 | let renderer = Renderer::opengl().map_err(|error| { | ||
| 145 | format!( | ||
| 146 | "Snowbound needs OpenGL 2.1, and this display driver has {driver}. Install \ | ||
| 147 | the driver from your graphics card's maker. ({error})" | ||
| 148 | ) | ||
| 149 | })?; | ||
| 110 | let size = window.inner_size(); | 150 | let size = window.inner_size(); |
| 111 | Ok(( | 151 | Ok(( |
| 112 | Self { | 152 | Self { |
| 113 | size: [size.width, size.height], | 153 | size: [size.width, size.height], |
| 114 | backend: Backend::Gl { | 154 | backend: Backend::Gl { |
| 115 | device, | 155 | device, |
| 116 | translucent: backdrop, | 156 | translucent, |
| 117 | target: None, | 157 | target: None, |
| 118 | }, | 158 | }, |
| 119 | }, | 159 | }, |
crates/snowbound/src/sync.rs+1032-252| ... | @@ -1,14 +1,27 @@ | ... | @@ -1,14 +1,27 @@ |
| 1 | //! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook | 1 | //! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook |
| 2 | //! Synchronization for the open section's notebook and each of its sections. | 2 | //! Synchronization for the open section's notebook, led by where its sync stands. |
| 3 | 3 | ||
| 4 | use crate::{Session, State, art, filetime, library, platform, update}; | 4 | use crate::{Library, Session, State, art, filetime, library, platform, update}; |
| 5 | use notebook::session::{SyncState, SyncStatus}; | 5 | use notebook::session::{SyncState, SyncStatus}; |
| 6 | use std::error::Error; | 6 | use onestore::ExGuid; |
| 7 | use std::{error::Error, path::Path, sync::Arc, time::Duration}; | ||
| 7 | use ui::{ | 8 | use ui::{ |
| 8 | Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL, | 9 | Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL, |
| 9 | }; | 10 | }; |
| 10 | 11 | ||
| 11 | const WIDTH: f32 = 380.0; | 12 | const WIDTH: f32 = 340.0; |
| 13 | const PAD: f32 = 16.0; | ||
| 14 | /// A sync shorter than this never shows, so a quick publish doesn't flash the bar. | ||
| 15 | const SHOW_AFTER: Duration = Duration::from_millis(400); | ||
| 16 | /// How long a shown sync's full bar stays before the status reads up to date. | ||
| 17 | const LINGER: Duration = Duration::from_millis(700); | ||
| 18 | /// One pass of the sheen along a running bar. | ||
| 19 | const SHEEN: f32 = 1.4; | ||
| 20 | const THIS: &str = if cfg!(target_os = "macos") { | ||
| 21 | "this Mac" | ||
| 22 | } else { | ||
| 23 | "this computer" | ||
| 24 | }; | ||
| 12 | 25 | ||
| 13 | fn id() -> Id { | 26 | fn id() -> Id { |
| 14 | Id::ROOT.child("sync-status") | 27 | Id::ROOT.child("sync-status") |
| ... | @@ -18,55 +31,52 @@ fn button() -> Id { | ... | @@ -18,55 +31,52 @@ fn button() -> Id { |
| 18 | Id::ROOT.child("sync-button") | 31 | Id::ROOT.child("sync-button") |
| 19 | } | 32 | } |
| 20 | 33 | ||
| 21 | /// The status's label and icon, and what the reader can do about an error; `mounted` where | 34 | /// The status's headline and icon. `quiet` holds a sync back that hasn't run `SHOW_AFTER` |
| 22 | /// the notebook syncs through the system's mount because Snowbound's client couldn't sign in. | 35 | /// yet: a notebook synced before still reads up to date. |
| 23 | fn describe( | 36 | fn describe( |
| 24 | sync: &SyncStatus, | 37 | sync: &SyncStatus, |
| 25 | mounted: bool, | 38 | offline: bool, |
| 26 | ) -> (&'static str, &'static [&'static str], Option<&'static str>) { | 39 | quiet: bool, |
| 27 | if library::offline() { | 40 | ) -> (&'static str, &'static [&'static str]) { |
| 28 | return ( | 41 | if offline { |
| 29 | "Working offline", | 42 | return ("Working offline", art::SYNC_OFFLINE); |
| 30 | art::SYNC_OFFLINE, | ||
| 31 | Some("Changes stay on this computer until you sync."), | ||
| 32 | ); | ||
| 33 | } | 43 | } |
| 34 | match sync.state() { | 44 | match sync.state() { |
| 35 | SyncState::ReadOnly => ( | 45 | SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR), |
| 36 | "Unable to sync", | 46 | SyncState::InUse => ("Waiting for another device", art::SYNC_BUSY), |
| 37 | art::SYNC_ERROR, | 47 | SyncState::NotConnected => ("Waiting for connection", art::SYNC_OFFLINE), |
| 38 | Some( | 48 | SyncState::Protected => ("Password-protected section", art::SYNC_ERROR), |
| 39 | "You can’t change this notebook where it’s stored. Changes stay on this computer.", | 49 | SyncState::Unreadable => ("Can’t read a section", art::SYNC_ERROR), |
| 40 | ), | 50 | SyncState::Failed => ("Unable to sync", art::SYNC_ERROR), |
| 41 | ), | 51 | SyncState::Syncing if !quiet || sync.synced.is_none() => ("Syncing…", art::SYNC_BUSY), |
| 42 | SyncState::InUse => ( | 52 | SyncState::Syncing | SyncState::UpToDate => ("Up to date", art::SYNC_DONE), |
| 43 | "Section in use", | ||
| 44 | art::SYNC_BUSY, | ||
| 45 | Some("Someone else is saving this section. Sync continues when they finish."), | ||
| 46 | ), | ||
| 47 | SyncState::NotConnected => ( | ||
| 48 | "Not connected", | ||
| 49 | art::SYNC_OFFLINE, | ||
| 50 | Some("Changes stay on this computer and sync when the notebook is back."), | ||
| 51 | ), | ||
| 52 | SyncState::Protected => ( | ||
| 53 | "Password protected", | ||
| 54 | art::SYNC_ERROR, | ||
| 55 | Some("Snowbound can’t open password-protected sections yet."), | ||
| 56 | ), | ||
| 57 | SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None), | ||
| 58 | SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None), | ||
| 59 | SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None), | ||
| 60 | SyncState::UpToDate if mounted => { | ||
| 61 | ("Using the system’s connection", art::SYNC_WARNING, None) | ||
| 62 | } | ||
| 63 | SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None), | ||
| 64 | } | 53 | } |
| 65 | } | 54 | } |
| 66 | 55 | ||
| 67 | /// Every section of the open section's notebook with its status, in catalog order: the | 56 | /// A section's status beside its name. |
| 68 | /// open one's from its session, the others' from the notebook's background sync. | 57 | fn brief(sync: &SyncStatus, offline: bool) -> String { |
| 69 | fn sections(session: &Session) -> Vec<(String, SyncStatus)> { | 58 | let waiting = match sync.queued { |
| 59 | 0 => String::new(), | ||
| 60 | queued => format!(" · {} waiting", changes(queued)), | ||
| 61 | }; | ||
| 62 | match sync.state() { | ||
| 63 | SyncState::UpToDate => "Up to date".to_owned(), | ||
| 64 | SyncState::Syncing if offline => format!("{} waiting", changes(sync.queued)), | ||
| 65 | SyncState::Syncing if sync.queued > 0 => format!("Sending {}", changes(sync.queued)), | ||
| 66 | SyncState::Syncing => "Syncing…".to_owned(), | ||
| 67 | SyncState::InUse => format!("In use elsewhere{waiting}"), | ||
| 68 | SyncState::NotConnected => format!("Not connected{waiting}"), | ||
| 69 | SyncState::ReadOnly => format!("Read-only{waiting}"), | ||
| 70 | SyncState::Protected => "Password protected".to_owned(), | ||
| 71 | SyncState::Unreadable => "Can’t read".to_owned(), | ||
| 72 | SyncState::Failed => format!("Unable to sync{waiting}"), | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | /// Every section of `library` with its status, in catalog order: the open one's from its | ||
| 77 | /// session, the others' from the notebook's background sync. OneNote's sync dialog leaves | ||
| 78 | /// out the recycle bin, unless something is wrong there. | ||
| 79 | fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> { | ||
| 70 | let open = &session.tabs[session.tab].path; | 80 | let open = &session.tabs[session.tab].path; |
| 71 | let copy = |sync: &SyncStatus| SyncStatus { | 81 | let copy = |sync: &SyncStatus| SyncStatus { |
| 72 | synced: sync.synced, | 82 | synced: sync.synced, |
| ... | @@ -76,16 +86,23 @@ fn sections(session: &Session) -> Vec<(String, SyncStatus)> { | ... | @@ -76,16 +86,23 @@ fn sections(session: &Session) -> Vec<(String, SyncStatus)> { |
| 76 | .map(|error| std::io::Error::new(error.kind(), error.to_string())), | 86 | .map(|error| std::io::Error::new(error.kind(), error.to_string())), |
| 77 | queued: sync.queued, | 87 | queued: sync.queued, |
| 78 | }; | 88 | }; |
| 79 | let mut sections = session | 89 | let mut sections = library |
| 80 | .library | ||
| 81 | .background | 90 | .background |
| 82 | .as_ref() | 91 | .as_ref() |
| 83 | .map(|background| background.status()) | 92 | .map(|background| background.status()) |
| 84 | .unwrap_or_default(); | 93 | .unwrap_or_default(); |
| 85 | match sections.iter_mut().find(|(path, _)| path == open) { | 94 | if std::ptr::eq(library, &*session.library) { |
| 86 | Some((_, sync)) => *sync = copy(&session.sync), | 95 | match sections.iter_mut().find(|(path, _)| path == open) { |
| 87 | None => sections.insert(0, (open.clone(), copy(&session.sync))), | 96 | Some((_, sync)) => *sync = copy(&session.sync), |
| 97 | None => sections.insert(0, (open.clone(), copy(&session.sync))), | ||
| 98 | } | ||
| 88 | } | 99 | } |
| 100 | sections.retain(|(path, sync)| { | ||
| 101 | sync.error.is_some() | ||
| 102 | || !path | ||
| 103 | .rsplit_once('/') | ||
| 104 | .is_some_and(|(folder, _)| library::recycle_bin(folder)) | ||
| 105 | }); | ||
| 89 | sections | 106 | sections |
| 90 | } | 107 | } |
| 91 | 108 | ||
| ... | @@ -117,15 +134,39 @@ fn changes(count: u64) -> String { | ... | @@ -117,15 +134,39 @@ fn changes(count: u64) -> String { |
| 117 | } | 134 | } |
| 118 | } | 135 | } |
| 119 | 136 | ||
| 120 | /// A FILETIME as OneNote's "Last sync": the time, and the date too before today. | 137 | /// A FILETIME as how long ago it was: minutes within the hour, then the time, and the date |
| 121 | fn when(time: u64) -> String { | 138 | /// too before today. |
| 139 | fn ago(time: u64) -> String { | ||
| 140 | let minutes = filetime().saturating_sub(time) / 600_000_000; | ||
| 141 | if minutes < 1 { | ||
| 142 | return "just now".to_owned(); | ||
| 143 | } | ||
| 144 | if minutes < 60 { | ||
| 145 | return format!("{minutes} min ago"); | ||
| 146 | } | ||
| 122 | let [_, clock] = platform::date_text(time); | 147 | let [_, clock] = platform::date_text(time); |
| 123 | let day = platform::short_date(time); | 148 | let day = platform::short_date(time); |
| 124 | if day == platform::short_date(filetime()) { | 149 | if day == platform::short_date(filetime()) { |
| 125 | clock | 150 | format!("at {clock}") |
| 126 | } else { | 151 | } else { |
| 127 | format!("{day} {clock}") | 152 | format!("{day} at {clock}") |
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | /// Whether a sync shows: once it has run `SHOW_AFTER`, and for `LINGER` after it ends up | ||
| 157 | /// to date where it `shown`, so a quick one never flashes. `id` keeps the timings. | ||
| 158 | fn shows_sync(ui: &mut Ui, id: Id, syncing: bool, shown: bool) -> bool { | ||
| 159 | let running = ui.lasted(id.child("running"), syncing); | ||
| 160 | let ended = ui.lasted(id.child("ended"), !syncing); | ||
| 161 | let due = match syncing { | ||
| 162 | true => SHOW_AFTER.checked_sub(running), | ||
| 163 | false if shown => LINGER.checked_sub(ended), | ||
| 164 | false => None, | ||
| 165 | }; | ||
| 166 | if let Some(due) = due.filter(|due| !due.is_zero()) { | ||
| 167 | ui.wake_after(due); | ||
| 128 | } | 168 | } |
| 169 | running >= SHOW_AFTER || !syncing && shown && ended < LINGER | ||
| 129 | } | 170 | } |
| 130 | 171 | ||
| 131 | const TITLE: &str = "Notebook Sync Status"; | 172 | const TITLE: &str = "Notebook Sync Status"; |
| ... | @@ -143,8 +184,16 @@ fn update_note(update: &update::Status) -> Option<String> { | ... | @@ -143,8 +184,16 @@ fn update_note(update: &update::Status) -> Option<String> { |
| 143 | /// The status's icon in the toolbar, which opens the popup; its tooltip names the status, | 184 | /// The status's icon in the toolbar, which opens the popup; its tooltip names the status, |
| 144 | /// and a dot on it says a newer build is ready. | 185 | /// and a dot on it says a newer build is ready. |
| 145 | pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) { | 186 | pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) { |
| 146 | let sync = overall(&sections(session)); | 187 | let sync = overall(&sections(&session.library, session)); |
| 147 | let (label, icon, _) = describe(&sync, session.library.notice.is_some()); | 188 | let offline = library::offline(); |
| 189 | let syncing = !offline && sync.state() == SyncState::Syncing; | ||
| 190 | let quiet = !shows_sync(ui, button(), syncing, false); | ||
| 191 | let (label, icon) = describe(&sync, offline, quiet); | ||
| 192 | // Through the system's mount, OneNote's locks aren't taken. | ||
| 193 | let icon = match session.library.notice { | ||
| 194 | Some(_) if icon == art::SYNC_DONE => art::SYNC_WARNING, | ||
| 195 | _ => icon, | ||
| 196 | }; | ||
| 148 | ui.open_as( | 197 | ui.open_as( |
| 149 | button(), | 198 | button(), |
| 150 | Spec { | 199 | Spec { |
| ... | @@ -197,255 +246,986 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t | ... | @@ -197,255 +246,986 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t |
| 197 | } | 246 | } |
| 198 | } | 247 | } |
| 199 | 248 | ||
| 200 | impl State { | 249 | /// What the popup shows of the notebook. |
| 201 | /// Builds the sync status popup while it is open. | 250 | struct Facts<'a> { |
| 202 | pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> { | 251 | name: &'a str, |
| 203 | let (ui, notebooks) = (&mut self.ui, &self.notebooks); | 252 | /// The notebook's folder or address in full. |
| 204 | let update = self.updates.status(); | 253 | location: &'a str, |
| 205 | let Some(session) = &mut self.session else { | 254 | place: Vec<String>, |
| 206 | ui.close_popup(id()); | 255 | /// Why the notebook syncs through the system's mount of its share. |
| 207 | return Ok(()); | 256 | notice: Option<&'a str>, |
| 208 | }; | 257 | sections: Vec<(String, SyncStatus)>, |
| 209 | if !ui.popup_open(id()) { | 258 | /// The open section's pages with conflict pages, and their titles. |
| 210 | return Ok(()); | 259 | conflicts: Vec<(ExGuid, &'a str)>, |
| 211 | } | 260 | offline: bool, |
| 212 | // Read afresh while shown, so the last sync time follows each poll. | 261 | update: &'a update::Status, |
| 213 | session.sync = session.section.sync_status()?; | 262 | /// Whether the open section's file can be shown in the file manager. |
| 214 | let theme = ui.theme.clone(); | 263 | local: bool, |
| 215 | let offline = library::offline(); | 264 | } |
| 216 | let sections = sections(session); | 265 | |
| 217 | let sync = overall(&sections); | 266 | /// What the reader picked in the popup. |
| 218 | let (progress, _, advice) = describe(&sync, session.library.notice.is_some()); | 267 | #[derive(Default)] |
| 219 | ui.open_as( | 268 | struct Picked { |
| 220 | id(), | 269 | offline: bool, |
| 270 | sync_now: bool, | ||
| 271 | show_file: bool, | ||
| 272 | sign_in: bool, | ||
| 273 | conflict: Option<ExGuid>, | ||
| 274 | build_folder: bool, | ||
| 275 | restart: bool, | ||
| 276 | } | ||
| 277 | |||
| 278 | /// Lays out the open popup's contents. | ||
| 279 | fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ||
| 280 | let theme = ui.theme.clone(); | ||
| 281 | let mut picked = Picked::default(); | ||
| 282 | let line = theme.font_size * 1.6; | ||
| 283 | let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4]| { | ||
| 284 | ui.leaf( | ||
| 285 | part, | ||
| 221 | Spec { | 286 | Spec { |
| 222 | axis: Axis::Y, | 287 | size: [fill(), fit()], |
| 223 | size: [px(WIDTH), children()], | 288 | text: Some(text), |
| 224 | fill: Some(theme.popup), | 289 | color: Some(color), |
| 225 | border: Some(theme.chip), | 290 | overflow: Overflow::Wrap, |
| 226 | shadow: Some(theme.shadow), | 291 | pad: [0.0, 2.0], |
| 227 | radius: 8.0, | ||
| 228 | pad: [14.0, 10.0], | ||
| 229 | gap: 2.0, | ||
| 230 | anchor: Some(Anchor::Below(ui.rect(button()).unwrap_or_default())), | ||
| 231 | role: Some(accesskit::Role::Dialog), | ||
| 232 | ..Spec::default() | 292 | ..Spec::default() |
| 233 | }, | 293 | }, |
| 234 | ); | 294 | ); |
| 235 | if let Some(node) = ui.access(id()) { | 295 | }; |
| 236 | node.set_label(TITLE); | 296 | let rule = |ui: &mut Ui, part: &str| { |
| 297 | ui.leaf( | ||
| 298 | part, | ||
| 299 | Spec { | ||
| 300 | size: [fill(), px(1.0)], | ||
| 301 | fill: Some(theme.chip), | ||
| 302 | ..Spec::default() | ||
| 303 | }, | ||
| 304 | ); | ||
| 305 | }; | ||
| 306 | let sync = overall(&facts.sections); | ||
| 307 | let total = facts.sections.len(); | ||
| 308 | let done = facts | ||
| 309 | .sections | ||
| 310 | .iter() | ||
| 311 | .filter(|(_, sync)| sync.state() == SyncState::UpToDate) | ||
| 312 | .count(); | ||
| 313 | let state = sync.state(); | ||
| 314 | let syncing = !facts.offline && state == SyncState::Syncing; | ||
| 315 | let bar = id().child("status").child("bar"); | ||
| 316 | let shown = ui.rect(bar).is_some() && state == SyncState::UpToDate; | ||
| 317 | let showing = !facts.offline && shows_sync(ui, button(), syncing, shown); | ||
| 318 | let (mut headline, mut icon) = describe(&sync, facts.offline, !showing); | ||
| 319 | let host = facts.place.first().map_or("the notebook", String::as_str); | ||
| 320 | let waiting = changes(sync.queued); | ||
| 321 | let conflict; | ||
| 322 | let advice = if facts.offline { | ||
| 323 | Some(match sync.queued { | ||
| 324 | 0 => "Turn off Work offline to sync.".to_owned(), | ||
| 325 | _ => format!("{waiting} will sync when you go back online."), | ||
| 326 | }) | ||
| 327 | } else { | ||
| 328 | match state { | ||
| 329 | SyncState::NotConnected => Some(match sync.queued { | ||
| 330 | 0 => format!("Can’t reach {host}. Sync continues when it’s back."), | ||
| 331 | _ => format!("Can’t reach {host}. {waiting} will sync when it’s back."), | ||
| 332 | }), | ||
| 333 | SyncState::InUse => { | ||
| 334 | Some("Someone else is saving a section. Sync continues when they finish.".into()) | ||
| 335 | } | ||
| 336 | SyncState::ReadOnly => Some(format!( | ||
| 337 | "This location is read-only. Your changes stay on {THIS}." | ||
| 338 | )), | ||
| 339 | SyncState::Protected => { | ||
| 340 | Some("Snowbound can’t open password-protected sections yet.".into()) | ||
| 341 | } | ||
| 342 | SyncState::Unreadable | SyncState::Failed => { | ||
| 343 | Some("Snowbound keeps trying. Sync Now tries again at once.".into()) | ||
| 344 | } | ||
| 345 | _ => None, | ||
| 237 | } | 346 | } |
| 238 | let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| { | 347 | }; |
| 239 | ui.leaf( | 348 | if !showing && headline == "Up to date" && !facts.conflicts.is_empty() { |
| 240 | part, | 349 | conflict = match facts.conflicts.as_slice() { |
| 241 | Spec { | 350 | [(_, title)] => format!("Conflict on “{title}”"), |
| 242 | size: [fill(), fit()], | 351 | pages => format!("Conflicts on {} pages", pages.len()), |
| 243 | text: Some(text), | ||
| 244 | color: Some(color), | ||
| 245 | bold, | ||
| 246 | overflow: Overflow::Wrap, | ||
| 247 | pad: [0.0, 3.0], | ||
| 248 | ..Spec::default() | ||
| 249 | }, | ||
| 250 | ); | ||
| 251 | }; | 352 | }; |
| 252 | let row = |ui: &mut Ui, key: &str, value: &str| { | 353 | headline = &conflict; |
| 253 | ui.open( | 354 | icon = art::SYNC_WARNING; |
| 254 | key, | 355 | } |
| 255 | Spec { | 356 | |
| 256 | size: [fill(), children()], | 357 | // The status: headline, when it last synced or what to do, and a running sync's bar. |
| 257 | gap: 8.0, | 358 | ui.open( |
| 258 | ..Spec::default() | 359 | "status", |
| 259 | }, | 360 | Spec { |
| 260 | ); | 361 | axis: Axis::Y, |
| 261 | ui.leaf( | 362 | size: [fill(), children()], |
| 262 | "key", | 363 | gap: 2.0, |
| 263 | Spec { | 364 | ..Spec::default() |
| 264 | size: [px(92.0), px(theme.font_size * 1.6)], | 365 | }, |
| 265 | text: Some(key), | 366 | ); |
| 266 | color: Some(theme.text_dim), | 367 | if let Some(node) = ui.access(ui.current()) { |
| 267 | ..Spec::default() | 368 | node.set_live(accesskit::Live::Polite); |
| 268 | }, | 369 | } |
| 269 | ); | 370 | ui.leaf( |
| 270 | ui.leaf( | 371 | "headline", |
| 271 | "value", | 372 | Spec { |
| 272 | Spec { | 373 | size: [fill(), px(theme.font_size * 2.0)], |
| 273 | size: [fill(), px(theme.font_size * 1.6)], | 374 | text: Some(headline), |
| 274 | text: Some(value), | 375 | icon: Some(icon), |
| 275 | overflow: Overflow::Ellipsis, | 376 | font_size: Some(theme.font_size * 1.2), |
| 276 | ..Spec::default() | 377 | bold: true, |
| 277 | }, | 378 | overflow: Overflow::Ellipsis, |
| 278 | ); | 379 | ..Spec::default() |
| 279 | ui.close(); | 380 | }, |
| 381 | ); | ||
| 382 | if showing { | ||
| 383 | let fraction = match total { | ||
| 384 | 0 => 1.0, | ||
| 385 | _ if state == SyncState::UpToDate => 1.0, | ||
| 386 | _ => done as f32 / total as f32, | ||
| 280 | }; | 387 | }; |
| 281 | text(ui, "title", TITLE, theme.text, true); | 388 | let fraction = ui.animate(bar.child("fraction"), fraction); |
| 282 | row(ui, "Notebook", &session.library.name); | 389 | let running = ui.lasted(bar.child("running"), true).as_secs_f32(); |
| 283 | row(ui, "Location", &session.library.location); | 390 | ui.open_as( |
| 284 | row(ui, "Connection", &session.library.transport()); | 391 | bar, |
| 285 | row(ui, "Progress", progress); | 392 | Spec { |
| 286 | if let Some(synced) = sync.synced { | 393 | size: [fill(), px(6.0)], |
| 287 | row(ui, "Last sync", &when(synced)); | 394 | fill: Some(theme.chip), |
| 288 | } | 395 | radius: 3.0, |
| 289 | if sync.queued > 0 { | 396 | role: Some(accesskit::Role::ProgressIndicator), |
| 290 | row(ui, "Not yet synced", &changes(sync.queued)); | 397 | ..Spec::default() |
| 398 | }, | ||
| 399 | ); | ||
| 400 | let width = ui.rect(bar).map_or(0.0, |rect| rect[2] - rect[0]); | ||
| 401 | let filled = width * fraction; | ||
| 402 | ui.mark([0.0, 0.0, filled, 6.0], theme.accent, 3.0); | ||
| 403 | // A sheen sweeps the filled part while the sync runs, so a long section still moves. | ||
| 404 | if fraction < 1.0 && filled > 0.0 { | ||
| 405 | let band = 36.0_f32.min(filled); | ||
| 406 | let at = (running % SHEEN) / SHEEN * (filled + band) - band; | ||
| 407 | let [r, g, b, _] = theme.popup; | ||
| 408 | ui.mark( | ||
| 409 | [at.max(0.0), 0.0, (at + band).min(filled), 6.0], | ||
| 410 | [r, g, b, 0.28], | ||
| 411 | 3.0, | ||
| 412 | ); | ||
| 413 | ui.wake_after(Duration::from_millis(16)); | ||
| 291 | } | 414 | } |
| 292 | if let Some(advice) = advice { | 415 | if let Some(node) = ui.access(bar) { |
| 293 | text(ui, "advice", advice, theme.text, false); | 416 | node.set_label("Sync progress"); |
| 417 | node.set_min_numeric_value(0.0); | ||
| 418 | node.set_max_numeric_value(100.0); | ||
| 419 | node.set_numeric_value(f64::from((fraction * 100.0).round())); | ||
| 294 | } | 420 | } |
| 295 | let mut sign_in = None; | 421 | ui.close(); |
| 296 | if let Some(notice) = &session.library.notice { | 422 | let mut caption = match total { |
| 297 | let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}"); | 423 | 0 | 1 => String::new(), |
| 298 | text(ui, "notice", &notice, theme.text_dim, false); | 424 | _ => format!("{done} of {total} sections synced"), |
| 299 | if ui::button(ui, "sign-in", "Sign In\u{2026}").clicked { | 425 | }; |
| 300 | sign_in = Some(session.library.location.clone()); | 426 | if sync.queued > 0 { |
| 427 | if !caption.is_empty() { | ||
| 428 | caption += " · "; | ||
| 301 | } | 429 | } |
| 430 | caption += &format!("{waiting} to send"); | ||
| 302 | } | 431 | } |
| 303 | text(ui, "sections", "Sections", theme.text, true); | 432 | if caption.is_empty() { |
| 304 | // OneNote's sync dialog leaves out the recycle bin, unless something is wrong there. | 433 | caption = "Checking for changes…".to_owned(); |
| 305 | let listed = sections.iter().filter(|(path, sync)| { | 434 | } |
| 306 | sync.error.is_some() | 435 | text(ui, "caption", &caption, theme.text_dim); |
| 307 | || !path | 436 | } else { |
| 308 | .rsplit_once('/') | 437 | if let Some(advice) = &advice { |
| 309 | .is_some_and(|(folder, _)| library::recycle_bin(folder)) | 438 | text(ui, "advice", advice, theme.text); |
| 310 | }); | 439 | } else if !facts.conflicts.is_empty() && headline != "Syncing…" { |
| 311 | for (index, (path, sync)) in listed.enumerate() { | 440 | text( |
| 312 | ui.open( | 441 | ui, |
| 313 | format!("section-{index}"), | 442 | "advice", |
| 314 | Spec { | 443 | "Both versions are kept. Open the page to compare them.", |
| 315 | size: [fill(), children()], | 444 | theme.text, |
| 316 | gap: 8.0, | ||
| 317 | ..Spec::default() | ||
| 318 | }, | ||
| 319 | ); | ||
| 320 | let name = path.strip_suffix(".one").unwrap_or(path); | ||
| 321 | ui.leaf( | ||
| 322 | "name", | ||
| 323 | Spec { | ||
| 324 | size: [fill(), px(theme.font_size * 1.6)], | ||
| 325 | text: Some(name), | ||
| 326 | overflow: Overflow::Ellipsis, | ||
| 327 | ..Spec::default() | ||
| 328 | }, | ||
| 329 | ); | 445 | ); |
| 330 | let status = match sync.queued { | 446 | } |
| 331 | 0 => describe(sync, false).0.to_owned(), | 447 | if let Some(synced) = sync.synced { |
| 332 | queued => format!("{}, {}", describe(sync, false).0, changes(queued)), | 448 | let prefix = match advice { |
| 449 | Some(_) => "Last synced", | ||
| 450 | None => "Synced", | ||
| 333 | }; | 451 | }; |
| 334 | ui.leaf( | 452 | text( |
| 335 | "status", | 453 | ui, |
| 336 | Spec { | 454 | "synced", |
| 337 | size: [fit(), px(theme.font_size * 1.6)], | 455 | &format!("{prefix} {}", ago(synced)), |
| 338 | text: Some(&status), | 456 | theme.text_dim, |
| 339 | color: Some(theme.text_dim), | ||
| 340 | ..Spec::default() | ||
| 341 | }, | ||
| 342 | ); | 457 | ); |
| 343 | ui.close(); | 458 | ui.wake_after(Duration::from_secs(30)); |
| 344 | if let Some(error) = &sync.error { | ||
| 345 | let part = format!("section-{index}-error"); | ||
| 346 | text(ui, &part, &error.to_string(), theme.text_dim, false); | ||
| 347 | } | ||
| 348 | } | 459 | } |
| 349 | let (mut folder, mut restart) = (false, false); | 460 | } |
| 350 | if let Some(note) = update_note(&update) { | 461 | ui.close(); |
| 351 | text(ui, "update-title", "Snowbound Update", theme.text, true); | 462 | |
| 352 | row(ui, "Update", &note); | 463 | rule(ui, "identity-rule"); |
| 353 | ui.open( | 464 | // Which notebook, and where it lives; the full location in the tooltip. |
| 354 | "update", | 465 | ui.open( |
| 355 | Spec { | 466 | "identity", |
| 356 | size: [fill(), children()], | 467 | Spec { |
| 357 | pad: [0.0, 4.0], | 468 | axis: Axis::Y, |
| 358 | gap: 8.0, | 469 | size: [fill(), children()], |
| 359 | ..Spec::default() | 470 | ..Spec::default() |
| 360 | }, | 471 | }, |
| 361 | ); | 472 | ); |
| 362 | folder = ui::button(ui, "build-folder", "Build Folder").clicked; | 473 | ui.leaf( |
| 363 | ui.leaf( | 474 | "name", |
| 364 | "space", | 475 | Spec { |
| 365 | Spec { | 476 | size: [fill(), px(line)], |
| 366 | size: [fill(), px(1.0)], | 477 | text: Some(facts.name), |
| 367 | ..Spec::default() | 478 | icon: Some(art::NOTEBOOK), |
| 368 | }, | 479 | bold: true, |
| 480 | overflow: Overflow::Ellipsis, | ||
| 481 | ..Spec::default() | ||
| 482 | }, | ||
| 483 | ); | ||
| 484 | if !facts.place.is_empty() { | ||
| 485 | ui.leaf( | ||
| 486 | "place", | ||
| 487 | Spec { | ||
| 488 | size: [fill(), px(line)], | ||
| 489 | text: Some(&facts.place.join(" › ")), | ||
| 490 | color: Some(theme.text_dim), | ||
| 491 | overflow: Overflow::Ellipsis, | ||
| 492 | pad: [22.0, 0.0], | ||
| 493 | ..Spec::default() | ||
| 494 | }, | ||
| 495 | ); | ||
| 496 | ui::popup::tooltip(ui, facts.location, "", None); | ||
| 497 | } | ||
| 498 | if let Some(notice) = facts.notice { | ||
| 499 | ui.open( | ||
| 500 | "notice", | ||
| 501 | Spec { | ||
| 502 | size: [fill(), children()], | ||
| 503 | gap: 8.0, | ||
| 504 | pad: [22.0, 4.0], | ||
| 505 | ..Spec::default() | ||
| 506 | }, | ||
| 507 | ); | ||
| 508 | text( | ||
| 509 | ui, | ||
| 510 | "text", | ||
| 511 | &format!("Using the system’s connection. Snowbound couldn’t sign in: {notice}"), | ||
| 512 | theme.text_dim, | ||
| 513 | ); | ||
| 514 | picked.sign_in = ui::button(ui, "sign-in", "Sign In…").clicked; | ||
| 515 | ui.close(); | ||
| 516 | } | ||
| 517 | ui.close(); | ||
| 518 | |||
| 519 | rule(ui, "sections-rule"); | ||
| 520 | // Only what isn't up to date, and how many others are. | ||
| 521 | ui.open( | ||
| 522 | "sections", | ||
| 523 | Spec { | ||
| 524 | axis: Axis::Y, | ||
| 525 | size: [fill(), children()], | ||
| 526 | ..Spec::default() | ||
| 527 | }, | ||
| 528 | ); | ||
| 529 | // Where the whole notebook can't be reached, the headline says so for each section | ||
| 530 | // without changes waiting. | ||
| 531 | let unreached = |sync: &SyncStatus| { | ||
| 532 | !facts.offline && sync.state() == SyncState::NotConnected && sync.queued == 0 | ||
| 533 | }; | ||
| 534 | let behind: Vec<_> = facts | ||
| 535 | .sections | ||
| 536 | .iter() | ||
| 537 | .filter(|(_, sync)| sync.state() != SyncState::UpToDate) | ||
| 538 | .filter(|(_, sync)| !(state == SyncState::NotConnected && unreached(sync))) | ||
| 539 | .collect(); | ||
| 540 | let unreached = match state { | ||
| 541 | SyncState::NotConnected => facts | ||
| 542 | .sections | ||
| 543 | .iter() | ||
| 544 | .filter(|(_, sync)| unreached(sync)) | ||
| 545 | .count(), | ||
| 546 | _ => 0, | ||
| 547 | }; | ||
| 548 | for (index, (path, sync)) in behind.iter().enumerate() { | ||
| 549 | ui.open( | ||
| 550 | ("section", index), | ||
| 551 | Spec { | ||
| 552 | size: [fill(), children()], | ||
| 553 | gap: 8.0, | ||
| 554 | ..Spec::default() | ||
| 555 | }, | ||
| 556 | ); | ||
| 557 | let name = path | ||
| 558 | .strip_suffix(".one") | ||
| 559 | .unwrap_or(path) | ||
| 560 | .replace('/', " › "); | ||
| 561 | ui.leaf( | ||
| 562 | "name", | ||
| 563 | Spec { | ||
| 564 | size: [fill(), px(line)], | ||
| 565 | text: Some(&name), | ||
| 566 | overflow: Overflow::Ellipsis, | ||
| 567 | ..Spec::default() | ||
| 568 | }, | ||
| 569 | ); | ||
| 570 | let color = match sync.state() { | ||
| 571 | SyncState::Syncing => theme.text_dim, | ||
| 572 | _ => theme.text, | ||
| 573 | }; | ||
| 574 | ui.leaf( | ||
| 575 | "status", | ||
| 576 | Spec { | ||
| 577 | size: [fit(), px(line)], | ||
| 578 | text: Some(&brief(sync, facts.offline)), | ||
| 579 | color: Some(color), | ||
| 580 | ..Spec::default() | ||
| 581 | }, | ||
| 582 | ); | ||
| 583 | ui.close(); | ||
| 584 | if let Some(error) = sync | ||
| 585 | .error | ||
| 586 | .as_ref() | ||
| 587 | .filter(|_| matches!(sync.state(), SyncState::Failed | SyncState::Unreadable)) | ||
| 588 | { | ||
| 589 | text( | ||
| 590 | ui, | ||
| 591 | &format!("section-{index}-error"), | ||
| 592 | &error.to_string(), | ||
| 593 | theme.text_dim, | ||
| 369 | ); | 594 | ); |
| 370 | if matches!(update, update::Status::Ready(..)) { | ||
| 371 | restart = ui::button(ui, "restart", "Restart to Update").clicked; | ||
| 372 | } | ||
| 373 | ui.close(); | ||
| 374 | } | 595 | } |
| 596 | } | ||
| 597 | for (space, title) in &facts.conflicts { | ||
| 375 | ui.open( | 598 | ui.open( |
| 376 | "controls", | 599 | ("conflict", space), |
| 377 | Spec { | 600 | Spec { |
| 378 | size: [fill(), children()], | 601 | size: [fill(), children()], |
| 379 | pad: [0.0, 6.0], | ||
| 380 | gap: 8.0, | 602 | gap: 8.0, |
| 381 | ..Spec::default() | 603 | ..Spec::default() |
| 382 | }, | 604 | }, |
| 383 | ); | 605 | ); |
| 384 | let toggled = ui::check_box(ui, "offline", "Work offline", offline).clicked; | ||
| 385 | ui.leaf( | 606 | ui.leaf( |
| 386 | "space", | 607 | "name", |
| 387 | Spec { | 608 | Spec { |
| 388 | size: [fill(), px(1.0)], | 609 | size: [fill(), px(line)], |
| 610 | text: Some(title), | ||
| 611 | icon: Some(art::SYNC_WARNING), | ||
| 612 | overflow: Overflow::Ellipsis, | ||
| 389 | ..Spec::default() | 613 | ..Spec::default() |
| 390 | }, | 614 | }, |
| 391 | ); | 615 | ); |
| 392 | let file = session.library.local(session.section.file()); | 616 | if ui::button(ui, "open", "Open Page").clicked { |
| 393 | let show = match file { | 617 | picked.conflict = Some(*space); |
| 394 | Some(_) => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, | 618 | } |
| 395 | None => { | 619 | ui.close(); |
| 396 | ui.leaf( | 620 | } |
| 397 | "show-file", | 621 | let summaries = [ |
| 398 | Spec { | 622 | match (behind.len() + unreached, done) { |
| 399 | size: [fit(), px(theme.font_size * 2.0)], | 623 | (_, 0) | (0, 1) => None, |
| 400 | text: Some(platform::SHOW_FILE), | 624 | (0, all) => Some(format!("All {all} sections up to date")), |
| 401 | color: Some(theme.text_dim), | 625 | (_, 1) => Some("1 other section up to date".to_owned()), |
| 402 | fill: Some(theme.chip), | 626 | (_, others) => Some(format!("{others} other sections up to date")), |
| 403 | radius: 4.0, | 627 | }, |
| 404 | pad: [theme.font_size * 0.75, 0.0], | 628 | match (unreached, behind.is_empty() && done == 0) { |
| 405 | center: true, | 629 | (0, _) | (1, true) => None, |
| 406 | role: Some(accesskit::Role::Button), | 630 | (count, true) => Some(format!("All {count} sections waiting for connection")), |
| 407 | ..Spec::default() | 631 | (1, false) => Some("1 other section waiting for connection".to_owned()), |
| 408 | }, | 632 | (count, false) => Some(format!("{count} other sections waiting for connection")), |
| 409 | ); | 633 | }, |
| 410 | if let Some(node) = ui.access(ui.id("show-file")) { | 634 | ]; |
| 411 | node.set_disabled(); | 635 | for (index, summary) in summaries.iter().flatten().enumerate() { |
| 412 | } | 636 | ui.leaf( |
| 413 | false | 637 | ("summary", index), |
| 638 | Spec { | ||
| 639 | size: [fill(), px(line)], | ||
| 640 | text: Some(summary), | ||
| 641 | color: Some(theme.text_dim), | ||
| 642 | ..Spec::default() | ||
| 643 | }, | ||
| 644 | ); | ||
| 645 | } | ||
| 646 | ui.close(); | ||
| 647 | |||
| 648 | if let Some(note) = update_note(facts.update) { | ||
| 649 | rule(ui, "update-rule"); | ||
| 650 | ui.open( | ||
| 651 | "update", | ||
| 652 | Spec { | ||
| 653 | size: [fill(), children()], | ||
| 654 | gap: 8.0, | ||
| 655 | ..Spec::default() | ||
| 656 | }, | ||
| 657 | ); | ||
| 658 | ui.leaf( | ||
| 659 | "note", | ||
| 660 | Spec { | ||
| 661 | size: [fill(), px(theme.font_size * 2.0)], | ||
| 662 | text: Some(&format!("Snowbound {note}")), | ||
| 663 | overflow: Overflow::Ellipsis, | ||
| 664 | ..Spec::default() | ||
| 665 | }, | ||
| 666 | ); | ||
| 667 | picked.build_folder = ui::button(ui, "build-folder", "Build Folder").clicked; | ||
| 668 | if matches!(facts.update, update::Status::Ready(..)) { | ||
| 669 | picked.restart = ui::button(ui, "restart", "Restart to Update").clicked; | ||
| 670 | } | ||
| 671 | ui.close(); | ||
| 672 | } | ||
| 673 | |||
| 674 | ui.open( | ||
| 675 | "controls", | ||
| 676 | Spec { | ||
| 677 | size: [fill(), children()], | ||
| 678 | pad: [0.0, 4.0], | ||
| 679 | gap: 8.0, | ||
| 680 | ..Spec::default() | ||
| 681 | }, | ||
| 682 | ); | ||
| 683 | picked.offline = ui::check_box(ui, "offline", "Work offline", facts.offline).clicked; | ||
| 684 | ui.leaf( | ||
| 685 | "space", | ||
| 686 | Spec { | ||
| 687 | size: [fill(), px(1.0)], | ||
| 688 | ..Spec::default() | ||
| 689 | }, | ||
| 690 | ); | ||
| 691 | picked.show_file = match facts.local { | ||
| 692 | true => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, | ||
| 693 | false => { | ||
| 694 | ui.leaf( | ||
| 695 | "show-file", | ||
| 696 | Spec { | ||
| 697 | size: [fit(), px(theme.font_size * 2.0)], | ||
| 698 | text: Some(platform::SHOW_FILE), | ||
| 699 | color: Some(theme.text_dim), | ||
| 700 | fill: Some(theme.chip), | ||
| 701 | radius: 4.0, | ||
| 702 | pad: [theme.font_size * 0.75, 0.0], | ||
| 703 | center: true, | ||
| 704 | role: Some(accesskit::Role::Button), | ||
| 705 | ..Spec::default() | ||
| 706 | }, | ||
| 707 | ); | ||
| 708 | if let Some(node) = ui.access(ui.id("show-file")) { | ||
| 709 | node.set_disabled(); | ||
| 414 | } | 710 | } |
| 711 | false | ||
| 712 | } | ||
| 713 | }; | ||
| 714 | picked.sync_now = ui::button(ui, "sync-now", "Sync Now").clicked; | ||
| 715 | ui.close(); | ||
| 716 | picked | ||
| 717 | } | ||
| 718 | |||
| 719 | /// Opens the popup's box at `anchor`. | ||
| 720 | fn open(ui: &mut Ui, anchor: Anchor) { | ||
| 721 | let theme = ui.theme.clone(); | ||
| 722 | ui.open_as( | ||
| 723 | id(), | ||
| 724 | Spec { | ||
| 725 | axis: Axis::Y, | ||
| 726 | size: [px(WIDTH), children()], | ||
| 727 | fill: Some(theme.popup), | ||
| 728 | border: Some(theme.chip), | ||
| 729 | shadow: Some(theme.shadow), | ||
| 730 | radius: 8.0, | ||
| 731 | pad: [PAD, 12.0], | ||
| 732 | gap: 10.0, | ||
| 733 | anchor: Some(anchor), | ||
| 734 | role: Some(accesskit::Role::Dialog), | ||
| 735 | ..Spec::default() | ||
| 736 | }, | ||
| 737 | ); | ||
| 738 | if let Some(node) = ui.access(id()) { | ||
| 739 | node.set_label(TITLE); | ||
| 740 | } | ||
| 741 | } | ||
| 742 | |||
| 743 | /// `library`'s status in a line, as its context menu heads with it. | ||
| 744 | pub(crate) fn line(library: &Library, session: &Session) -> (String, &'static [&'static str]) { | ||
| 745 | let sections = sections(library, session); | ||
| 746 | let sync = overall(&sections); | ||
| 747 | let offline = library::offline(); | ||
| 748 | let (headline, icon) = describe(&sync, offline, false); | ||
| 749 | let done = sections | ||
| 750 | .iter() | ||
| 751 | .filter(|(_, sync)| sync.state() == SyncState::UpToDate) | ||
| 752 | .count(); | ||
| 753 | let text = match sync.state() { | ||
| 754 | _ if offline && sync.queued > 0 => format!("Offline · {} waiting", changes(sync.queued)), | ||
| 755 | SyncState::Syncing if !offline && sections.len() > 1 => { | ||
| 756 | format!("Syncing {done} of {} sections…", sections.len()) | ||
| 757 | } | ||
| 758 | SyncState::UpToDate if !offline => match sync.synced { | ||
| 759 | Some(synced) => format!("Up to date · {}", ago(synced)), | ||
| 760 | None => headline.to_owned(), | ||
| 761 | }, | ||
| 762 | _ if sync.queued > 0 => format!("{headline} · {} waiting", changes(sync.queued)), | ||
| 763 | _ => headline.to_owned(), | ||
| 764 | }; | ||
| 765 | (text, icon) | ||
| 766 | } | ||
| 767 | |||
| 768 | impl State { | ||
| 769 | /// Opens the sync status popup on `library`, at `at` in the window. | ||
| 770 | pub(crate) fn show_sync(&mut self, library: Arc<Library>, at: [f32; 2]) { | ||
| 771 | self.sync_notebook = Some((library, at)); | ||
| 772 | self.ui.open_popup(id()); | ||
| 773 | } | ||
| 774 | |||
| 775 | /// Builds the sync status popup while it is open: on the notebook its context menu | ||
| 776 | /// opened it for, or the open section's. | ||
| 777 | pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> { | ||
| 778 | let (ui, notebooks) = (&mut self.ui, &self.notebooks); | ||
| 779 | let update = self.updates.status(); | ||
| 780 | let Some(session) = &mut self.session else { | ||
| 781 | ui.close_popup(id()); | ||
| 782 | return Ok(()); | ||
| 415 | }; | 783 | }; |
| 416 | let now = ui::button(ui, "sync-now", "Sync Now").clicked; | 784 | if !ui.popup_open(id()) { |
| 417 | ui.close(); | 785 | self.sync_notebook = None; |
| 786 | return Ok(()); | ||
| 787 | } | ||
| 788 | // Read afresh while shown, so the last sync time follows each poll. | ||
| 789 | session.sync = session.section.sync_status()?; | ||
| 790 | let (library, anchor) = match &self.sync_notebook { | ||
| 791 | Some((library, at)) => (Arc::clone(library), Anchor::Point(*at)), | ||
| 792 | None => ( | ||
| 793 | Arc::clone(&session.library), | ||
| 794 | Anchor::Below(ui.rect(button()).unwrap_or_default()), | ||
| 795 | ), | ||
| 796 | }; | ||
| 797 | let own = Arc::ptr_eq(&library, &session.library); | ||
| 798 | let offline = library::offline(); | ||
| 799 | let file = match own { | ||
| 800 | true => library.local(session.section.file()), | ||
| 801 | false => library.folder().map(Path::to_owned), | ||
| 802 | }; | ||
| 803 | let facts = Facts { | ||
| 804 | name: &library.name, | ||
| 805 | location: &library.location, | ||
| 806 | place: library.place(), | ||
| 807 | notice: library.notice.as_deref(), | ||
| 808 | sections: sections(&library, session), | ||
| 809 | conflicts: session | ||
| 810 | .conflicts | ||
| 811 | .iter() | ||
| 812 | .filter(|(_, pages)| own && !pages.is_empty()) | ||
| 813 | .filter_map(|(space, _)| { | ||
| 814 | let (_, title, _) = session.pages.iter().find(|page| page.0 == *space)?; | ||
| 815 | Some((*space, title.as_str())) | ||
| 816 | }) | ||
| 817 | .collect(), | ||
| 818 | offline, | ||
| 819 | update: &update, | ||
| 820 | local: file.is_some(), | ||
| 821 | }; | ||
| 822 | open(ui, anchor); | ||
| 823 | let picked = build(ui, &facts); | ||
| 418 | ui.close(); | 824 | ui.close(); |
| 419 | let backgrounds = notebooks | 825 | let backgrounds = notebooks |
| 420 | .iter() | 826 | .iter() |
| 421 | .filter_map(|library| library.background.as_ref()); | 827 | .filter_map(|library| library.background.as_ref()); |
| 422 | if toggled { | 828 | if picked.offline { |
| 423 | library::set_offline(!offline); | 829 | library::set_offline(!offline); |
| 424 | session.section.set_offline(!offline); | 830 | session.section.set_offline(!offline); |
| 425 | backgrounds.for_each(|background| background.set_offline(!offline)); | 831 | notebooks |
| 426 | } else if now { | 832 | .iter() |
| 427 | session.section.wake(); | 833 | .for_each(|library| library.set_offline(!offline)); |
| 834 | } else if picked.sync_now { | ||
| 835 | if own { | ||
| 836 | session.section.wake(); | ||
| 837 | } | ||
| 428 | backgrounds.for_each(|background| background.wake()); | 838 | backgrounds.for_each(|background| background.wake()); |
| 429 | } | 839 | } |
| 430 | if let Some(file) = file.filter(|_| show) { | 840 | if let Some(file) = file.filter(|_| picked.show_file) { |
| 431 | platform::show_file(&file); | 841 | platform::show_file(&file); |
| 432 | } | 842 | } |
| 433 | if let Some(location) = sign_in { | 843 | if picked.sign_in { |
| 844 | let location = library.location.clone(); | ||
| 434 | self.ui.close_popup(id()); | 845 | self.ui.close_popup(id()); |
| 435 | self.commands | 846 | self.commands |
| 436 | .push(crate::Command::OpenFromServer(Some(location))); | 847 | .push(crate::Command::OpenFromServer(Some(location))); |
| 437 | } | 848 | } |
| 849 | if let Some(space) = picked.conflict { | ||
| 850 | self.ui.close_popup(id()); | ||
| 851 | self.commands.push(crate::Command::OpenPage(space)); | ||
| 852 | } | ||
| 438 | match update { | 853 | match update { |
| 439 | update::Status::Downloading(version) | 854 | update::Status::Downloading(version) |
| 440 | | update::Status::Ready(version, _) | 855 | | update::Status::Ready(version, _) |
| 441 | | update::Status::Available(version) | 856 | | update::Status::Available(version) |
| 442 | if folder => | 857 | if picked.build_folder => |
| 443 | { | 858 | { |
| 444 | update::show_build(&version) | 859 | update::show_build(&version) |
| 445 | } | 860 | } |
| 446 | _ if restart => self.restart_to_update(), | 861 | _ if picked.restart => self.restart_to_update(), |
| 447 | _ => {} | 862 | _ => {} |
| 448 | } | 863 | } |
| 449 | Ok(()) | 864 | Ok(()) |
| 450 | } | 865 | } |
| 451 | } | 866 | } |
| 867 | |||
| 868 | /// The popup in each state, laid out with made-up statuses on a made-up clock. A quick sync | ||
| 869 | /// never shows, a longer one shows its bar and fills it before reading up to date. Where | ||
| 870 | /// wgpu paints, `SNOWBOUND_SYNC_RENDER` names a directory for PNGs of each state in both | ||
| 871 | /// appearances, and `frames-light`/`frames-dark` of a sync running, for a GIF. | ||
| 872 | #[cfg(test)] | ||
| 873 | mod tests { | ||
| 874 | use super::*; | ||
| 875 | use std::{io, time::Instant}; | ||
| 876 | use winit::window::Theme as Appearance; | ||
| 877 | |||
| 878 | const SIZE: [f32; 2] = [380.0, 640.0]; | ||
| 879 | const SCALE: f32 = 2.0; | ||
| 880 | const NAMES: [&str; 5] = [ | ||
| 881 | "Recipes", | ||
| 882 | "Travel", | ||
| 883 | "Work/Meetings", | ||
| 884 | "Work/Projects", | ||
| 885 | "Journal", | ||
| 886 | ]; | ||
| 887 | |||
| 888 | fn status(synced: bool, queued: u64, error: Option<(io::ErrorKind, &str)>) -> SyncStatus { | ||
| 889 | SyncStatus { | ||
| 890 | synced: synced.then(|| filetime() - 3 * 600_000_000), | ||
| 891 | error: error.map(|(kind, text)| io::Error::new(kind, text)), | ||
| 892 | queued, | ||
| 893 | } | ||
| 894 | } | ||
| 895 | |||
| 896 | fn sections(each: impl Fn(usize) -> SyncStatus) -> Vec<(String, SyncStatus)> { | ||
| 897 | NAMES | ||
| 898 | .iter() | ||
| 899 | .enumerate() | ||
| 900 | .map(|(index, name)| (format!("{name}.one"), each(index))) | ||
| 901 | .collect() | ||
| 902 | } | ||
| 903 | |||
| 904 | static IDLE: update::Status = update::Status::Idle; | ||
| 905 | |||
| 906 | fn facts(sections: Vec<(String, SyncStatus)>) -> Facts<'static> { | ||
| 907 | Facts { | ||
| 908 | name: "OneNote", | ||
| 909 | location: "/Volumes/clover/Documents/OneNote", | ||
| 910 | place: ["zenith", "clover", "Documents"] | ||
| 911 | .map(str::to_owned) | ||
| 912 | .to_vec(), | ||
| 913 | notice: None, | ||
| 914 | sections, | ||
| 915 | conflicts: Vec::new(), | ||
| 916 | offline: false, | ||
| 917 | update: &IDLE, | ||
| 918 | local: true, | ||
| 919 | } | ||
| 920 | } | ||
| 921 | |||
| 922 | /// One frame at `now`: the toolbar's button at the top right and the popup open below it. | ||
| 923 | fn frame(ui: &mut Ui, now: Instant, facts: &Facts) { | ||
| 924 | ui.begin(SIZE, SCALE, now); | ||
| 925 | ui.leaf( | ||
| 926 | "toolbar", | ||
| 927 | Spec { | ||
| 928 | size: [fill(), px(TOOL + 12.0)], | ||
| 929 | fill: Some(ui.theme.strip), | ||
| 930 | ..Spec::default() | ||
| 931 | }, | ||
| 932 | ); | ||
| 933 | ui.open_as( | ||
| 934 | button(), | ||
| 935 | Spec { | ||
| 936 | flags: Flags::FLOAT, | ||
| 937 | size: [px(TOOL), px(TOOL)], | ||
| 938 | position: [SIZE[0] - TOOL - 10.0, 6.0], | ||
| 939 | icon: Some(art::SYNC_DONE), | ||
| 940 | ..Spec::default() | ||
| 941 | }, | ||
| 942 | ); | ||
| 943 | ui.close(); | ||
| 944 | if !ui.popup_open(id()) { | ||
| 945 | ui.open_popup(id()); | ||
| 946 | } | ||
| 947 | open(ui, Anchor::Below(ui.rect(button()).unwrap_or_default())); | ||
| 948 | build(ui, facts); | ||
| 949 | ui.close(); | ||
| 950 | ui.end(); | ||
| 951 | } | ||
| 952 | |||
| 953 | fn ui(appearance: Appearance) -> Ui { | ||
| 954 | let theme = crate::theme(appearance, false, false); | ||
| 955 | let mut ui = Ui::new(theme, Duration::from_millis(500)); | ||
| 956 | ui.icon_palette = ui.theme.icon_palette( | ||
| 957 | ui.theme.section(crate::section_color(None)).accent, | ||
| 958 | [1.0; 4], | ||
| 959 | ); | ||
| 960 | ui | ||
| 961 | } | ||
| 962 | |||
| 963 | #[cfg(all(feature = "wgpu", not(windows)))] | ||
| 964 | struct Gpu { | ||
| 965 | renderer: draw::Renderer, | ||
| 966 | device: wgpu::Device, | ||
| 967 | queue: wgpu::Queue, | ||
| 968 | } | ||
| 969 | |||
| 970 | #[cfg(all(feature = "wgpu", not(windows)))] | ||
| 971 | impl Gpu { | ||
| 972 | fn new() -> Self { | ||
| 973 | let instance = | ||
| 974 | wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | ||
| 975 | let adapter = | ||
| 976 | pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); | ||
| 977 | let (device, queue) = | ||
| 978 | pollster::block_on(adapter.request_device(&Default::default())).unwrap(); | ||
| 979 | let renderer = draw::Renderer::new( | ||
| 980 | device.clone(), | ||
| 981 | queue.clone(), | ||
| 982 | wgpu::TextureFormat::Rgba8UnormSrgb, | ||
| 983 | ); | ||
| 984 | Self { | ||
| 985 | renderer, | ||
| 986 | device, | ||
| 987 | queue, | ||
| 988 | } | ||
| 989 | } | ||
| 990 | |||
| 991 | /// Writes the frame `ui` laid out to `path`, cropped to the popup and its shadow, or | ||
| 992 | /// to `bottom` where given so a GIF's frames are one size. | ||
| 993 | fn save(&mut self, ui: &Ui, path: &Path, bottom: Option<f32>) { | ||
| 994 | let size = SIZE.map(|side| (side * SCALE) as u32); | ||
| 995 | let interface = ui.layers(); | ||
| 996 | let layers: Vec<_> = interface | ||
| 997 | .iter() | ||
| 998 | .filter_map(|layer| match layer { | ||
| 999 | ui::Layer::Primitives(primitives) => Some(primitives.layer(SCALE)), | ||
| 1000 | ui::Layer::Custom { .. } => None, | ||
| 1001 | }) | ||
| 1002 | .collect(); | ||
| 1003 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | ||
| 1004 | label: Some("Sync popup"), | ||
| 1005 | size: wgpu::Extent3d { | ||
| 1006 | width: size[0], | ||
| 1007 | height: size[1], | ||
| 1008 | depth_or_array_layers: 1, | ||
| 1009 | }, | ||
| 1010 | mip_level_count: 1, | ||
| 1011 | sample_count: 1, | ||
| 1012 | dimension: wgpu::TextureDimension::D2, | ||
| 1013 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | ||
| 1014 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, | ||
| 1015 | view_formats: &[], | ||
| 1016 | }); | ||
| 1017 | self.renderer | ||
| 1018 | .draw( | ||
| 1019 | &texture.create_view(&Default::default()), | ||
| 1020 | size, | ||
| 1021 | ui.theme.base, | ||
| 1022 | &layers, | ||
| 1023 | ) | ||
| 1024 | .unwrap(); | ||
| 1025 | let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); | ||
| 1026 | let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { | ||
| 1027 | label: Some("Sync popup readback"), | ||
| 1028 | size: u64::from(row) * u64::from(size[1]), | ||
| 1029 | usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, | ||
| 1030 | mapped_at_creation: false, | ||
| 1031 | }); | ||
| 1032 | let mut encoder = self.device.create_command_encoder(&Default::default()); | ||
| 1033 | encoder.copy_texture_to_buffer( | ||
| 1034 | texture.as_image_copy(), | ||
| 1035 | wgpu::TexelCopyBufferInfo { | ||
| 1036 | buffer: &buffer, | ||
| 1037 | layout: wgpu::TexelCopyBufferLayout { | ||
| 1038 | offset: 0, | ||
| 1039 | bytes_per_row: Some(row), | ||
| 1040 | rows_per_image: Some(size[1]), | ||
| 1041 | }, | ||
| 1042 | }, | ||
| 1043 | texture.size(), | ||
| 1044 | ); | ||
| 1045 | self.queue.submit([encoder.finish()]); | ||
| 1046 | buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); | ||
| 1047 | self.device | ||
| 1048 | .poll(wgpu::PollType::Wait { | ||
| 1049 | submission_index: None, | ||
| 1050 | timeout: Some(Duration::from_secs(5)), | ||
| 1051 | }) | ||
| 1052 | .unwrap(); | ||
| 1053 | let mapped = buffer.get_mapped_range(..).unwrap(); | ||
| 1054 | let bottom = bottom | ||
| 1055 | .unwrap_or(ui.rect(id()).unwrap()[3] + 24.0) | ||
| 1056 | .min(SIZE[1]); | ||
| 1057 | let height = (bottom * SCALE) as u32; | ||
| 1058 | let pixels: Vec<u8> = mapped | ||
| 1059 | .chunks(row as usize) | ||
| 1060 | .take(height as usize) | ||
| 1061 | .flat_map(|line| &line[..size[0] as usize * 4]) | ||
| 1062 | .copied() | ||
| 1063 | .collect(); | ||
| 1064 | let file = std::fs::File::create(path).unwrap(); | ||
| 1065 | let mut encoder = png::Encoder::new(file, size[0], height); | ||
| 1066 | encoder.set_color(png::ColorType::Rgba); | ||
| 1067 | encoder.set_depth(png::BitDepth::Eight); | ||
| 1068 | encoder | ||
| 1069 | .write_header() | ||
| 1070 | .unwrap() | ||
| 1071 | .write_image_data(&pixels) | ||
| 1072 | .unwrap(); | ||
| 1073 | } | ||
| 1074 | } | ||
| 1075 | |||
| 1076 | /// Lays out `facts` until the popup has opened and settled, from `now`. | ||
| 1077 | fn settle(ui: &mut Ui, now: &mut Instant, facts: &Facts) { | ||
| 1078 | for _ in 0..90 { | ||
| 1079 | *now += Duration::from_millis(16); | ||
| 1080 | frame(ui, *now, facts); | ||
| 1081 | } | ||
| 1082 | } | ||
| 1083 | |||
| 1084 | fn bar(ui: &Ui) -> bool { | ||
| 1085 | ui.rect(id().child("status").child("bar")).is_some() | ||
| 1086 | } | ||
| 1087 | |||
| 1088 | #[test] | ||
| 1089 | fn a_quick_sync_never_shows_and_a_long_one_fills_before_up_to_date() { | ||
| 1090 | let mut ui = ui(Appearance::Light); | ||
| 1091 | let mut now = Instant::now(); | ||
| 1092 | let done = facts(sections(|_| status(true, 0, None))); | ||
| 1093 | settle(&mut ui, &mut now, &done); | ||
| 1094 | let busy = facts(sections(|index| status(true, u64::from(index == 1), None))); | ||
| 1095 | for _ in 0..12 { | ||
| 1096 | now += Duration::from_millis(16); | ||
| 1097 | frame(&mut ui, now, &busy); | ||
| 1098 | assert!(!bar(&ui), "a sync under {SHOW_AFTER:?} shows"); | ||
| 1099 | } | ||
| 1100 | settle(&mut ui, &mut now, &done); | ||
| 1101 | assert!(!bar(&ui)); | ||
| 1102 | settle(&mut ui, &mut now, &busy); | ||
| 1103 | assert!(bar(&ui), "a running sync shows its bar"); | ||
| 1104 | now += Duration::from_millis(16); | ||
| 1105 | frame(&mut ui, now, &done); | ||
| 1106 | assert!(bar(&ui), "a finished sync's bar stays a moment, full"); | ||
| 1107 | now += LINGER + Duration::from_millis(50); | ||
| 1108 | frame(&mut ui, now, &done); | ||
| 1109 | frame(&mut ui, now, &done); | ||
| 1110 | assert!(!bar(&ui)); | ||
| 1111 | } | ||
| 1112 | |||
| 1113 | #[cfg(all(feature = "wgpu", not(windows)))] | ||
| 1114 | #[test] | ||
| 1115 | fn popup_renders_each_state() { | ||
| 1116 | let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); | ||
| 1117 | let mut gpu = output.as_ref().map(|output| { | ||
| 1118 | std::fs::create_dir_all(output).unwrap(); | ||
| 1119 | Gpu::new() | ||
| 1120 | }); | ||
| 1121 | type Made = fn() -> Facts<'static>; | ||
| 1122 | let states: Vec<(&str, Made)> = vec![ | ||
| 1123 | ("up-to-date", || facts(sections(|_| status(true, 0, None)))), | ||
| 1124 | ("syncing", || { | ||
| 1125 | facts(sections(|index| match index { | ||
| 1126 | 0 | 1 => status(true, 0, None), | ||
| 1127 | 2 => status(true, 3, None), | ||
| 1128 | _ => status(false, 0, None), | ||
| 1129 | })) | ||
| 1130 | }), | ||
| 1131 | ("offline", || Facts { | ||
| 1132 | offline: true, | ||
| 1133 | ..facts(sections(|index| status(true, [0, 4, 0, 1, 0][index], None))) | ||
| 1134 | }), | ||
| 1135 | ("not-connected", || { | ||
| 1136 | facts(sections(|index| { | ||
| 1137 | status( | ||
| 1138 | true, | ||
| 1139 | [0, 2, 0, 0, 0][index], | ||
| 1140 | Some((io::ErrorKind::TimedOut, "The server didn’t answer")), | ||
| 1141 | ) | ||
| 1142 | })) | ||
| 1143 | }), | ||
| 1144 | ("conflict", || Facts { | ||
| 1145 | conflicts: vec![(ExGuid::default(), "Packing list")], | ||
| 1146 | ..facts(sections(|_| status(true, 0, None))) | ||
| 1147 | }), | ||
| 1148 | ("error", || { | ||
| 1149 | facts(sections(|index| match index { | ||
| 1150 | 3 => status( | ||
| 1151 | true, | ||
| 1152 | 1, | ||
| 1153 | Some((io::ErrorKind::Other, "The section file ended early.")), | ||
| 1154 | ), | ||
| 1155 | _ => status(true, 0, None), | ||
| 1156 | })) | ||
| 1157 | }), | ||
| 1158 | ("in-use", || { | ||
| 1159 | facts(sections(|index| match index { | ||
| 1160 | 2 => status(true, 2, Some((io::ErrorKind::ResourceBusy, "In use"))), | ||
| 1161 | _ => status(true, 0, None), | ||
| 1162 | })) | ||
| 1163 | }), | ||
| 1164 | ("read-only", || { | ||
| 1165 | facts(sections(|index| { | ||
| 1166 | let denied = (io::ErrorKind::PermissionDenied, "Read-only"); | ||
| 1167 | status(true, u64::from(index == 0), (index == 0).then_some(denied)) | ||
| 1168 | })) | ||
| 1169 | }), | ||
| 1170 | ("system-connection", || Facts { | ||
| 1171 | notice: Some("the Keychain has no password for clover on zenith"), | ||
| 1172 | place: vec!["clover".into(), "Documents".into()], | ||
| 1173 | ..facts(sections(|_| status(true, 0, None))) | ||
| 1174 | }), | ||
| 1175 | ("icloud", || Facts { | ||
| 1176 | name: "Personal", | ||
| 1177 | location: "/Users/clover/Library/Mobile Documents/com~apple~CloudDocs/Notes/Personal", | ||
| 1178 | place: vec!["iCloud Drive".into(), "Notes".into()], | ||
| 1179 | ..facts(sections(|_| status(true, 0, None))) | ||
| 1180 | }), | ||
| 1181 | ]; | ||
| 1182 | for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] { | ||
| 1183 | for (name, facts) in &states { | ||
| 1184 | let mut ui = ui(appearance); | ||
| 1185 | let mut now = Instant::now(); | ||
| 1186 | let facts = facts(); | ||
| 1187 | settle(&mut ui, &mut now, &facts); | ||
| 1188 | assert!(ui.rect(id()).is_some(), "{name}"); | ||
| 1189 | if let (Some(output), Some(gpu)) = (&output, &mut gpu) { | ||
| 1190 | gpu.save(&ui, &output.join(format!("{name}-{theme}.png")), None); | ||
| 1191 | } | ||
| 1192 | } | ||
| 1193 | // Changes made offline sending once the share is back, a section at a time, at | ||
| 1194 | // 30 frames a second; a quick one first never shows. | ||
| 1195 | let Some((output, gpu)) = output.as_ref().zip(gpu.as_mut()) else { | ||
| 1196 | continue; | ||
| 1197 | }; | ||
| 1198 | let frames = output.join(format!("frames-{theme}")); | ||
| 1199 | std::fs::create_dir_all(&frames).unwrap(); | ||
| 1200 | let mut ui = ui(appearance); | ||
| 1201 | let mut now = Instant::now(); | ||
| 1202 | settle( | ||
| 1203 | &mut ui, | ||
| 1204 | &mut now, | ||
| 1205 | &facts(sections(|_| status(true, 0, None))), | ||
| 1206 | ); | ||
| 1207 | let mut shot = 0; | ||
| 1208 | for tick in 0..300 { | ||
| 1209 | let seconds = tick as f32 / 60.0; | ||
| 1210 | let quick = (0.2..0.45).contains(&seconds); | ||
| 1211 | // Each section's waiting changes, and when they're sent. | ||
| 1212 | let sending = [(0, 0.0), (3, 1.4), (1, 2.6), (0, 0.0), (2, 3.3)]; | ||
| 1213 | let facts = facts(sections(|index| { | ||
| 1214 | let (queued, sent) = sending[index]; | ||
| 1215 | let queued = match seconds { | ||
| 1216 | _ if index == 0 && quick => 1, | ||
| 1217 | seconds if (0.8..sent).contains(&seconds) => queued, | ||
| 1218 | _ => 0, | ||
| 1219 | }; | ||
| 1220 | status(true, queued, None) | ||
| 1221 | })); | ||
| 1222 | now += Duration::from_micros(16_667); | ||
| 1223 | frame(&mut ui, now, &facts); | ||
| 1224 | if tick % 2 == 0 { | ||
| 1225 | gpu.save(&ui, &frames.join(format!("{shot:03}.png")), Some(360.0)); | ||
| 1226 | shot += 1; | ||
| 1227 | } | ||
| 1228 | } | ||
| 1229 | } | ||
| 1230 | } | ||
| 1231 | } |
crates/snowbound/src/themes.rs created+936| ... | @@ -0,0 +1,936 @@ | ||
| 1 | //! Style themes in the app (`resources/styles.md`): the page wears its own theme, else its | ||
| 2 | //! section's, else its notebook's, as its paragraph style objects; the Styles gallery | ||
| 3 | //! applies a style in the page's theme; the Themes dialog picks a theme for the page, the | ||
| 4 | //! section or the notebook, and edits a notebook's own themes with a live preview. | ||
| 5 | |||
| 6 | use crate::{Library, State, filetime}; | ||
| 7 | use accesskit::Role; | ||
| 8 | use notebook::sidecar::themes::{ | ||
| 9 | self as stored, Assignment, STYLES, Theme, ThemeStyle, Themes, built_in, | ||
| 10 | }; | ||
| 11 | use onestore::page::Definition; | ||
| 12 | use std::{error::Error, sync::Arc}; | ||
| 13 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, popup::Item, px}; | ||
| 14 | |||
| 15 | /// What a theme is chosen for, from where the page is. | ||
| 16 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| 17 | pub enum Scope { | ||
| 18 | Page, | ||
| 19 | Section, | ||
| 20 | Notebook, | ||
| 21 | } | ||
| 22 | |||
| 23 | impl Scope { | ||
| 24 | fn name(self) -> &'static str { | ||
| 25 | match self { | ||
| 26 | Self::Page => "This Page", | ||
| 27 | Self::Section => "This Section", | ||
| 28 | Self::Notebook => "This Notebook", | ||
| 29 | } | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | /// The Themes dialog while it is open. | ||
| 34 | pub struct Dialog { | ||
| 35 | scope: Scope, | ||
| 36 | library: Arc<Library>, | ||
| 37 | target: stored::Scope, | ||
| 38 | /// The built-in themes, then the notebook's own as the dialog edits them. | ||
| 39 | themes: Vec<Theme>, | ||
| 40 | /// The notebook's own themes changed here, by id. | ||
| 41 | changed: Vec<String>, | ||
| 42 | /// The notebook's own themes deleted here. | ||
| 43 | deleted: Vec<Theme>, | ||
| 44 | selected: usize, | ||
| 45 | /// The style the controls edit, by its place in the gallery. | ||
| 46 | style: usize, | ||
| 47 | } | ||
| 48 | |||
| 49 | fn id() -> Id { | ||
| 50 | Id::ROOT.child("themes") | ||
| 51 | } | ||
| 52 | |||
| 53 | fn popup(name: &str) -> Id { | ||
| 54 | id().child(name) | ||
| 55 | } | ||
| 56 | |||
| 57 | /// Spacing the dialog offers above and below a style, in points. | ||
| 58 | const SPACING: [f32; 9] = [0.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 18.0]; | ||
| 59 | |||
| 60 | /// A style's colour as `#rrggbb` from a COLORREF, and back. | ||
| 61 | fn hex(colorref: u32) -> String { | ||
| 62 | let [red, green, blue, _] = colorref.to_le_bytes(); | ||
| 63 | format!("#{red:02X}{green:02X}{blue:02X}") | ||
| 64 | } | ||
| 65 | |||
| 66 | fn colorref(hex: &str) -> Option<u32> { | ||
| 67 | let rgb = u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()?; | ||
| 68 | Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16)) | ||
| 69 | } | ||
| 70 | |||
| 71 | /// A line of `text` in `style` as a page shows it, scaled down to fit a menu row up to | ||
| 72 | /// `largest` pixels, its colour moved onto the popup's paper so it reads in either scheme. | ||
| 73 | pub(crate) fn preview(ui: &mut Ui, part: &str, text: &str, style: &ThemeStyle, largest: f32) { | ||
| 74 | let theme = ui.theme.clone(); | ||
| 75 | let paper = canvas::gpu::Paper { | ||
| 76 | color: theme.popup, | ||
| 77 | ink: theme.text, | ||
| 78 | }; | ||
| 79 | let color = style | ||
| 80 | .color | ||
| 81 | .as_deref() | ||
| 82 | .and_then(colorref) | ||
| 83 | .map_or(theme.text, |color| paper.tint(canvas::gpu::colorref(color))); | ||
| 84 | ui.leaf( | ||
| 85 | part, | ||
| 86 | Spec { | ||
| 87 | flags: Flags::CLIP, | ||
| 88 | size: [fill(), fill()], | ||
| 89 | text: Some(text), | ||
| 90 | font: Some(&style.font), | ||
| 91 | font_size: Some((style.size * 1.2).clamp(10.0, largest)), | ||
| 92 | bold: style.bold, | ||
| 93 | italic: style.italic, | ||
| 94 | color: Some(color), | ||
| 95 | ..Spec::default() | ||
| 96 | }, | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | |||
| 100 | impl State { | ||
| 101 | /// Where a theme for `scope` goes from the open page: its notebook and the scope's key. | ||
| 102 | pub(crate) fn theme_target(&self, scope: Scope) -> Option<(Arc<Library>, stored::Scope)> { | ||
| 103 | let session = self.session.as_ref()?; | ||
| 104 | let library = &session.library; | ||
| 105 | library.catalog()?; | ||
| 106 | let target = match scope { | ||
| 107 | Scope::Page => stored::Scope::page(self.view.editor.identity()?), | ||
| 108 | Scope::Section => { | ||
| 109 | stored::Scope::section(library.section_identity(&session.tabs[session.tab].path)?) | ||
| 110 | } | ||
| 111 | Scope::Notebook => stored::Scope::Notebook, | ||
| 112 | }; | ||
| 113 | Some((Arc::clone(library), target)) | ||
| 114 | } | ||
| 115 | |||
| 116 | /// The theme the open page wears, if any scope names one. | ||
| 117 | pub(crate) fn page_theme(&self) -> Option<Theme> { | ||
| 118 | let session = self.session.as_ref()?; | ||
| 119 | let section = session | ||
| 120 | .library | ||
| 121 | .section_identity(&session.tabs[session.tab].path); | ||
| 122 | session | ||
| 123 | .library | ||
| 124 | .themes() | ||
| 125 | .effective(section, self.view.editor.identity()) | ||
| 126 | } | ||
| 127 | |||
| 128 | /// The styles the gallery offers: the page's theme's, else OneNote 2010's. | ||
| 129 | pub(crate) fn gallery_sheet(&self) -> Theme { | ||
| 130 | self.page_theme() | ||
| 131 | .unwrap_or_else(|| built_in().swap_remove(0)) | ||
| 132 | } | ||
| 133 | |||
| 134 | /// Stored style `name` as the gallery applies it on the open page. | ||
| 135 | pub(crate) fn gallery_style(&self, name: &str) -> Definition { | ||
| 136 | let theme = self.gallery_sheet(); | ||
| 137 | stored::definition(name, &theme.styles[name]) | ||
| 138 | } | ||
| 139 | |||
| 140 | /// Dresses the open page in its theme: new text and Enter take the theme's styles, and | ||
| 141 | /// style objects the theme gives otherwise are restyled, as one edit. A page with no theme | ||
| 142 | /// keeps what it holds. | ||
| 143 | pub(crate) fn wear_theme(&mut self) -> Result<(), Box<dyn Error>> { | ||
| 144 | let theme = self.page_theme(); | ||
| 145 | let sheet = theme.as_ref().map(Theme::sheet).unwrap_or_default(); | ||
| 146 | self.view.editor.styles = sheet.clone(); | ||
| 147 | let Some(session) = &self.session else { | ||
| 148 | return Ok(()); | ||
| 149 | }; | ||
| 150 | if theme.is_none() || session.read_only() { | ||
| 151 | return Ok(()); | ||
| 152 | } | ||
| 153 | let page = session.section.page(session.space)?; | ||
| 154 | let ops = onestore::op::restyle(&page, &sheet)?; | ||
| 155 | if ops.is_empty() { | ||
| 156 | return Ok(()); | ||
| 157 | } | ||
| 158 | self.persist()?; | ||
| 159 | let Some(session) = &self.session else { | ||
| 160 | return Ok(()); | ||
| 161 | }; | ||
| 162 | let space = session.space; | ||
| 163 | session.section.apply( | ||
| 164 | &self.author, | ||
| 165 | onestore::op::Edit { | ||
| 166 | at: filetime(), | ||
| 167 | ops: ops | ||
| 168 | .into_iter() | ||
| 169 | .map(|op| onestore::op::Op::Page { space, op }) | ||
| 170 | .collect(), | ||
| 171 | }, | ||
| 172 | )?; | ||
| 173 | self.edited(vec![space]); | ||
| 174 | self.refresh() | ||
| 175 | } | ||
| 176 | |||
| 177 | /// Opens the Themes dialog choosing a theme for `scope` of the open page. | ||
| 178 | pub(crate) fn open_themes(&mut self, scope: Scope) { | ||
| 179 | let Some((library, target)) = self.theme_target(scope) else { | ||
| 180 | return; | ||
| 181 | }; | ||
| 182 | self.show_themes(scope, library, target); | ||
| 183 | } | ||
| 184 | |||
| 185 | /// Opens the Themes dialog choosing a theme for `target`, which `scope` names. | ||
| 186 | pub(crate) fn show_themes( | ||
| 187 | &mut self, | ||
| 188 | scope: Scope, | ||
| 189 | library: Arc<Library>, | ||
| 190 | target: stored::Scope, | ||
| 191 | ) { | ||
| 192 | let themes = library.themes(); | ||
| 193 | let assigned = themes.assigned(&target).map(|theme| theme.id); | ||
| 194 | let all = themes.all(); | ||
| 195 | let selected = assigned | ||
| 196 | .and_then(|id| all.iter().position(|theme| theme.id == id)) | ||
| 197 | .unwrap_or(0); | ||
| 198 | self.themes = Some(Dialog { | ||
| 199 | scope, | ||
| 200 | library, | ||
| 201 | target, | ||
| 202 | themes: all, | ||
| 203 | changed: Vec::new(), | ||
| 204 | deleted: Vec::new(), | ||
| 205 | selected, | ||
| 206 | style: 0, | ||
| 207 | }); | ||
| 208 | self.ui.open_popup(id()); | ||
| 209 | } | ||
| 210 | |||
| 211 | /// Builds the Themes dialog while it is open. Apply keeps the edits and gives the theme | ||
| 212 | /// to the dialog's scope; No Theme takes the scope's own away; Cancel, Escape or a press | ||
| 213 | /// outside leave everything as it was. | ||
| 214 | pub(crate) fn themes_dialog(&mut self) { | ||
| 215 | let Some(dialog) = &mut self.themes else { | ||
| 216 | return; | ||
| 217 | }; | ||
| 218 | let ui = &mut self.ui; | ||
| 219 | if !ui.popup_open(id()) { | ||
| 220 | self.themes = None; | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | let theme = ui.theme.clone(); | ||
| 224 | let row = theme.font_size * 2.0; | ||
| 225 | ui.open_as( | ||
| 226 | id(), | ||
| 227 | Spec { | ||
| 228 | axis: Axis::Y, | ||
| 229 | size: [px(760.0), children()], | ||
| 230 | fill: Some(theme.popup), | ||
| 231 | border: Some(theme.chip), | ||
| 232 | shadow: Some(theme.shadow), | ||
| 233 | radius: 8.0, | ||
| 234 | pad: [10.0, 10.0], | ||
| 235 | gap: 8.0, | ||
| 236 | anchor: Some(Anchor::Dialog), | ||
| 237 | role: Some(Role::Dialog), | ||
| 238 | ..Spec::default() | ||
| 239 | }, | ||
| 240 | ); | ||
| 241 | if let Some(node) = ui.access(id()) { | ||
| 242 | node.set_label("Themes"); | ||
| 243 | } | ||
| 244 | let title = format!("Theme for {}", dialog.scope.name()); | ||
| 245 | ui.leaf( | ||
| 246 | "title", | ||
| 247 | Spec { | ||
| 248 | size: [fill(), px(row)], | ||
| 249 | text: Some(&title), | ||
| 250 | bold: true, | ||
| 251 | role: Some(Role::Heading), | ||
| 252 | ..Spec::default() | ||
| 253 | }, | ||
| 254 | ); | ||
| 255 | ui.open( | ||
| 256 | "body", | ||
| 257 | Spec { | ||
| 258 | size: [fill(), px(420.0)], | ||
| 259 | gap: 10.0, | ||
| 260 | ..Spec::default() | ||
| 261 | }, | ||
| 262 | ); | ||
| 263 | // The themes, built-in first. | ||
| 264 | ui.open( | ||
| 265 | "list", | ||
| 266 | Spec { | ||
| 267 | axis: Axis::Y, | ||
| 268 | size: [px(170.0), fill()], | ||
| 269 | fill: Some(theme.panel), | ||
| 270 | radius: 5.0, | ||
| 271 | pad: [6.0, 6.0], | ||
| 272 | gap: 2.0, | ||
| 273 | role: Some(Role::ListBox), | ||
| 274 | ..Spec::default() | ||
| 275 | }, | ||
| 276 | ); | ||
| 277 | for (index, listed) in dialog.themes.iter().enumerate() { | ||
| 278 | let shown = index == dialog.selected; | ||
| 279 | let item = ui.open( | ||
| 280 | ("theme", index), | ||
| 281 | Spec { | ||
| 282 | flags: Flags::CLICKABLE, | ||
| 283 | size: [fill(), px(row)], | ||
| 284 | fill: shown.then(|| theme.hover()), | ||
| 285 | hover_fill: Some(theme.hover()), | ||
| 286 | radius: 4.0, | ||
| 287 | pad: [8.0, 0.0], | ||
| 288 | role: Some(Role::ListBoxOption), | ||
| 289 | ..Spec::default() | ||
| 290 | }, | ||
| 291 | ); | ||
| 292 | preview(ui, "name", &listed.name, &listed.styles["h2"], 15.0); | ||
| 293 | if let Some(node) = ui.access(item) { | ||
| 294 | node.set_label(listed.name.as_str()); | ||
| 295 | node.set_selected(shown); | ||
| 296 | } | ||
| 297 | ui.close(); | ||
| 298 | if ui.signal(item).clicked { | ||
| 299 | dialog.selected = index; | ||
| 300 | } | ||
| 301 | } | ||
| 302 | ui.leaf( | ||
| 303 | "space", | ||
| 304 | Spec { | ||
| 305 | size: [fill(), fill()], | ||
| 306 | ..Spec::default() | ||
| 307 | }, | ||
| 308 | ); | ||
| 309 | ui.open( | ||
| 310 | "list buttons", | ||
| 311 | Spec { | ||
| 312 | size: [fill(), children()], | ||
| 313 | gap: 6.0, | ||
| 314 | ..Spec::default() | ||
| 315 | }, | ||
| 316 | ); | ||
| 317 | let duplicate = ui::button(ui, "duplicate", "Duplicate").clicked; | ||
| 318 | let delete = | ||
| 319 | dialog.selected >= built_in().len() && ui::button(ui, "delete", "Delete").clicked; | ||
| 320 | ui.close(); | ||
| 321 | ui.close(); | ||
| 322 | // The selected theme's styles. | ||
| 323 | ui.open( | ||
| 324 | "editor", | ||
| 325 | Spec { | ||
| 326 | axis: Axis::Y, | ||
| 327 | size: [px(270.0), fill()], | ||
| 328 | gap: 6.0, | ||
| 329 | ..Spec::default() | ||
| 330 | }, | ||
| 331 | ); | ||
| 332 | let built = dialog.selected < built_in().len(); | ||
| 333 | if built { | ||
| 334 | ui.leaf( | ||
| 335 | "locked", | ||
| 336 | Spec { | ||
| 337 | size: [fill(), px(row * 1.5)], | ||
| 338 | text: Some("Changing a built-in theme edits a copy."), | ||
| 339 | color: Some(theme.text_dim), | ||
| 340 | ..Spec::default() | ||
| 341 | }, | ||
| 342 | ); | ||
| 343 | } else { | ||
| 344 | let edited = &mut dialog.themes[dialog.selected]; | ||
| 345 | let before = edited.name.clone(); | ||
| 346 | ui::text_field( | ||
| 347 | ui, | ||
| 348 | popup("name"), | ||
| 349 | &mut edited.name, | ||
| 350 | "Theme name", | ||
| 351 | Spec { | ||
| 352 | size: [fill(), px(row)], | ||
| 353 | fill: Some(theme.base), | ||
| 354 | border: Some(theme.chip), | ||
| 355 | radius: 4.0, | ||
| 356 | pad: [6.0, 0.0], | ||
| 357 | ..Spec::default() | ||
| 358 | }, | ||
| 359 | ); | ||
| 360 | if edited.name != before && !dialog.changed.contains(&edited.id) { | ||
| 361 | dialog.changed.push(edited.id.clone()); | ||
| 362 | } | ||
| 363 | } | ||
| 364 | // Each control edits the style shown; a built-in theme is copied first. | ||
| 365 | let mut change: Option<Change> = None; | ||
| 366 | let shown = dialog.themes[dialog.selected].clone(); | ||
| 367 | let (name, label) = STYLES[dialog.style]; | ||
| 368 | let style = &shown.styles[name]; | ||
| 369 | let field = |ui: &mut Ui, label: &str, build: &mut dyn FnMut(&mut Ui)| { | ||
| 370 | ui.open( | ||
| 371 | label, | ||
| 372 | Spec { | ||
| 373 | size: [fill(), px(row)], | ||
| 374 | gap: 8.0, | ||
| 375 | ..Spec::default() | ||
| 376 | }, | ||
| 377 | ); | ||
| 378 | ui.leaf( | ||
| 379 | "label", | ||
| 380 | Spec { | ||
| 381 | size: [px(64.0), px(row)], | ||
| 382 | text: Some(label), | ||
| 383 | ..Spec::default() | ||
| 384 | }, | ||
| 385 | ); | ||
| 386 | build(ui); | ||
| 387 | ui.close(); | ||
| 388 | }; | ||
| 389 | field(ui, "Style:", &mut |ui| { | ||
| 390 | let combo = ui.id("combo"); | ||
| 391 | ui::shell::combo(ui, "combo", "Style", label, 180.0, popup("styles"), true); | ||
| 392 | let items: Vec<Item> = STYLES | ||
| 393 | .iter() | ||
| 394 | .enumerate() | ||
| 395 | .map(|(at, (_, label))| Item { | ||
| 396 | text: label, | ||
| 397 | checked: Some(at == dialog.style), | ||
| 398 | current: at == dialog.style, | ||
| 399 | ..Item::default() | ||
| 400 | }) | ||
| 401 | .collect(); | ||
| 402 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); | ||
| 403 | if let Some(index) = ui::popup::menu(ui, popup("styles"), anchor, &items, None) { | ||
| 404 | dialog.style = index; | ||
| 405 | } | ||
| 406 | }); | ||
| 407 | let fonts = &self.fonts; | ||
| 408 | field(ui, "Font:", &mut |ui| { | ||
| 409 | let combo = ui.id("combo"); | ||
| 410 | ui::shell::combo( | ||
| 411 | ui, | ||
| 412 | "combo", | ||
| 413 | "Font", | ||
| 414 | &style.font, | ||
| 415 | 180.0, | ||
| 416 | popup("fonts"), | ||
| 417 | true, | ||
| 418 | ); | ||
| 419 | let mut names: Vec<&str> = crate::FONTS.to_vec(); | ||
| 420 | names.push("Georgia"); | ||
| 421 | names.extend(fonts.iter().map(String::as_str)); | ||
| 422 | let items: Vec<Item> = names | ||
| 423 | .iter() | ||
| 424 | .map(|font| Item { | ||
| 425 | text: font, | ||
| 426 | font: Some(font), | ||
| 427 | current: *font == style.font, | ||
| 428 | ..Item::default() | ||
| 429 | }) | ||
| 430 | .collect(); | ||
| 431 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); | ||
| 432 | if let Some(index) = ui::popup::menu(ui, popup("fonts"), anchor, &items, Some("Font")) { | ||
| 433 | let font = names[index].to_owned(); | ||
| 434 | change = Some(Change::Font(font)); | ||
| 435 | } | ||
| 436 | }); | ||
| 437 | field(ui, "Size:", &mut |ui| { | ||
| 438 | let combo = ui.id("combo"); | ||
| 439 | let size = format!("{}", style.size); | ||
| 440 | ui::shell::combo(ui, "combo", "Size", &size, 60.0, popup("sizes"), true); | ||
| 441 | let labels: Vec<String> = crate::SIZES.iter().map(|size| format!("{size}")).collect(); | ||
| 442 | let items: Vec<Item> = labels | ||
| 443 | .iter() | ||
| 444 | .map(|label| Item { | ||
| 445 | text: label, | ||
| 446 | checked: Some(*label == size), | ||
| 447 | current: *label == size, | ||
| 448 | ..Item::default() | ||
| 449 | }) | ||
| 450 | .collect(); | ||
| 451 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); | ||
| 452 | if let Some(index) = ui::popup::menu(ui, popup("sizes"), anchor, &items, None) { | ||
| 453 | let size = crate::SIZES[index]; | ||
| 454 | change = Some(Change::Size(size)); | ||
| 455 | } | ||
| 456 | }); | ||
| 457 | field(ui, "", &mut |ui| { | ||
| 458 | if ui::check_box(ui, "bold", "Bold", style.bold).clicked { | ||
| 459 | change = Some(Change::Bold); | ||
| 460 | } | ||
| 461 | if ui::check_box(ui, "italic", "Italic", style.italic).clicked { | ||
| 462 | change = Some(Change::Italic); | ||
| 463 | } | ||
| 464 | }); | ||
| 465 | field(ui, "Color:", &mut |ui| { | ||
| 466 | let button = ui.id("color"); | ||
| 467 | let shown = style.color.clone().unwrap_or_else(|| "Automatic".into()); | ||
| 468 | ui::shell::combo(ui, "color", "Color", &shown, 120.0, popup("colors"), true); | ||
| 469 | let swatches: Vec<_> = crate::FONT_COLORS | ||
| 470 | .iter() | ||
| 471 | .map(|&(color, name)| (canvas::gpu::colorref(color), name)) | ||
| 472 | .collect(); | ||
| 473 | let anchor = Anchor::Below(ui.rect(button).unwrap_or_default()); | ||
| 474 | if let Some(chosen) = | ||
| 475 | ui::popup::colors(ui, popup("colors"), anchor, "Automatic", &swatches, 10) | ||
| 476 | { | ||
| 477 | let color = chosen.and_then(|chosen| { | ||
| 478 | crate::FONT_COLORS | ||
| 479 | .iter() | ||
| 480 | .zip(&swatches) | ||
| 481 | .find(|(_, (swatch, _))| *swatch == chosen) | ||
| 482 | .map(|((color, _), _)| hex(*color)) | ||
| 483 | }); | ||
| 484 | change = Some(Change::Color(color)); | ||
| 485 | } | ||
| 486 | }); | ||
| 487 | for (part, label, value) in [ | ||
| 488 | ("before", "Above:", style.before), | ||
| 489 | ("after", "Below:", style.after), | ||
| 490 | ] { | ||
| 491 | field(ui, label, &mut |ui| { | ||
| 492 | let combo = ui.id("combo"); | ||
| 493 | let shown = format!("{value} pt"); | ||
| 494 | ui::shell::combo(ui, "combo", label, &shown, 80.0, popup(part), true); | ||
| 495 | let labels: Vec<String> = SPACING | ||
| 496 | .iter() | ||
| 497 | .map(|points| format!("{points} pt")) | ||
| 498 | .collect(); | ||
| 499 | let items: Vec<Item> = labels | ||
| 500 | .iter() | ||
| 501 | .map(|text| Item { | ||
| 502 | text, | ||
| 503 | checked: Some(*text == shown), | ||
| 504 | current: *text == shown, | ||
| 505 | ..Item::default() | ||
| 506 | }) | ||
| 507 | .collect(); | ||
| 508 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); | ||
| 509 | if let Some(index) = ui::popup::menu(ui, popup(part), anchor, &items, None) { | ||
| 510 | let points = SPACING[index]; | ||
| 511 | change = Some(Change::Spacing(part == "before", points)); | ||
| 512 | } | ||
| 513 | }); | ||
| 514 | } | ||
| 515 | ui.close(); | ||
| 516 | // The preview: every style as a page in the theme shows it. | ||
| 517 | ui.open( | ||
| 518 | "preview", | ||
| 519 | Spec { | ||
| 520 | axis: Axis::Y, | ||
| 521 | size: [fill(), fill()], | ||
| 522 | fill: Some(theme.popup), | ||
| 523 | border: Some(theme.chip), | ||
| 524 | radius: 5.0, | ||
| 525 | pad: [14.0, 10.0], | ||
| 526 | role: Some(Role::Group), | ||
| 527 | ..Spec::default() | ||
| 528 | }, | ||
| 529 | ); | ||
| 530 | for (at, (name, label)) in STYLES.iter().enumerate() { | ||
| 531 | let style = &shown.styles[*name]; | ||
| 532 | let height = (style.size * 1.2).clamp(10.0, 26.0) * 1.35 | ||
| 533 | + (style.before + style.after).min(12.0); | ||
| 534 | let line = ui.open( | ||
| 535 | ("line", at), | ||
| 536 | Spec { | ||
| 537 | flags: Flags::CLICKABLE, | ||
| 538 | size: [fill(), px(height)], | ||
| 539 | fill: (at == dialog.style).then(|| theme.hover()), | ||
| 540 | radius: 3.0, | ||
| 541 | pad: [4.0, (style.before.min(12.0)) / 2.0], | ||
| 542 | ..Spec::default() | ||
| 543 | }, | ||
| 544 | ); | ||
| 545 | preview(ui, "text", label, style, 26.0); | ||
| 546 | ui.close(); | ||
| 547 | if ui.signal(line).clicked { | ||
| 548 | dialog.style = at; | ||
| 549 | } | ||
| 550 | } | ||
| 551 | ui.close(); | ||
| 552 | ui.close(); | ||
| 553 | ui.open( | ||
| 554 | "buttons", | ||
| 555 | Spec { | ||
| 556 | size: [fill(), children()], | ||
| 557 | gap: 8.0, | ||
| 558 | ..Spec::default() | ||
| 559 | }, | ||
| 560 | ); | ||
| 561 | let none = ui::button(ui, "none", "No Theme").clicked; | ||
| 562 | ui.leaf( | ||
| 563 | "space", | ||
| 564 | Spec { | ||
| 565 | size: [fill(), px(1.0)], | ||
| 566 | ..Spec::default() | ||
| 567 | }, | ||
| 568 | ); | ||
| 569 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | ||
| 570 | let apply = ui::button(ui, "apply", "Apply").clicked; | ||
| 571 | ui.close(); | ||
| 572 | ui.close(); | ||
| 573 | |||
| 574 | let now = filetime(); | ||
| 575 | let copy = |dialog: &mut Dialog, name: String| { | ||
| 576 | let mut copy = dialog.themes[dialog.selected].clone(); | ||
| 577 | copy.id = fresh_id(now, dialog.themes.len()); | ||
| 578 | copy.name = name; | ||
| 579 | copy.modified = now; | ||
| 580 | dialog.changed.push(copy.id.clone()); | ||
| 581 | dialog.themes.push(copy); | ||
| 582 | dialog.selected = dialog.themes.len() - 1; | ||
| 583 | }; | ||
| 584 | if duplicate { | ||
| 585 | let name = format!("{} Copy", dialog.themes[dialog.selected].name); | ||
| 586 | copy(dialog, name); | ||
| 587 | } | ||
| 588 | if let Some(change) = change { | ||
| 589 | if dialog.selected < built_in().len() { | ||
| 590 | let name = format!("My {}", dialog.themes[dialog.selected].name); | ||
| 591 | copy(dialog, name); | ||
| 592 | } | ||
| 593 | let edited = &mut dialog.themes[dialog.selected]; | ||
| 594 | change.apply( | ||
| 595 | edited | ||
| 596 | .styles | ||
| 597 | .get_mut(name) | ||
| 598 | .expect("a theme has every style"), | ||
| 599 | ); | ||
| 600 | if !dialog.changed.contains(&edited.id) { | ||
| 601 | dialog.changed.push(edited.id.clone()); | ||
| 602 | } | ||
| 603 | } | ||
| 604 | if delete && dialog.selected >= built_in().len() { | ||
| 605 | let mut gone = dialog.themes.remove(dialog.selected); | ||
| 606 | gone.deleted = true; | ||
| 607 | dialog.deleted.push(gone); | ||
| 608 | dialog.selected = 0; | ||
| 609 | } | ||
| 610 | if !(apply || none || cancel) { | ||
| 611 | return; | ||
| 612 | } | ||
| 613 | if !cancel { | ||
| 614 | let themes = dialog | ||
| 615 | .themes | ||
| 616 | .iter() | ||
| 617 | .filter(|listed| dialog.changed.contains(&listed.id)) | ||
| 618 | .chain(&dialog.deleted) | ||
| 619 | .map(|listed| Theme { | ||
| 620 | modified: now, | ||
| 621 | ..listed.clone() | ||
| 622 | }) | ||
| 623 | .collect(); | ||
| 624 | let chosen = &dialog.themes[dialog.selected]; | ||
| 625 | let assignment = Assignment { | ||
| 626 | scope: dialog.target.clone(), | ||
| 627 | theme: apply.then(|| chosen.id.clone()), | ||
| 628 | assigned: now, | ||
| 629 | }; | ||
| 630 | dialog.library.save_themes(Themes { | ||
| 631 | themes, | ||
| 632 | assignments: vec![assignment], | ||
| 633 | }); | ||
| 634 | } | ||
| 635 | self.ui.close_popup(id()); | ||
| 636 | self.themes = None; | ||
| 637 | if !cancel && let Err(error) = self.wear_theme() { | ||
| 638 | eprintln!("Restyling the page failed: {error}"); | ||
| 639 | } | ||
| 640 | } | ||
| 641 | } | ||
| 642 | |||
| 643 | /// An edit the dialog's controls make to the style they show. | ||
| 644 | enum Change { | ||
| 645 | Font(String), | ||
| 646 | Size(f32), | ||
| 647 | Bold, | ||
| 648 | Italic, | ||
| 649 | Color(Option<String>), | ||
| 650 | /// Space above (true) or below, in points. | ||
| 651 | Spacing(bool, f32), | ||
| 652 | } | ||
| 653 | |||
| 654 | impl Change { | ||
| 655 | fn apply(self, style: &mut ThemeStyle) { | ||
| 656 | match self { | ||
| 657 | Self::Font(font) => style.font = font, | ||
| 658 | Self::Size(size) => style.size = size, | ||
| 659 | Self::Bold => style.bold = !style.bold, | ||
| 660 | Self::Italic => style.italic = !style.italic, | ||
| 661 | Self::Color(color) => style.color = color, | ||
| 662 | Self::Spacing(true, points) => style.before = points, | ||
| 663 | Self::Spacing(false, points) => style.after = points, | ||
| 664 | } | ||
| 665 | } | ||
| 666 | } | ||
| 667 | |||
| 668 | /// A new theme's id: unique to this machine's clock and the themes it knows. | ||
| 669 | fn fresh_id(now: u64, count: usize) -> String { | ||
| 670 | format!("{now:x}-{count}") | ||
| 671 | } | ||
| 672 | |||
| 673 | /// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in | ||
| 674 | /// `sheet`, the one at the caret outlined, then the theme commands. Returns the command chosen. | ||
| 675 | pub(crate) fn gallery( | ||
| 676 | ui: &mut Ui, | ||
| 677 | menu: Id, | ||
| 678 | anchor: Anchor, | ||
| 679 | sheet: &Theme, | ||
| 680 | current: Option<&str>, | ||
| 681 | ) -> Option<crate::commands::Id> { | ||
| 682 | use crate::commands::Id as Cmd; | ||
| 683 | let scopes = [Scope::Page, Scope::Section, Scope::Notebook]; | ||
| 684 | let groups = [ | ||
| 685 | ui::popup::Group { | ||
| 686 | heading: &sheet.name, | ||
| 687 | cells: STYLES.len(), | ||
| 688 | columns: 1, | ||
| 689 | size: [240.0, 34.0], | ||
| 690 | }, | ||
| 691 | ui::popup::Group { | ||
| 692 | heading: "Theme", | ||
| 693 | cells: scopes.len(), | ||
| 694 | columns: 1, | ||
| 695 | size: [240.0, 24.0], | ||
| 696 | }, | ||
| 697 | ]; | ||
| 698 | let shown: Vec<usize> = STYLES | ||
| 699 | .iter() | ||
| 700 | .position(|(name, _)| Some(*name) == current) | ||
| 701 | .into_iter() | ||
| 702 | .collect(); | ||
| 703 | let chosen = ui::popup::gallery(ui, menu, anchor, &groups, &shown, |ui, index| match STYLES | ||
| 704 | .get(index) | ||
| 705 | { | ||
| 706 | Some((name, label)) => { | ||
| 707 | preview(ui, "preview", label, &sheet.styles[*name], 24.0); | ||
| 708 | if let Some(node) = ui.access(ui.id("preview")) { | ||
| 709 | node.set_label(*label); | ||
| 710 | } | ||
| 711 | } | ||
| 712 | None => { | ||
| 713 | let label = format!("{}…", scopes[index - STYLES.len()].name()); | ||
| 714 | ui.leaf( | ||
| 715 | "scope", | ||
| 716 | Spec { | ||
| 717 | size: [fill(), fill()], | ||
| 718 | text: Some(&label), | ||
| 719 | ..Spec::default() | ||
| 720 | }, | ||
| 721 | ); | ||
| 722 | } | ||
| 723 | })?; | ||
| 724 | Some(match scopes.get(chosen.wrapping_sub(STYLES.len())) { | ||
| 725 | Some(scope) => Cmd::Theme(*scope), | ||
| 726 | None => Cmd::Style(chosen), | ||
| 727 | }) | ||
| 728 | } | ||
| 729 | |||
| 730 | #[cfg(test)] | ||
| 731 | mod tests { | ||
| 732 | use super::*; | ||
| 733 | use canvas::{ | ||
| 734 | document::TextPosition, | ||
| 735 | editor::{CanvasEditor, Formatting}, | ||
| 736 | layout::TextEngine, | ||
| 737 | }; | ||
| 738 | use onestore::{ | ||
| 739 | RevisionIndex, Store, | ||
| 740 | document::{Document, Kind}, | ||
| 741 | op, | ||
| 742 | page::Page, | ||
| 743 | }; | ||
| 744 | |||
| 745 | fn page(bytes: &[u8]) -> (onestore::ExGuid, Page) { | ||
| 746 | let store = Store::parse(bytes).unwrap(); | ||
| 747 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 748 | let document = Document::parse(&index).unwrap(); | ||
| 749 | let (space, _) = document.pages().unwrap()[0]; | ||
| 750 | (space, Page::from_space(&document, space).unwrap()) | ||
| 751 | } | ||
| 752 | |||
| 753 | /// The name of each paragraph's style on `page`, with its definition, in page order. | ||
| 754 | fn styled(page: &Page) -> Vec<(String, Definition)> { | ||
| 755 | let mut out = Vec::new(); | ||
| 756 | let mut pending: Vec<&onestore::page::PageParagraph> = Vec::new(); | ||
| 757 | for object in &page.objects { | ||
| 758 | match object { | ||
| 759 | onestore::page::PageObject::Outline(outline) => { | ||
| 760 | pending.extend(outline.paragraphs.iter()); | ||
| 761 | } | ||
| 762 | onestore::page::PageObject::Title(title) => { | ||
| 763 | pending.extend(title.outlines.iter().flat_map(|o| &o.paragraphs)); | ||
| 764 | } | ||
| 765 | _ => {} | ||
| 766 | } | ||
| 767 | } | ||
| 768 | for paragraph in pending { | ||
| 769 | let Some(style) = paragraph.style else { | ||
| 770 | continue; | ||
| 771 | }; | ||
| 772 | let definition = page.definitions[&style].clone(); | ||
| 773 | if let Kind::Style { | ||
| 774 | name: Some(name), .. | ||
| 775 | } = &definition.kind | ||
| 776 | { | ||
| 777 | out.push((name.clone(), definition)); | ||
| 778 | } | ||
| 779 | } | ||
| 780 | out | ||
| 781 | } | ||
| 782 | |||
| 783 | /// A section per built-in theme, each a page with a paragraph in every gallery style, | ||
| 784 | /// applied through the editor as the gallery does and the title restyled as opening the | ||
| 785 | /// page does: every style object holds the theme's definition under OneNote's name, the | ||
| 786 | /// next paragraph after a heading is Normal, and a second theme restyles the page whole. | ||
| 787 | /// `SNOWBOUND_STYLES_EXPORT` names a new directory receiving the notebook, its themes | ||
| 788 | /// assigned by section, for a cold open in OneNote (`corpus/styles`). | ||
| 789 | #[test] | ||
| 790 | fn themed_pages_hold_their_theme_under_onenote_s_names() { | ||
| 791 | let temporary = | ||
| 792 | std::env::temp_dir().join(format!("snowbound-styles-{}", std::process::id())); | ||
| 793 | let _ = std::fs::remove_dir_all(&temporary); | ||
| 794 | let root = temporary.join("Styles"); | ||
| 795 | std::fs::create_dir_all(&root).unwrap(); | ||
| 796 | let mut engine = TextEngine::default(); | ||
| 797 | let mut sections = Vec::new(); | ||
| 798 | for theme in built_in() { | ||
| 799 | let file = format!("{}.one", theme.name); | ||
| 800 | let source = onestore::create_section(&file, "", "Author").unwrap(); | ||
| 801 | let (space, before) = page(&source); | ||
| 802 | let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap(); | ||
| 803 | let sheet = theme.sheet(); | ||
| 804 | editor.styles = sheet.clone(); | ||
| 805 | let body = editor | ||
| 806 | .outlines() | ||
| 807 | .iter() | ||
| 808 | .find(|outline| !outline.title) | ||
| 809 | .unwrap() | ||
| 810 | .id; | ||
| 811 | editor.focus_outline(body).unwrap(); | ||
| 812 | for (paragraph, (name, label)) in STYLES.iter().enumerate() { | ||
| 813 | if paragraph > 0 { | ||
| 814 | editor.enter(&mut engine, false).unwrap(); | ||
| 815 | } | ||
| 816 | editor | ||
| 817 | .insert(&mut engine, &format!("{label} in {}", theme.name)) | ||
| 818 | .unwrap(); | ||
| 819 | editor | ||
| 820 | .select( | ||
| 821 | [TextPosition { | ||
| 822 | paragraph, | ||
| 823 | offset: 0, | ||
| 824 | }; 2] | ||
| 825 | .into(), | ||
| 826 | ) | ||
| 827 | .unwrap(); | ||
| 828 | editor | ||
| 829 | .format(&mut engine, Formatting::Style(sheet[*name].clone())) | ||
| 830 | .unwrap(); | ||
| 831 | let offset = editor.outlines()[editor | ||
| 832 | .outlines() | ||
| 833 | .iter() | ||
| 834 | .position(|outline| outline.id == body) | ||
| 835 | .unwrap()] | ||
| 836 | .document() | ||
| 837 | .paragraphs() | ||
| 838 | .nth(paragraph) | ||
| 839 | .unwrap() | ||
| 840 | .text() | ||
| 841 | .encode_utf16() | ||
| 842 | .count() as u32; | ||
| 843 | editor | ||
| 844 | .select([TextPosition { paragraph, offset }; 2].into()) | ||
| 845 | .unwrap(); | ||
| 846 | } | ||
| 847 | let arena = onestore::Arena::default(); | ||
| 848 | let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); | ||
| 849 | let edit = |ops: Vec<op::PageOp>, at| op::Edit { | ||
| 850 | at, | ||
| 851 | ops: ops | ||
| 852 | .into_iter() | ||
| 853 | .map(|op| op::Op::Page { space, op }) | ||
| 854 | .collect(), | ||
| 855 | }; | ||
| 856 | section | ||
| 857 | .apply( | ||
| 858 | "Author", | ||
| 859 | &edit(editor.take_ops().unwrap(), 134_000_000_000_000_000), | ||
| 860 | ) | ||
| 861 | .unwrap(); | ||
| 862 | let typed = section.page(space).unwrap(); | ||
| 863 | let restyle = op::restyle(&typed, &sheet).unwrap(); | ||
| 864 | section | ||
| 865 | .apply("Author", &edit(restyle, 134_000_000_000_000_001)) | ||
| 866 | .unwrap(); | ||
| 867 | section.seal().unwrap(); | ||
| 868 | let written = section.image(); | ||
| 869 | let (_, stored) = page(&written); | ||
| 870 | let styles: Vec<_> = styled(&stored) | ||
| 871 | .into_iter() | ||
| 872 | .filter(|(name, _)| sheet.contains_key(name)) | ||
| 873 | .collect(); | ||
| 874 | let names: std::collections::BTreeSet<&str> = | ||
| 875 | styles.iter().map(|(name, _)| name.as_str()).collect(); | ||
| 876 | assert_eq!(names.len(), STYLES.len(), "{}", theme.name); | ||
| 877 | for (name, definition) in &styles { | ||
| 878 | assert_eq!(definition, &sheet[name], "{} {name}", theme.name); | ||
| 879 | } | ||
| 880 | assert!(op::restyle(&stored, &sheet).unwrap().is_empty()); | ||
| 881 | // Another theme restyles the page whole, keeping the names. | ||
| 882 | let other = built_in().swap_remove(3).sheet(); | ||
| 883 | let mut copy = onestore::Section::open(&arena, written.clone()).unwrap(); | ||
| 884 | copy.apply( | ||
| 885 | "Author", | ||
| 886 | &edit( | ||
| 887 | op::restyle(&stored, &other).unwrap(), | ||
| 888 | 134_000_000_000_000_002, | ||
| 889 | ), | ||
| 890 | ) | ||
| 891 | .unwrap(); | ||
| 892 | for (name, definition) in styled(&copy.page(space).unwrap()) { | ||
| 893 | if !other.contains_key(&name) { | ||
| 894 | continue; | ||
| 895 | } | ||
| 896 | assert_eq!(definition, other[&name], "{} to modern {name}", theme.name); | ||
| 897 | } | ||
| 898 | std::fs::write(root.join(&file), &written).unwrap(); | ||
| 899 | let file_id = Store::parse(&written).unwrap().header.file_id; | ||
| 900 | sections.push((file, file_id, theme.id)); | ||
| 901 | } | ||
| 902 | let listed: Vec<(&str, [u8; 16])> = sections | ||
| 903 | .iter() | ||
| 904 | .map(|(file, id, _)| (file.as_str(), *id)) | ||
| 905 | .collect(); | ||
| 906 | let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &listed).unwrap(); | ||
| 907 | std::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap(); | ||
| 908 | let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap(); | ||
| 909 | let themes = notebook | ||
| 910 | .save_themes(Themes { | ||
| 911 | assignments: sections | ||
| 912 | .iter() | ||
| 913 | .map(|(_, id, theme)| Assignment { | ||
| 914 | scope: stored::Scope::section(*id), | ||
| 915 | theme: Some(theme.clone()), | ||
| 916 | assigned: 134_000_000_000_000_000, | ||
| 917 | }) | ||
| 918 | .collect(), | ||
| 919 | ..Themes::default() | ||
| 920 | }) | ||
| 921 | .unwrap(); | ||
| 922 | for (_, id, theme) in &sections { | ||
| 923 | assert_eq!(&themes.effective(Some(*id), None).unwrap().id, theme); | ||
| 924 | } | ||
| 925 | if let Some(directory) = std::env::var_os("SNOWBOUND_STYLES_EXPORT") { | ||
| 926 | let status = std::process::Command::new("cp") | ||
| 927 | .arg("-R") | ||
| 928 | .arg(&root) | ||
| 929 | .arg(directory) | ||
| 930 | .status() | ||
| 931 | .unwrap(); | ||
| 932 | assert!(status.success()); | ||
| 933 | } | ||
| 934 | std::fs::remove_dir_all(&temporary).unwrap(); | ||
| 935 | } | ||
| 936 | } | ||
crates/snowbound/src/undo.rs created+1630| ... | @@ -0,0 +1,1630 @@ | ||
| 1 | //! Undo and Redo across pages, sections and notebooks (`arc/canvas.md`). Each page keeps | ||
| 2 | //! its own history in its editor, kept while the window is open as OneNote 2010 keeps it; the | ||
| 3 | //! window keeps what was done to pages and sections between those edits, so Undo takes back | ||
| 4 | //! whichever came last where the user is. Every step taken back is an edit of its own. | ||
| 5 | |||
| 6 | use crate::{Command, Library, Loaded, State, UserEvent}; | ||
| 7 | use canvas::editor::CanvasEditor; | ||
| 8 | use notebook::{Replica, discover::Folder}; | ||
| 9 | use onestore::{ | ||
| 10 | ExGuid, PageCreation, PageEdit, | ||
| 11 | op::{Edit, Op, SectionOp}, | ||
| 12 | page::{Page, PageObject}, | ||
| 13 | }; | ||
| 14 | use std::{error::Error, sync::Arc}; | ||
| 15 | |||
| 16 | /// Pages whose editors are kept with their history once left, at most. | ||
| 17 | const KEPT: usize = 50; | ||
| 18 | |||
| 19 | #[derive(Default)] | ||
| 20 | pub struct Timeline { | ||
| 21 | undo: Vec<Step>, | ||
| 22 | redo: Vec<Step>, | ||
| 23 | /// Editors of pages left holding history, the longest left first. | ||
| 24 | parked: Vec<(ExGuid, CanvasEditor)>, | ||
| 25 | /// Actions on their way, while which Undo and Redo wait. | ||
| 26 | busy: usize, | ||
| 27 | } | ||
| 28 | |||
| 29 | enum Step { | ||
| 30 | /// One step of a page's own history, which its editor holds. | ||
| 31 | Edit(ExGuid), | ||
| 32 | Action(Action), | ||
| 33 | } | ||
| 34 | |||
| 35 | impl Step { | ||
| 36 | fn edits(&self, page: ExGuid) -> bool { | ||
| 37 | matches!(self, Step::Edit(edited) if *edited == page) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | /// A change to pages or sections and where it was made: a notebook, by location, and | ||
| 42 | /// for a change to pages, the section by file identity. Undo reaches it there. | ||
| 43 | pub struct Action { | ||
| 44 | notebook: String, | ||
| 45 | section: Option<[u8; 16]>, | ||
| 46 | change: Change, | ||
| 47 | } | ||
| 48 | |||
| 49 | /// What an action does when taken; doing one gives the action that takes it back. | ||
| 50 | pub enum Change { | ||
| 51 | /// Takes pages out of the section, to the recycle bin where the notebook has one. Pages | ||
| 52 | /// `created` go only while they hold nothing, and never to the bin. Shows `show` after, | ||
| 53 | /// else the page after the first one taken, or before it. | ||
| 54 | Delete { | ||
| 55 | pages: Vec<ExGuid>, | ||
| 56 | show: Option<ExGuid>, | ||
| 57 | created: bool, | ||
| 58 | }, | ||
| 59 | /// Puts pages back where they were, taking them out of the recycle bin unless `created`, | ||
| 60 | /// and takes out `added`, the page a section left without pages gained, while it holds | ||
| 61 | /// nothing. | ||
| 62 | Restore { | ||
| 63 | pages: Vec<Kept>, | ||
| 64 | added: Option<ExGuid>, | ||
| 65 | created: bool, | ||
| 66 | }, | ||
| 67 | /// Puts the pages `moved` back as `order` lists the section's pages, with their levels. | ||
| 68 | Arrange { | ||
| 69 | order: Vec<(ExGuid, u32)>, | ||
| 70 | moved: Vec<ExGuid>, | ||
| 71 | }, | ||
| 72 | /// Titles a page `to` while its title is `from`. | ||
| 73 | Retitle { | ||
| 74 | page: ExGuid, | ||
| 75 | from: String, | ||
| 76 | to: String, | ||
| 77 | }, | ||
| 78 | /// Moves a page to the end of the section `target` names, as a drop on its tab does. | ||
| 79 | Send { page: ExGuid, target: [u8; 16] }, | ||
| 80 | /// Brings a page sent to `target` back, before `before` at `level`, taking out `added` | ||
| 81 | /// while it holds nothing. | ||
| 82 | Return { | ||
| 83 | page: ExGuid, | ||
| 84 | target: [u8; 16], | ||
| 85 | before: Option<ExGuid>, | ||
| 86 | level: u32, | ||
| 87 | added: Option<ExGuid>, | ||
| 88 | }, | ||
| 89 | /// Names a section or group `to` while it is named `from`. | ||
| 90 | Rename { | ||
| 91 | entry: [u8; 16], | ||
| 92 | from: String, | ||
| 93 | to: String, | ||
| 94 | }, | ||
| 95 | /// Puts a section or group, `entry`, in the folder `folder` names (the notebook's own | ||
| 96 | /// without), which then takes `order`. | ||
| 97 | Place { | ||
| 98 | entry: Option<[u8; 16]>, | ||
| 99 | folder: Option<[u8; 16]>, | ||
| 100 | order: Vec<[u8; 16]>, | ||
| 101 | }, | ||
| 102 | } | ||
| 103 | |||
| 104 | /// A page taken out of its section, and where it was: before `before` at `level`. | ||
| 105 | pub struct Kept { | ||
| 106 | page: Page, | ||
| 107 | before: Option<ExGuid>, | ||
| 108 | level: u32, | ||
| 109 | } | ||
| 110 | |||
| 111 | /// Where the user is: the open section's notebook, by location, and the section. | ||
| 112 | pub struct Here { | ||
| 113 | notebook: String, | ||
| 114 | section: [u8; 16], | ||
| 115 | } | ||
| 116 | |||
| 117 | impl Action { | ||
| 118 | fn applies(&self, here: &Here) -> bool { | ||
| 119 | self.notebook == here.notebook && self.section.is_none_or(|section| section == here.section) | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | /// What Undo or Redo takes next. | ||
| 124 | pub enum Next { | ||
| 125 | /// The open page's last edit, or the last undone. | ||
| 126 | Edit, | ||
| 127 | Action(Action), | ||
| 128 | /// The page a created page Undo would take out still holds edits made since, so | ||
| 129 | /// Undo shows it, where those go back first. | ||
| 130 | Show(ExGuid), | ||
| 131 | } | ||
| 132 | |||
| 133 | impl Timeline { | ||
| 134 | /// Notes `action`, just done; what Redo held of actions goes. | ||
| 135 | pub fn record(&mut self, action: Action) { | ||
| 136 | self.undo.push(Step::Action(action)); | ||
| 137 | self.redo.retain(|step| matches!(step, Step::Edit(_))); | ||
| 138 | } | ||
| 139 | |||
| 140 | /// Notes `action`, which takes back one the user just did (`taken` none), or Undo, or | ||
| 141 | /// `taken` Redo, just took. | ||
| 142 | fn note(&mut self, taken: Option<bool>, action: Action) { | ||
| 143 | match taken { | ||
| 144 | None => self.record(action), | ||
| 145 | Some(true) => self.undo.push(Step::Action(action)), | ||
| 146 | Some(false) => self.redo.push(Step::Action(action)), | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | /// Follows page `page`'s history, `depth` steps of Undo and Redo, after an edit or | ||
| 151 | /// before one is taken: steps it gained are the newest; `run`, a run of typing that | ||
| 152 | /// went on, makes its last step the newest. | ||
| 153 | pub fn edited(&mut self, page: ExGuid, [undo, redo]: [usize; 2], run: bool) { | ||
| 154 | let count = |steps: &[Step]| steps.iter().filter(|step| step.edits(page)).count(); | ||
| 155 | let (had, had_redo) = (count(&self.undo), count(&self.redo)); | ||
| 156 | if undo < had { | ||
| 157 | drop_newest(&mut self.undo, page, had - undo); | ||
| 158 | } else if undo > had || run { | ||
| 159 | // A run of typing that went on makes its step the newest. | ||
| 160 | if undo == had | ||
| 161 | && let Some(at) = self.undo.iter().rposition(|step| step.edits(page)) | ||
| 162 | { | ||
| 163 | let step = self.undo.remove(at); | ||
| 164 | self.undo.push(step); | ||
| 165 | } | ||
| 166 | self.undo.extend((had..undo).map(|_| Step::Edit(page))); | ||
| 167 | // A new edit ends what actions Redo held. | ||
| 168 | self.redo.retain(|step| matches!(step, Step::Edit(_))); | ||
| 169 | } | ||
| 170 | if redo < had_redo { | ||
| 171 | drop_newest(&mut self.redo, page, had_redo - redo); | ||
| 172 | } else { | ||
| 173 | self.redo.extend((had_redo..redo).map(|_| Step::Edit(page))); | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | /// What Undo, or `redo` Redo, takes next on page `page`, its history `depth` steps deep, | ||
| 178 | /// with `here` open: the newer of the page's own step and the last action made there. | ||
| 179 | /// An action is taken off the timeline; an edit stays until `stepped`. | ||
| 180 | pub fn next( | ||
| 181 | &mut self, | ||
| 182 | redo: bool, | ||
| 183 | page: ExGuid, | ||
| 184 | depth: [usize; 2], | ||
| 185 | here: &Here, | ||
| 186 | ) -> Option<Next> { | ||
| 187 | if self.busy > 0 { | ||
| 188 | return None; | ||
| 189 | } | ||
| 190 | self.edited(page, depth, false); | ||
| 191 | let steps = if redo { &mut self.redo } else { &mut self.undo }; | ||
| 192 | let at = steps.iter().rposition(|step| match step { | ||
| 193 | Step::Edit(edited) => *edited == page, | ||
| 194 | Step::Action(action) => action.applies(here), | ||
| 195 | })?; | ||
| 196 | match &steps[at] { | ||
| 197 | Step::Edit(_) => return Some(Next::Edit), | ||
| 198 | Step::Action(Action { | ||
| 199 | change: | ||
| 200 | Change::Delete { | ||
| 201 | pages, | ||
| 202 | created: true, | ||
| 203 | .. | ||
| 204 | }, | ||
| 205 | .. | ||
| 206 | }) if !redo => { | ||
| 207 | let edited = pages | ||
| 208 | .iter() | ||
| 209 | .find(|page| steps.iter().any(|step| step.edits(**page))); | ||
| 210 | if let Some(edited) = edited { | ||
| 211 | return Some(Next::Show(*edited)); | ||
| 212 | } | ||
| 213 | } | ||
| 214 | Step::Action(_) => {} | ||
| 215 | } | ||
| 216 | match steps.remove(at) { | ||
| 217 | Step::Action(action) => Some(Next::Action(action)), | ||
| 218 | Step::Edit(_) => unreachable!("an edit is not taken off"), | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | /// Notes that Undo, or `redo` Redo, took page `page`'s step. | ||
| 223 | pub fn stepped(&mut self, page: ExGuid, redo: bool) { | ||
| 224 | let (from, to) = if redo { | ||
| 225 | (&mut self.redo, &mut self.undo) | ||
| 226 | } else { | ||
| 227 | (&mut self.undo, &mut self.redo) | ||
| 228 | }; | ||
| 229 | if let Some(at) = from.iter().rposition(|step| step.edits(page)) { | ||
| 230 | to.push(from.remove(at)); | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | /// Whether Undo, or `redo` Redo, has an action to take `here`. | ||
| 235 | pub fn reaches(&self, redo: bool, here: &Here) -> bool { | ||
| 236 | let steps = if redo { &self.redo } else { &self.undo }; | ||
| 237 | self.busy == 0 | ||
| 238 | && steps | ||
| 239 | .iter() | ||
| 240 | .any(|step| matches!(step, Step::Action(action) if action.applies(here))) | ||
| 241 | } | ||
| 242 | |||
| 243 | /// Keeps the editor of page `page`, just left, for its history. | ||
| 244 | pub fn park(&mut self, page: ExGuid, editor: CanvasEditor) { | ||
| 245 | self.parked.retain(|(parked, _)| *parked != page); | ||
| 246 | if editor.history_depth() == [0, 0] || editor.marked_range().is_some() { | ||
| 247 | return self.forget(&[page]); | ||
| 248 | } | ||
| 249 | self.parked.push((page, editor)); | ||
| 250 | if self.parked.len() > KEPT { | ||
| 251 | let (oldest, _) = self.parked.remove(0); | ||
| 252 | self.forget(&[oldest]); | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | /// The editor page `page` was left with, which takes up its history again. | ||
| 257 | pub fn resume(&mut self, page: ExGuid) -> Option<CanvasEditor> { | ||
| 258 | let at = self.parked.iter().position(|(parked, _)| *parked == page)?; | ||
| 259 | Some(self.parked.remove(at).1) | ||
| 260 | } | ||
| 261 | |||
| 262 | /// Drops the histories of pages gone. | ||
| 263 | pub fn forget(&mut self, pages: &[ExGuid]) { | ||
| 264 | self.parked.retain(|(parked, _)| !pages.contains(parked)); | ||
| 265 | for steps in [&mut self.undo, &mut self.redo] { | ||
| 266 | steps.retain(|step| !matches!(step, Step::Edit(page) if pages.contains(page))); | ||
| 267 | } | ||
| 268 | } | ||
| 269 | |||
| 270 | /// Drops the actions of the notebook at `location`, closed. | ||
| 271 | pub fn close(&mut self, location: &str) { | ||
| 272 | for steps in [&mut self.undo, &mut self.redo] { | ||
| 273 | steps.retain( | ||
| 274 | |step| !matches!(step, Step::Action(action) if action.notebook == location), | ||
| 275 | ); | ||
| 276 | } | ||
| 277 | } | ||
| 278 | } | ||
| 279 | |||
| 280 | /// Removes the `count` newest steps of page `page`. | ||
| 281 | fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) { | ||
| 282 | for _ in 0..count { | ||
| 283 | if let Some(at) = steps.iter().rposition(|step| step.edits(page)) { | ||
| 284 | steps.remove(at); | ||
| 285 | } | ||
| 286 | } | ||
| 287 | } | ||
| 288 | |||
| 289 | /// Whether `page` holds nothing but its title, untitled. | ||
| 290 | fn blank(page: &Page) -> bool { | ||
| 291 | page.title.trim().is_empty() | ||
| 292 | && page | ||
| 293 | .objects | ||
| 294 | .iter() | ||
| 295 | .all(|object| matches!(object, PageObject::Title(_))) | ||
| 296 | } | ||
| 297 | |||
| 298 | /// The page shown once `gone` leave the section listing `listed`: `prefer` if it stays, | ||
| 299 | /// else the page after the first one gone, or before it, as OneNote 2010 shows. | ||
| 300 | fn neighbor( | ||
| 301 | listed: &[(ExGuid, String, u32)], | ||
| 302 | gone: &[ExGuid], | ||
| 303 | prefer: Option<ExGuid>, | ||
| 304 | ) -> Option<ExGuid> { | ||
| 305 | let stays = |space: &ExGuid| !gone.contains(space) && listed.iter().any(|(s, ..)| s == space); | ||
| 306 | if let Some(prefer) = prefer.filter(stays) { | ||
| 307 | return Some(prefer); | ||
| 308 | } | ||
| 309 | let at = listed | ||
| 310 | .iter() | ||
| 311 | .position(|(space, ..)| gone.contains(space)) | ||
| 312 | .unwrap_or_default(); | ||
| 313 | listed[at..] | ||
| 314 | .iter() | ||
| 315 | .chain(listed[..at].iter().rev()) | ||
| 316 | .map(|(space, ..)| *space) | ||
| 317 | .find(stays) | ||
| 318 | } | ||
| 319 | |||
| 320 | /// The space of the page an import op creates. | ||
| 321 | fn imported(op: &Op) -> ExGuid { | ||
| 322 | match op { | ||
| 323 | Op::Section(SectionOp::Import { creation, .. }) => creation.space(), | ||
| 324 | _ => unreachable!("notebook::session::moved imports"), | ||
| 325 | } | ||
| 326 | } | ||
| 327 | |||
| 328 | /// What a change to pages works with: the open section, its notebook, who changes it, and | ||
| 329 | /// a page to give it should it be left without pages. | ||
| 330 | struct Site { | ||
| 331 | replica: Arc<Replica>, | ||
| 332 | library: Arc<Library>, | ||
| 333 | author: String, | ||
| 334 | fresh: PageCreation, | ||
| 335 | } | ||
| 336 | |||
| 337 | /// A change to pages done: the change taking it back, the page to show, and pages gone. | ||
| 338 | struct Applied { | ||
| 339 | undo: Change, | ||
| 340 | show: ExGuid, | ||
| 341 | gone: Vec<ExGuid>, | ||
| 342 | } | ||
| 343 | |||
| 344 | impl Site { | ||
| 345 | fn apply(&self, ops: Vec<Op>) -> Result<(), Box<dyn Error>> { | ||
| 346 | self.replica.apply( | ||
| 347 | &self.author, | ||
| 348 | Edit { | ||
| 349 | at: crate::filetime(), | ||
| 350 | ops, | ||
| 351 | }, | ||
| 352 | )?; | ||
| 353 | Ok(()) | ||
| 354 | } | ||
| 355 | |||
| 356 | /// The catalog path of the section `identity` names. | ||
| 357 | fn section_path(&self, identity: [u8; 16]) -> Option<String> { | ||
| 358 | entry_of(self.library.catalog()?, identity).map(|(_, path)| path) | ||
| 359 | } | ||
| 360 | |||
| 361 | /// Does `change` to the section's pages, `shown` the page open; `None` where what it | ||
| 362 | /// changes is gone or holds what it did not make, which it leaves alone. | ||
| 363 | fn change(&self, change: Change, shown: ExGuid) -> Result<Option<Applied>, Box<dyn Error>> { | ||
| 364 | let listed = self.replica.pages()?; | ||
| 365 | let at = |space: ExGuid| listed.iter().position(|(listed, ..)| *listed == space); | ||
| 366 | let binned = matches!(self.library.notebook, Ok(Some(_))); | ||
| 367 | Ok(Some(match change { | ||
| 368 | Change::Delete { | ||
| 369 | pages, | ||
| 370 | show, | ||
| 371 | created, | ||
| 372 | } => { | ||
| 373 | let pages: Vec<ExGuid> = pages | ||
| 374 | .into_iter() | ||
| 375 | .filter(|page| at(*page).is_some()) | ||
| 376 | .collect(); | ||
| 377 | let read: Vec<Page> = pages | ||
| 378 | .iter() | ||
| 379 | .map(|page| self.replica.page(*page)) | ||
| 380 | .collect::<Result<_, _>>()?; | ||
| 381 | if pages.is_empty() || created && !read.iter().all(blank) { | ||
| 382 | return Ok(None); | ||
| 383 | } | ||
| 384 | let kept: Vec<Kept> = pages | ||
| 385 | .iter() | ||
| 386 | .zip(read) | ||
| 387 | .map(|(space, page)| { | ||
| 388 | let at = at(*space).expect("listed"); | ||
| 389 | Kept { | ||
| 390 | page, | ||
| 391 | before: listed[at + 1..] | ||
| 392 | .iter() | ||
| 393 | .map(|(space, ..)| *space) | ||
| 394 | .find(|space| !pages.contains(space)), | ||
| 395 | level: listed[at].2, | ||
| 396 | } | ||
| 397 | }) | ||
| 398 | .collect(); | ||
| 399 | let mut ops = vec![Op::Section(SectionOp::Delete(pages.clone()))]; | ||
| 400 | let (show, added) = match neighbor(&listed, &pages, show) { | ||
| 401 | Some(show) => (show, None), | ||
| 402 | None => { | ||
| 403 | let space = self.fresh.space(); | ||
| 404 | ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); | ||
| 405 | (space, Some(space)) | ||
| 406 | } | ||
| 407 | }; | ||
| 408 | if !created && binned { | ||
| 409 | let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect(); | ||
| 410 | self.library.reopen()?.recycle_pages(&pages, &self.author)?; | ||
| 411 | } | ||
| 412 | self.apply(ops)?; | ||
| 413 | Applied { | ||
| 414 | undo: Change::Restore { | ||
| 415 | pages: kept, | ||
| 416 | added, | ||
| 417 | created, | ||
| 418 | }, | ||
| 419 | show, | ||
| 420 | gone: pages, | ||
| 421 | } | ||
| 422 | } | ||
| 423 | Change::Restore { | ||
| 424 | pages, | ||
| 425 | added, | ||
| 426 | created, | ||
| 427 | } => { | ||
| 428 | // A page someone put back already, listed by its identity, stays as it is. | ||
| 429 | let identity = |space: ExGuid| self.replica.page(space).ok()?.identity; | ||
| 430 | let mut back = Vec::new(); | ||
| 431 | for kept in pages { | ||
| 432 | let listed_again = kept.page.identity.is_some() | ||
| 433 | && listed.iter().any(|(space, title, _)| { | ||
| 434 | *title == kept.page.title && identity(*space) == kept.page.identity | ||
| 435 | }); | ||
| 436 | if !listed_again { | ||
| 437 | back.push(kept); | ||
| 438 | } | ||
| 439 | } | ||
| 440 | if back.is_empty() { | ||
| 441 | return Ok(None); | ||
| 442 | } | ||
| 443 | let mut ops = Vec::new(); | ||
| 444 | let mut spaces = Vec::new(); | ||
| 445 | for kept in &back { | ||
| 446 | let import = notebook::session::moved(&kept.page, &self.author)?; | ||
| 447 | let space = imported(&import); | ||
| 448 | let before = kept.before.filter(|before| at(*before).is_some()); | ||
| 449 | ops.push(import); | ||
| 450 | ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to( | ||
| 451 | space, before, kept.level, | ||
| 452 | )?]))); | ||
| 453 | spaces.push(space); | ||
| 454 | } | ||
| 455 | let gone: Vec<ExGuid> = added | ||
| 456 | .filter(|added| { | ||
| 457 | at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p)) | ||
| 458 | }) | ||
| 459 | .into_iter() | ||
| 460 | .collect(); | ||
| 461 | if !gone.is_empty() { | ||
| 462 | ops.push(Op::Section(SectionOp::Delete(gone.clone()))); | ||
| 463 | } | ||
| 464 | self.apply(ops)?; | ||
| 465 | if !created && binned { | ||
| 466 | let identities: Vec<[u8; 16]> = | ||
| 467 | back.iter().filter_map(|kept| kept.page.identity).collect(); | ||
| 468 | self.library.reopen()?.unrecycle_pages(&identities)?; | ||
| 469 | } | ||
| 470 | Applied { | ||
| 471 | undo: Change::Delete { | ||
| 472 | pages: spaces.clone(), | ||
| 473 | show: Some(shown), | ||
| 474 | created, | ||
| 475 | }, | ||
| 476 | show: spaces[0], | ||
| 477 | gone, | ||
| 478 | } | ||
| 479 | } | ||
| 480 | Change::Arrange { order, moved } => { | ||
| 481 | let moving = |space: &ExGuid| moved.contains(space) && at(*space).is_some(); | ||
| 482 | let mut edits = Vec::new(); | ||
| 483 | for (index, (space, level)) in order.iter().enumerate() { | ||
| 484 | if !moving(space) { | ||
| 485 | continue; | ||
| 486 | } | ||
| 487 | let before = order[index + 1..] | ||
| 488 | .iter() | ||
| 489 | .map(|(space, _)| *space) | ||
| 490 | .find(|space| !moved.contains(space) && at(*space).is_some()); | ||
| 491 | edits.push(PageEdit::move_to(*space, before, *level)?); | ||
| 492 | } | ||
| 493 | let Some(show) = edits.first().map(PageEdit::space) else { | ||
| 494 | return Ok(None); | ||
| 495 | }; | ||
| 496 | self.apply(vec![Op::Section(SectionOp::Pages(edits))])?; | ||
| 497 | Applied { | ||
| 498 | undo: Change::Arrange { | ||
| 499 | order: listed | ||
| 500 | .iter() | ||
| 501 | .map(|(space, _, level)| (*space, *level)) | ||
| 502 | .collect(), | ||
| 503 | moved, | ||
| 504 | }, | ||
| 505 | show, | ||
| 506 | gone: Vec::new(), | ||
| 507 | } | ||
| 508 | } | ||
| 509 | Change::Retitle { page, from, to } => { | ||
| 510 | if at(page).is_none_or(|at| listed[at].1.trim() != from.trim()) { | ||
| 511 | return Ok(None); | ||
| 512 | } | ||
| 513 | let op = crate::rename::retitled(&self.replica.page(page)?, page, to.clone())?; | ||
| 514 | self.apply(vec![op])?; | ||
| 515 | Applied { | ||
| 516 | undo: Change::Retitle { | ||
| 517 | page, | ||
| 518 | from: to, | ||
| 519 | to: from, | ||
| 520 | }, | ||
| 521 | show: page, | ||
| 522 | gone: Vec::new(), | ||
| 523 | } | ||
| 524 | } | ||
| 525 | Change::Send { page, target } => { | ||
| 526 | let (Some(from), Some(path)) = (at(page), self.section_path(target)) else { | ||
| 527 | return Ok(None); | ||
| 528 | }; | ||
| 529 | let import = notebook::session::moved(&self.replica.page(page)?, &self.author)?; | ||
| 530 | let arrived = imported(&import); | ||
| 531 | let before = listed[from + 1..].first().map(|(space, ..)| *space); | ||
| 532 | let level = listed[from].2; | ||
| 533 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))]; | ||
| 534 | let (show, added) = match neighbor(&listed, &[page], None) { | ||
| 535 | Some(show) => (show, None), | ||
| 536 | None => { | ||
| 537 | let space = self.fresh.space(); | ||
| 538 | ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); | ||
| 539 | (space, Some(space)) | ||
| 540 | } | ||
| 541 | }; | ||
| 542 | let section = self.library.open(&path, || {})?; | ||
| 543 | section.replica().apply( | ||
| 544 | &self.author, | ||
| 545 | Edit { | ||
| 546 | at: crate::filetime(), | ||
| 547 | ops: vec![import], | ||
| 548 | }, | ||
| 549 | )?; | ||
| 550 | section.close()?; | ||
| 551 | self.apply(ops)?; | ||
| 552 | Applied { | ||
| 553 | undo: Change::Return { | ||
| 554 | page: arrived, | ||
| 555 | target, | ||
| 556 | before, | ||
| 557 | level, | ||
| 558 | added, | ||
| 559 | }, | ||
| 560 | show, | ||
| 561 | gone: vec![page], | ||
| 562 | } | ||
| 563 | } | ||
| 564 | Change::Return { | ||
| 565 | page, | ||
| 566 | target, | ||
| 567 | before, | ||
| 568 | level, | ||
| 569 | added, | ||
| 570 | } => { | ||
| 571 | let Some(path) = self.section_path(target) else { | ||
| 572 | return Ok(None); | ||
| 573 | }; | ||
| 574 | let section = self.library.open(&path, || {})?; | ||
| 575 | let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) { | ||
| 576 | true => Some(section.page(page)?), | ||
| 577 | false => None, | ||
| 578 | }; | ||
| 579 | let Some(sent) = sent else { | ||
| 580 | section.close()?; | ||
| 581 | return Ok(None); | ||
| 582 | }; | ||
| 583 | let import = notebook::session::moved(&sent, &self.author)?; | ||
| 584 | let back = imported(&import); | ||
| 585 | let mut ops = vec![ | ||
| 586 | import, | ||
| 587 | Op::Section(SectionOp::Pages(vec![PageEdit::move_to( | ||
| 588 | back, | ||
| 589 | before.filter(|before| at(*before).is_some()), | ||
| 590 | level, | ||
| 591 | )?])), | ||
| 592 | ]; | ||
| 593 | let gone: Vec<ExGuid> = added | ||
| 594 | .filter(|added| { | ||
| 595 | at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p)) | ||
| 596 | }) | ||
| 597 | .into_iter() | ||
| 598 | .collect(); | ||
| 599 | if !gone.is_empty() { | ||
| 600 | ops.push(Op::Section(SectionOp::Delete(gone.clone()))); | ||
| 601 | } | ||
| 602 | self.apply(ops)?; | ||
| 603 | section.replica().apply( | ||
| 604 | &self.author, | ||
| 605 | Edit { | ||
| 606 | at: crate::filetime(), | ||
| 607 | ops: vec![Op::Section(SectionOp::Delete(vec![page]))], | ||
| 608 | }, | ||
| 609 | )?; | ||
| 610 | section.close()?; | ||
| 611 | Applied { | ||
| 612 | undo: Change::Send { page: back, target }, | ||
| 613 | show: back, | ||
| 614 | gone, | ||
| 615 | } | ||
| 616 | } | ||
| 617 | Change::Rename { .. } | Change::Place { .. } => { | ||
| 618 | unreachable!("a section's own change is the notebook's") | ||
| 619 | } | ||
| 620 | })) | ||
| 621 | } | ||
| 622 | } | ||
| 623 | |||
| 624 | /// The identity of each section and group in `folder`, in order, with its catalog path; | ||
| 625 | /// the recycle bin is left out. | ||
| 626 | fn entries(folder: &Folder) -> Vec<([u8; 16], String)> { | ||
| 627 | folder | ||
| 628 | .sections | ||
| 629 | .iter() | ||
| 630 | .map(|section| (section.file_id, section.path.clone())) | ||
| 631 | .chain(folder.groups.iter().filter_map(|group| { | ||
| 632 | let toc = group.toc.as_ref()?; | ||
| 633 | (!crate::library::recycle_bin(&group.path)).then(|| (toc.file_id, group.path.clone())) | ||
| 634 | })) | ||
| 635 | .collect() | ||
| 636 | } | ||
| 637 | |||
| 638 | /// The identity of a group's folder; none for the notebook's own. | ||
| 639 | fn folder_id(folder: &Folder) -> Option<[u8; 16]> { | ||
| 640 | let toc = folder.toc.as_ref()?; | ||
| 641 | (!folder.path.is_empty()).then_some(toc.file_id) | ||
| 642 | } | ||
| 643 | |||
| 644 | /// Every folder of `catalog`, the notebook's own first. | ||
| 645 | fn folders(catalog: &Folder) -> Vec<&Folder> { | ||
| 646 | let mut all = vec![catalog]; | ||
| 647 | let mut at = 0; | ||
| 648 | while let Some(folder) = all.get(at) { | ||
| 649 | all.extend(&folder.groups); | ||
| 650 | at += 1; | ||
| 651 | } | ||
| 652 | all | ||
| 653 | } | ||
| 654 | |||
| 655 | /// The folder `identity` names, the notebook's own without one. | ||
| 656 | fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { | ||
| 657 | match identity { | ||
| 658 | Some(_) => folders(catalog) | ||
| 659 | .into_iter() | ||
| 660 | .find(|folder| folder_id(folder) == identity), | ||
| 661 | None => Some(catalog), | ||
| 662 | } | ||
| 663 | } | ||
| 664 | |||
| 665 | /// The section or group `identity` names: its folder and catalog path. | ||
| 666 | fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { | ||
| 667 | folders(catalog).into_iter().find_map(|folder| { | ||
| 668 | let (_, path) = entries(folder) | ||
| 669 | .into_iter() | ||
| 670 | .find(|(id, _)| *id == identity)?; | ||
| 671 | Some((folder, path)) | ||
| 672 | }) | ||
| 673 | } | ||
| 674 | |||
| 675 | /// The identity of the section or group at catalog `path`. | ||
| 676 | fn identity_at(catalog: &Folder, path: &str) -> Option<[u8; 16]> { | ||
| 677 | folders(catalog).into_iter().find_map(|folder| { | ||
| 678 | let (id, _) = entries(folder).into_iter().find(|(_, at)| at == path)?; | ||
| 679 | Some(id) | ||
| 680 | }) | ||
| 681 | } | ||
| 682 | |||
| 683 | /// A section or group's name, as its catalog path ends. | ||
| 684 | fn name_of(path: &str) -> &str { | ||
| 685 | let name = path.rsplit('/').next().unwrap_or(path); | ||
| 686 | name.strip_suffix(".one").unwrap_or(name) | ||
| 687 | } | ||
| 688 | |||
| 689 | /// The action taking back `change` to `library`'s sections and groups, which is about to be | ||
| 690 | /// made; none for changes Undo does not take back, as OneNote 2010 takes back none. | ||
| 691 | pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> Option<Action> { | ||
| 692 | use crate::manage::Structure; | ||
| 693 | let catalog = library.catalog()?; | ||
| 694 | let change = match change { | ||
| 695 | Structure::Rename { path, name } => Change::Rename { | ||
| 696 | entry: identity_at(catalog, path)?, | ||
| 697 | from: name.clone(), | ||
| 698 | to: name_of(path).to_owned(), | ||
| 699 | }, | ||
| 700 | Structure::Move { path, .. } => { | ||
| 701 | let entry = identity_at(catalog, path)?; | ||
| 702 | let (folder, _) = entry_of(catalog, entry)?; | ||
| 703 | Change::Place { | ||
| 704 | entry: Some(entry), | ||
| 705 | folder: folder_id(folder), | ||
| 706 | order: entries(folder).into_iter().map(|(id, _)| id).collect(), | ||
| 707 | } | ||
| 708 | } | ||
| 709 | Structure::Reorder { folder, .. } => { | ||
| 710 | let folder = folders(catalog) | ||
| 711 | .into_iter() | ||
| 712 | .find(|listed| listed.path == *folder)?; | ||
| 713 | Change::Place { | ||
| 714 | entry: None, | ||
| 715 | folder: folder_id(folder), | ||
| 716 | order: entries(folder).into_iter().map(|(id, _)| id).collect(), | ||
| 717 | } | ||
| 718 | } | ||
| 719 | _ => return None, | ||
| 720 | }; | ||
| 721 | Some(Action { | ||
| 722 | notebook: library.location.clone(), | ||
| 723 | section: None, | ||
| 724 | change, | ||
| 725 | }) | ||
| 726 | } | ||
| 727 | |||
| 728 | /// The structure change carrying out a section or group's `change` in `library` now, with | ||
| 729 | /// the action taking it back; none where what it changes is gone or was changed since. | ||
| 730 | fn restructuring(library: &Library, change: &Change) -> Option<(crate::manage::Structure, Action)> { | ||
| 731 | use crate::manage::Structure; | ||
| 732 | let catalog = library.catalog()?; | ||
| 733 | let (structure, undo) = match change { | ||
| 734 | Change::Rename { entry, from, to } => { | ||
| 735 | let (_, path) = entry_of(catalog, *entry)?; | ||
| 736 | if name_of(&path) != from { | ||
| 737 | return None; | ||
| 738 | } | ||
| 739 | ( | ||
| 740 | Structure::Rename { | ||
| 741 | path, | ||
| 742 | name: to.clone(), | ||
| 743 | }, | ||
| 744 | Change::Rename { | ||
| 745 | entry: *entry, | ||
| 746 | from: to.clone(), | ||
| 747 | to: from.clone(), | ||
| 748 | }, | ||
| 749 | ) | ||
| 750 | } | ||
| 751 | Change::Place { | ||
| 752 | entry, | ||
| 753 | folder, | ||
| 754 | order, | ||
| 755 | } => { | ||
| 756 | let target = folder_of(catalog, *folder)?; | ||
| 757 | let placed = match entry { | ||
| 758 | Some(entry) => Some(entry_of(catalog, *entry)?), | ||
| 759 | None => None, | ||
| 760 | }; | ||
| 761 | // The folder the entry is in now, which the action taking this back reorders. | ||
| 762 | let now = placed.as_ref().map_or(target, |(folder, _)| *folder); | ||
| 763 | let undo = Change::Place { | ||
| 764 | entry: *entry, | ||
| 765 | folder: folder_id(now), | ||
| 766 | order: entries(now).into_iter().map(|(id, _)| id).collect(), | ||
| 767 | }; | ||
| 768 | let moving = placed.filter(|(from, _)| from.path != target.path); | ||
| 769 | let arrived = moving.as_ref().map(|(_, path)| { | ||
| 770 | let name = path.rsplit('/').next().unwrap_or(path); | ||
| 771 | match target.path.as_str() { | ||
| 772 | "" => name.to_owned(), | ||
| 773 | folder => format!("{folder}/{name}"), | ||
| 774 | } | ||
| 775 | }); | ||
| 776 | let current = entries(target); | ||
| 777 | let paths: Vec<String> = order | ||
| 778 | .iter() | ||
| 779 | .filter_map(|id| match &arrived { | ||
| 780 | Some(arrived) if Some(*id) == *entry => Some(arrived.clone()), | ||
| 781 | _ => current | ||
| 782 | .iter() | ||
| 783 | .find(|(at, _)| at == id) | ||
| 784 | .map(|(_, path)| path.clone()), | ||
| 785 | }) | ||
| 786 | .collect(); | ||
| 787 | let structure = match moving { | ||
| 788 | Some((_, path)) => Structure::Return { | ||
| 789 | path, | ||
| 790 | folder: target.path.clone(), | ||
| 791 | paths, | ||
| 792 | }, | ||
| 793 | None => Structure::Reorder { | ||
| 794 | folder: target.path.clone(), | ||
| 795 | paths, | ||
| 796 | }, | ||
| 797 | }; | ||
| 798 | (structure, undo) | ||
| 799 | } | ||
| 800 | _ => return None, | ||
| 801 | }; | ||
| 802 | Some(( | ||
| 803 | structure, | ||
| 804 | Action { | ||
| 805 | notebook: library.location.clone(), | ||
| 806 | section: None, | ||
| 807 | change: undo, | ||
| 808 | }, | ||
| 809 | )) | ||
| 810 | } | ||
| 811 | |||
| 812 | impl State { | ||
| 813 | /// The open section and its notebook, where Undo reaches. A section opened on its own | ||
| 814 | /// is its notebook's only one. | ||
| 815 | fn here(&self) -> Option<Here> { | ||
| 816 | let session = self.session.as_ref()?; | ||
| 817 | let path = &session.tabs.get(session.tab)?.path; | ||
| 818 | Some(Here { | ||
| 819 | notebook: session.library.location.clone(), | ||
| 820 | section: session.library.section_identity(path).unwrap_or_default(), | ||
| 821 | }) | ||
| 822 | } | ||
| 823 | |||
| 824 | /// Whether Undo, or `redo` Redo, has something to take. | ||
| 825 | pub(crate) fn can_step(&self, redo: bool) -> bool { | ||
| 826 | let editor = &self.view.editor; | ||
| 827 | (if redo { | ||
| 828 | editor.can_redo() | ||
| 829 | } else { | ||
| 830 | editor.can_undo() | ||
| 831 | }) || self | ||
| 832 | .here() | ||
| 833 | .is_some_and(|here| self.undo.reaches(redo, &here)) | ||
| 834 | } | ||
| 835 | |||
| 836 | /// Undo, or `redo` Redo: the open page's last edit or the last action made where the | ||
| 837 | /// user is, whichever came last. | ||
| 838 | pub(crate) fn step(&mut self, redo: bool) -> Result<(), Box<dyn Error>> { | ||
| 839 | self.persist()?; | ||
| 840 | let depth = self.view.editor.history_depth(); | ||
| 841 | // A page kept in no notebook has only its own history. | ||
| 842 | let shown = self.session.as_ref().map(|session| session.space); | ||
| 843 | let next = match (self.here(), shown) { | ||
| 844 | (Some(here), Some(page)) => self.undo.next(redo, page, depth, &here), | ||
| 845 | _ => Some(Next::Edit), | ||
| 846 | }; | ||
| 847 | match next { | ||
| 848 | None => {} | ||
| 849 | Some(Next::Edit) => { | ||
| 850 | let response = self.view.undo(redo)?; | ||
| 851 | self.respond(response); | ||
| 852 | if let Some(page) = shown | ||
| 853 | && self.view.editor.history_depth() != depth | ||
| 854 | { | ||
| 855 | self.undo.stepped(page, redo); | ||
| 856 | } | ||
| 857 | } | ||
| 858 | Some(Next::Show(page)) => self.commands.push(Command::OpenPage(page)), | ||
| 859 | Some(Next::Action(action)) => self.perform(action, Some(redo))?, | ||
| 860 | } | ||
| 861 | Ok(()) | ||
| 862 | } | ||
| 863 | |||
| 864 | /// Does `action`: one the user asked for, or that Undo, or `taken` Redo, took. What | ||
| 865 | /// takes it back goes on the timeline. | ||
| 866 | pub(crate) fn perform( | ||
| 867 | &mut self, | ||
| 868 | action: Action, | ||
| 869 | taken: Option<bool>, | ||
| 870 | ) -> Result<(), Box<dyn Error>> { | ||
| 871 | let Action { | ||
| 872 | notebook, | ||
| 873 | section, | ||
| 874 | change, | ||
| 875 | } = action; | ||
| 876 | let Some(section) = section else { | ||
| 877 | let library = self | ||
| 878 | .notebooks | ||
| 879 | .iter() | ||
| 880 | .find(|library| library.location == notebook) | ||
| 881 | .cloned() | ||
| 882 | .ok_or("That notebook is closed")?; | ||
| 883 | let Some((structure, undo)) = restructuring(&library, &change) else { | ||
| 884 | return match taken { | ||
| 885 | Some(redo) => self.step(redo), | ||
| 886 | None => Ok(()), | ||
| 887 | }; | ||
| 888 | }; | ||
| 889 | self.undo.note(taken, undo); | ||
| 890 | self.restructure(library, structure); | ||
| 891 | return Ok(()); | ||
| 892 | }; | ||
| 893 | self.persist()?; | ||
| 894 | let session = self.session.as_ref().ok_or("No section is open")?; | ||
| 895 | let site = Site { | ||
| 896 | replica: Arc::clone(session.section.replica()), | ||
| 897 | library: Arc::clone(&session.library), | ||
| 898 | author: self.author.clone(), | ||
| 899 | fresh: self.dated_page(None)?, | ||
| 900 | }; | ||
| 901 | let shown = session.space; | ||
| 902 | let proxy = self.proxy.clone(); | ||
| 903 | self.undo.busy += 1; | ||
| 904 | self.load(move || { | ||
| 905 | let applied = site.change(change, shown); | ||
| 906 | let show = match &applied { | ||
| 907 | Ok(Some(applied)) => applied.show, | ||
| 908 | _ => shown, | ||
| 909 | }; | ||
| 910 | let failed = applied.as_ref().err().map(ToString::to_string); | ||
| 911 | let (stale, applied) = (failed.is_none(), applied.ok().flatten()); | ||
| 912 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { | ||
| 913 | state.undo.busy -= 1; | ||
| 914 | let Some(applied) = applied else { | ||
| 915 | // What it would change is gone: Undo goes on to the step before. | ||
| 916 | return match taken { | ||
| 917 | Some(redo) if stale => state.step(redo), | ||
| 918 | _ => Ok(()), | ||
| 919 | }; | ||
| 920 | }; | ||
| 921 | state.undo.forget(&applied.gone); | ||
| 922 | let undo = Action { | ||
| 923 | notebook, | ||
| 924 | section: Some(section), | ||
| 925 | change: applied.undo, | ||
| 926 | }; | ||
| 927 | state.undo.note(taken, undo); | ||
| 928 | state.edited(Vec::new()); | ||
| 929 | Ok(()) | ||
| 930 | }))); | ||
| 931 | if let Some(failed) = failed { | ||
| 932 | return Err(failed.into()); | ||
| 933 | } | ||
| 934 | Ok(Loaded::Page(show, site.replica.page(show)?)) | ||
| 935 | }); | ||
| 936 | Ok(()) | ||
| 937 | } | ||
| 938 | |||
| 939 | /// An action the user takes on the open section's pages: `change`. | ||
| 940 | pub(crate) fn change_pages(&mut self, change: Change) -> Result<(), Box<dyn Error>> { | ||
| 941 | let here = self.here().ok_or("No section is open")?; | ||
| 942 | self.perform( | ||
| 943 | Action { | ||
| 944 | notebook: here.notebook, | ||
| 945 | section: Some(here.section), | ||
| 946 | change, | ||
| 947 | }, | ||
| 948 | None, | ||
| 949 | ) | ||
| 950 | } | ||
| 951 | |||
| 952 | /// Notes `change`, just made on the open section's pages, for Undo to take back. | ||
| 953 | pub(crate) fn made(&mut self, change: Change) { | ||
| 954 | if let Some(here) = self.here() { | ||
| 955 | self.undo.record(Action { | ||
| 956 | notebook: here.notebook, | ||
| 957 | section: Some(here.section), | ||
| 958 | change, | ||
| 959 | }); | ||
| 960 | } | ||
| 961 | } | ||
| 962 | } | ||
| 963 | |||
| 964 | #[cfg(test)] | ||
| 965 | mod tests { | ||
| 966 | use super::*; | ||
| 967 | use notebook::session::Notebook; | ||
| 968 | |||
| 969 | fn page(n: u32) -> ExGuid { | ||
| 970 | ExGuid { guid: [9; 16], n } | ||
| 971 | } | ||
| 972 | |||
| 973 | const SECTION: [u8; 16] = [1; 16]; | ||
| 974 | |||
| 975 | fn here() -> Here { | ||
| 976 | Here { | ||
| 977 | notebook: "/n".into(), | ||
| 978 | section: SECTION, | ||
| 979 | } | ||
| 980 | } | ||
| 981 | |||
| 982 | fn created(space: ExGuid) -> Action { | ||
| 983 | Action { | ||
| 984 | notebook: "/n".into(), | ||
| 985 | section: Some(SECTION), | ||
| 986 | change: Change::Delete { | ||
| 987 | pages: vec![space], | ||
| 988 | show: None, | ||
| 989 | created: true, | ||
| 990 | }, | ||
| 991 | } | ||
| 992 | } | ||
| 993 | |||
| 994 | fn is_action(next: Option<Next>) -> bool { | ||
| 995 | matches!(next, Some(Next::Action(_))) | ||
| 996 | } | ||
| 997 | |||
| 998 | fn is_edit(next: Option<Next>) -> bool { | ||
| 999 | matches!(next, Some(Next::Edit)) | ||
| 1000 | } | ||
| 1001 | |||
| 1002 | /// Cmd+N, a title typed, then Undo: the title goes, then the page. | ||
| 1003 | #[test] | ||
| 1004 | fn undo_takes_back_a_new_page_after_its_title() { | ||
| 1005 | let mut timeline = Timeline::default(); | ||
| 1006 | let (old, new) = (page(1), page(2)); | ||
| 1007 | timeline.edited(old, [3, 0], false); | ||
| 1008 | timeline.record(created(new)); | ||
| 1009 | timeline.edited(new, [1, 0], false); | ||
| 1010 | assert!(is_edit(timeline.next(false, new, [1, 0], &here()))); | ||
| 1011 | timeline.stepped(new, false); | ||
| 1012 | assert!(is_action(timeline.next(false, new, [0, 1], &here()))); | ||
| 1013 | // The page shown before keeps its own history. | ||
| 1014 | assert!(is_edit(timeline.next(false, old, [3, 0], &here()))); | ||
| 1015 | } | ||
| 1016 | |||
| 1017 | /// The newer of the page's own step and the last action goes first, as OneNote 2010 | ||
| 1018 | /// takes back a page deleted after typing on the page it then shows. | ||
| 1019 | #[test] | ||
| 1020 | fn the_newer_of_page_and_action_goes_first() { | ||
| 1021 | let mut timeline = Timeline::default(); | ||
| 1022 | let shown = page(1); | ||
| 1023 | timeline.edited(shown, [1, 0], false); | ||
| 1024 | timeline.record(created(page(5))); | ||
| 1025 | assert!(is_action(timeline.next(false, shown, [1, 0], &here()))); | ||
| 1026 | timeline.edited(shown, [2, 0], false); | ||
| 1027 | timeline.record(created(page(6))); | ||
| 1028 | // Typing that goes on a run started before the action is newer than it. | ||
| 1029 | timeline.edited(shown, [2, 0], true); | ||
| 1030 | assert!(is_edit(timeline.next(false, shown, [2, 0], &here()))); | ||
| 1031 | } | ||
| 1032 | |||
| 1033 | /// Another page's edits are its own; a new page holding edits is shown before it goes. | ||
| 1034 | #[test] | ||
| 1035 | fn a_created_page_with_edits_is_shown_first() { | ||
| 1036 | let mut timeline = Timeline::default(); | ||
| 1037 | let (other, new) = (page(1), page(2)); | ||
| 1038 | timeline.record(created(new)); | ||
| 1039 | timeline.edited(new, [2, 0], false); | ||
| 1040 | assert!(matches!( | ||
| 1041 | timeline.next(false, other, [0, 0], &here()), | ||
| 1042 | Some(Next::Show(shown)) if shown == new | ||
| 1043 | )); | ||
| 1044 | // Its edits taken back there, it goes. | ||
| 1045 | timeline.stepped(new, false); | ||
| 1046 | timeline.stepped(new, false); | ||
| 1047 | assert!(is_action(timeline.next(false, other, [0, 0], &here()))); | ||
| 1048 | } | ||
| 1049 | |||
| 1050 | /// Actions are reached where they were made: a page change in its section, a section | ||
| 1051 | /// change anywhere in its notebook. | ||
| 1052 | #[test] | ||
| 1053 | fn actions_are_reached_where_they_were_made() { | ||
| 1054 | let mut timeline = Timeline::default(); | ||
| 1055 | timeline.record(created(page(2))); | ||
| 1056 | timeline.record(Action { | ||
| 1057 | notebook: "/n".into(), | ||
| 1058 | section: None, | ||
| 1059 | change: Change::Rename { | ||
| 1060 | entry: [3; 16], | ||
| 1061 | from: "B".into(), | ||
| 1062 | to: "A".into(), | ||
| 1063 | }, | ||
| 1064 | }); | ||
| 1065 | let elsewhere = Here { | ||
| 1066 | notebook: "/n".into(), | ||
| 1067 | section: [2; 16], | ||
| 1068 | }; | ||
| 1069 | let other = Here { | ||
| 1070 | notebook: "/m".into(), | ||
| 1071 | section: SECTION, | ||
| 1072 | }; | ||
| 1073 | assert!(!timeline.reaches(false, &other)); | ||
| 1074 | assert!(matches!( | ||
| 1075 | timeline.next(false, page(7), [0, 0], &elsewhere), | ||
| 1076 | Some(Next::Action(Action { | ||
| 1077 | change: Change::Rename { .. }, | ||
| 1078 | .. | ||
| 1079 | })) | ||
| 1080 | )); | ||
| 1081 | assert!(timeline.next(false, page(7), [0, 0], &elsewhere).is_none()); | ||
| 1082 | assert!(is_action(timeline.next(false, page(7), [0, 0], &here()))); | ||
| 1083 | timeline.record(created(page(3))); | ||
| 1084 | timeline.close("/n"); | ||
| 1085 | assert!(!timeline.reaches(false, &here())); | ||
| 1086 | } | ||
| 1087 | |||
| 1088 | /// Redo takes back the last undone first; a new edit ends what actions it held, and | ||
| 1089 | /// waits while an action is on its way. | ||
| 1090 | #[test] | ||
| 1091 | fn redo_mirrors_undo_and_new_edits_end_it() { | ||
| 1092 | let mut timeline = Timeline::default(); | ||
| 1093 | let shown = page(1); | ||
| 1094 | timeline.record(created(page(2))); | ||
| 1095 | let Some(Next::Action(action)) = timeline.next(false, shown, [0, 0], &here()) else { | ||
| 1096 | panic!() | ||
| 1097 | }; | ||
| 1098 | timeline.note(Some(false), action); | ||
| 1099 | timeline.edited(shown, [1, 0], false); | ||
| 1100 | assert!(is_edit(timeline.next(false, shown, [1, 0], &here()))); | ||
| 1101 | timeline.stepped(shown, false); | ||
| 1102 | assert!(is_edit(timeline.next(true, shown, [0, 1], &here()))); | ||
| 1103 | // The undone action went with the edit made after it. | ||
| 1104 | timeline.stepped(shown, true); | ||
| 1105 | timeline.stepped(shown, false); | ||
| 1106 | assert!(!timeline.reaches(true, &here())); | ||
| 1107 | timeline.record(created(page(3))); | ||
| 1108 | timeline.busy = 1; | ||
| 1109 | assert!(timeline.next(false, shown, [0, 1], &here()).is_none()); | ||
| 1110 | } | ||
| 1111 | |||
| 1112 | const AUTHOR: &str = "Rust Author"; | ||
| 1113 | |||
| 1114 | fn dated() -> PageCreation { | ||
| 1115 | PageCreation::new(None, Some(""), AUTHOR) | ||
| 1116 | .unwrap() | ||
| 1117 | .dated("Wednesday, September 30, 2026", "7:04 PM") | ||
| 1118 | .unwrap() | ||
| 1119 | } | ||
| 1120 | |||
| 1121 | /// A notebook as the app makes one, its first section holding pages titled `titles` | ||
| 1122 | /// and open, beside a section "B"; deleted when dropped. | ||
| 1123 | struct Fixture { | ||
| 1124 | folder: std::path::PathBuf, | ||
| 1125 | library: Arc<Library>, | ||
| 1126 | section: Option<notebook::session::Section>, | ||
| 1127 | } | ||
| 1128 | |||
| 1129 | impl Fixture { | ||
| 1130 | fn new(name: &str, titles: &[&str]) -> Self { | ||
| 1131 | let folder = | ||
| 1132 | std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id())); | ||
| 1133 | let _ = std::fs::remove_dir_all(&folder); | ||
| 1134 | std::fs::create_dir_all(&folder).unwrap(); | ||
| 1135 | let root = folder.join("Notebook"); | ||
| 1136 | let mut notebook = | ||
| 1137 | Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()) | ||
| 1138 | .unwrap(); | ||
| 1139 | notebook.create_section("", "B", &dated()).unwrap(); | ||
| 1140 | let library = Arc::new(Library::created( | ||
| 1141 | root.to_str().unwrap(), | ||
| 1142 | notebook, | ||
| 1143 | &folder.join("cache"), | ||
| 1144 | )); | ||
| 1145 | let section = library.open("New Section 1.one", || {}).unwrap(); | ||
| 1146 | let fixture = Self { | ||
| 1147 | folder, | ||
| 1148 | library, | ||
| 1149 | section: Some(section), | ||
| 1150 | }; | ||
| 1151 | let first = fixture.pages()[0].0; | ||
| 1152 | fixture.retitle(first, titles[0]); | ||
| 1153 | for title in &titles[1..] { | ||
| 1154 | let creation = dated(); | ||
| 1155 | let space = creation.space(); | ||
| 1156 | fixture.edit(vec![Op::Section(SectionOp::Create(creation))]); | ||
| 1157 | fixture.retitle(space, title); | ||
| 1158 | } | ||
| 1159 | fixture | ||
| 1160 | } | ||
| 1161 | |||
| 1162 | fn section(&self) -> &notebook::session::Section { | ||
| 1163 | self.section.as_ref().unwrap() | ||
| 1164 | } | ||
| 1165 | |||
| 1166 | fn site(&self) -> Site { | ||
| 1167 | Site { | ||
| 1168 | replica: Arc::clone(self.section().replica()), | ||
| 1169 | library: Arc::clone(&self.library), | ||
| 1170 | author: AUTHOR.into(), | ||
| 1171 | fresh: dated(), | ||
| 1172 | } | ||
| 1173 | } | ||
| 1174 | |||
| 1175 | /// Another device's edit, or the user's outside Undo. | ||
| 1176 | fn edit(&self, ops: Vec<Op>) { | ||
| 1177 | self.section() | ||
| 1178 | .replica() | ||
| 1179 | .apply( | ||
| 1180 | AUTHOR, | ||
| 1181 | Edit { | ||
| 1182 | at: crate::filetime(), | ||
| 1183 | ops, | ||
| 1184 | }, | ||
| 1185 | ) | ||
| 1186 | .unwrap(); | ||
| 1187 | } | ||
| 1188 | |||
| 1189 | fn retitle(&self, space: ExGuid, title: &str) { | ||
| 1190 | let page = self.section().page(space).unwrap(); | ||
| 1191 | self.edit(vec![ | ||
| 1192 | crate::rename::retitled(&page, space, title.into()).unwrap(), | ||
| 1193 | ]); | ||
| 1194 | } | ||
| 1195 | |||
| 1196 | fn pages(&self) -> Vec<(ExGuid, String, u32)> { | ||
| 1197 | self.section().pages().unwrap() | ||
| 1198 | } | ||
| 1199 | |||
| 1200 | fn titles(&self) -> Vec<(String, u32)> { | ||
| 1201 | self.pages() | ||
| 1202 | .into_iter() | ||
| 1203 | .map(|(_, title, level)| (title, level)) | ||
| 1204 | .collect() | ||
| 1205 | } | ||
| 1206 | |||
| 1207 | fn space(&self, title: &str) -> ExGuid { | ||
| 1208 | self.pages() | ||
| 1209 | .into_iter() | ||
| 1210 | .find(|(_, listed, _)| listed == title) | ||
| 1211 | .unwrap() | ||
| 1212 | .0 | ||
| 1213 | } | ||
| 1214 | |||
| 1215 | /// The titles in the recycle bin's Deleted Pages. | ||
| 1216 | fn binned(&self) -> Vec<String> { | ||
| 1217 | let file = self | ||
| 1218 | .folder | ||
| 1219 | .join("Notebook/OneNote_RecycleBin/OneNote_DeletedPages.one"); | ||
| 1220 | std::fs::read(file).map_or_else( | ||
| 1221 | |_| Vec::new(), | ||
| 1222 | |bytes| { | ||
| 1223 | notebook::session::stored_pages(&bytes) | ||
| 1224 | .unwrap() | ||
| 1225 | .into_iter() | ||
| 1226 | .map(|stored| stored.page.title) | ||
| 1227 | .collect() | ||
| 1228 | }, | ||
| 1229 | ) | ||
| 1230 | } | ||
| 1231 | |||
| 1232 | /// Does `change`, as the app's thread does, returning what takes it back. | ||
| 1233 | fn change(&self, change: Change) -> Option<Applied> { | ||
| 1234 | let shown = self.pages()[0].0; | ||
| 1235 | self.site().change(change, shown).unwrap() | ||
| 1236 | } | ||
| 1237 | } | ||
| 1238 | |||
| 1239 | impl Drop for Fixture { | ||
| 1240 | fn drop(&mut self) { | ||
| 1241 | if let Some(section) = self.section.take() { | ||
| 1242 | let _ = section.close(); | ||
| 1243 | } | ||
| 1244 | let _ = std::fs::remove_dir_all(&self.folder); | ||
| 1245 | } | ||
| 1246 | } | ||
| 1247 | |||
| 1248 | fn titled(titles: &[&str]) -> Vec<(String, u32)> { | ||
| 1249 | titles.iter().map(|title| (title.to_string(), 1)).collect() | ||
| 1250 | } | ||
| 1251 | |||
| 1252 | /// A page deleted goes to the recycle bin; Undo puts it back where it was and takes it | ||
| 1253 | /// out of the bin, as OneNote 2010 does; Redo deletes it again. A page another device | ||
| 1254 | /// put back meanwhile is left alone. | ||
| 1255 | #[test] | ||
| 1256 | fn deleted_pages_come_back_where_they_were_and_leave_the_bin() { | ||
| 1257 | let fixture = Fixture::new("delete", &["First", "Second", "Third"]); | ||
| 1258 | let second = fixture.space("Second"); | ||
| 1259 | let deleted = fixture | ||
| 1260 | .change(Change::Delete { | ||
| 1261 | pages: vec![second], | ||
| 1262 | show: None, | ||
| 1263 | created: false, | ||
| 1264 | }) | ||
| 1265 | .unwrap(); | ||
| 1266 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); | ||
| 1267 | assert_eq!(deleted.show, fixture.space("Third")); | ||
| 1268 | assert_eq!(fixture.binned(), ["Second"]); | ||
| 1269 | let restored = fixture.change(deleted.undo).unwrap(); | ||
| 1270 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); | ||
| 1271 | assert_eq!(restored.show, fixture.space("Second")); | ||
| 1272 | assert!(fixture.binned().is_empty()); | ||
| 1273 | let again = fixture.change(restored.undo).unwrap(); | ||
| 1274 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); | ||
| 1275 | assert_eq!(fixture.binned(), ["Second"]); | ||
| 1276 | |||
| 1277 | // Put back elsewhere first, the page stays as that device left it. | ||
| 1278 | let Change::Restore { pages, .. } = &again.undo else { | ||
| 1279 | panic!() | ||
| 1280 | }; | ||
| 1281 | let copy = notebook::session::moved(&pages[0].page, AUTHOR).unwrap(); | ||
| 1282 | fixture.edit(vec![copy]); | ||
| 1283 | assert!(fixture.change(again.undo).is_none()); | ||
| 1284 | assert_eq!(fixture.titles(), titled(&["First", "Third", "Second"])); | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | /// New Page taken back: the page goes for good while it holds nothing, the page shown | ||
| 1288 | /// before it shows again, and Redo brings it back. Once another device wrote on it, | ||
| 1289 | /// it stays. | ||
| 1290 | #[test] | ||
| 1291 | fn a_new_page_goes_only_while_it_holds_nothing() { | ||
| 1292 | let fixture = Fixture::new("create", &["First", "Second"]); | ||
| 1293 | let creation = dated(); | ||
| 1294 | let new = creation.space(); | ||
| 1295 | fixture.edit(vec![Op::Section(SectionOp::Create(creation))]); | ||
| 1296 | let first = fixture.space("First"); | ||
| 1297 | let removed = fixture | ||
| 1298 | .change(Change::Delete { | ||
| 1299 | pages: vec![new], | ||
| 1300 | show: Some(first), | ||
| 1301 | created: true, | ||
| 1302 | }) | ||
| 1303 | .unwrap(); | ||
| 1304 | assert_eq!(fixture.titles(), titled(&["First", "Second"])); | ||
| 1305 | assert_eq!(removed.show, first); | ||
| 1306 | assert!(fixture.binned().is_empty()); | ||
| 1307 | let back = fixture.change(removed.undo).unwrap(); | ||
| 1308 | assert_eq!(fixture.titles(), titled(&["First", "Second", ""])); | ||
| 1309 | let Change::Delete { pages, .. } = &back.undo else { | ||
| 1310 | panic!() | ||
| 1311 | }; | ||
| 1312 | fixture.retitle(pages[0], "Written elsewhere"); | ||
| 1313 | assert!(fixture.change(back.undo).is_none()); | ||
| 1314 | assert_eq!( | ||
| 1315 | fixture.titles(), | ||
| 1316 | titled(&["First", "Second", "Written elsewhere"]) | ||
| 1317 | ); | ||
| 1318 | } | ||
| 1319 | |||
| 1320 | /// Pages moved and indented go back to their places and levels, before the next page | ||
| 1321 | /// still there when another device deleted the one they followed. | ||
| 1322 | #[test] | ||
| 1323 | fn arranged_pages_go_back_before_the_next_page_still_there() { | ||
| 1324 | let fixture = Fixture::new("arrange", &["A", "B", "C", "D"]); | ||
| 1325 | let order: Vec<(ExGuid, u32)> = fixture | ||
| 1326 | .pages() | ||
| 1327 | .into_iter() | ||
| 1328 | .map(|(space, _, level)| (space, level)) | ||
| 1329 | .collect(); | ||
| 1330 | let [a, b, c, d] = ["A", "B", "C", "D"].map(|title| fixture.space(title)); | ||
| 1331 | fixture.edit(vec![Op::Section(SectionOp::Pages(vec![ | ||
| 1332 | PageEdit::move_to(a, None, 1).unwrap(), | ||
| 1333 | PageEdit::set_level(c, 2).unwrap(), | ||
| 1334 | ]))]); | ||
| 1335 | assert_eq!( | ||
| 1336 | fixture.titles(), | ||
| 1337 | [("B", 1), ("C", 2), ("D", 1), ("A", 1)].map(|(t, l)| (t.to_owned(), l)) | ||
| 1338 | ); | ||
| 1339 | let arrange = Change::Arrange { | ||
| 1340 | order: order.clone(), | ||
| 1341 | moved: vec![a, c], | ||
| 1342 | }; | ||
| 1343 | let arranged = fixture.change(arrange).unwrap(); | ||
| 1344 | assert_eq!(fixture.titles(), titled(&["A", "B", "C", "D"])); | ||
| 1345 | assert_eq!(arranged.show, a); | ||
| 1346 | fixture.change(arranged.undo).unwrap(); | ||
| 1347 | fixture.edit(vec![Op::Section(SectionOp::Delete(vec![b]))]); | ||
| 1348 | fixture | ||
| 1349 | .change(Change::Arrange { | ||
| 1350 | order, | ||
| 1351 | moved: vec![a, c, d], | ||
| 1352 | }) | ||
| 1353 | .unwrap(); | ||
| 1354 | assert_eq!(fixture.titles(), titled(&["A", "C", "D"])); | ||
| 1355 | } | ||
| 1356 | |||
| 1357 | /// A page sent to another section comes back where it was and leaves that section; | ||
| 1358 | /// gone from there meanwhile, it does not. | ||
| 1359 | #[test] | ||
| 1360 | fn a_page_sent_away_comes_back() { | ||
| 1361 | let fixture = Fixture::new("send", &["First", "Second", "Third"]); | ||
| 1362 | let target = fixture.library.section_identity("B.one").unwrap(); | ||
| 1363 | let in_b = |fixture: &Fixture| { | ||
| 1364 | let section = fixture.library.open("B.one", || {}).unwrap(); | ||
| 1365 | let titles: Vec<String> = section | ||
| 1366 | .pages() | ||
| 1367 | .unwrap() | ||
| 1368 | .into_iter() | ||
| 1369 | .map(|(_, title, _)| title) | ||
| 1370 | .collect(); | ||
| 1371 | section.close().unwrap(); | ||
| 1372 | titles | ||
| 1373 | }; | ||
| 1374 | let sent = fixture | ||
| 1375 | .change(Change::Send { | ||
| 1376 | page: fixture.space("Second"), | ||
| 1377 | target, | ||
| 1378 | }) | ||
| 1379 | .unwrap(); | ||
| 1380 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); | ||
| 1381 | assert_eq!(in_b(&fixture), ["", "Second"]); | ||
| 1382 | let returned = fixture.change(sent.undo).unwrap(); | ||
| 1383 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); | ||
| 1384 | assert_eq!(in_b(&fixture), [""]); | ||
| 1385 | let sent = fixture.change(returned.undo).unwrap(); | ||
| 1386 | let Change::Return { page, .. } = &sent.undo else { | ||
| 1387 | panic!() | ||
| 1388 | }; | ||
| 1389 | let section = fixture.library.open("B.one", || {}).unwrap(); | ||
| 1390 | section.delete_pages(&[*page]).unwrap(); | ||
| 1391 | section.close().unwrap(); | ||
| 1392 | assert!(fixture.change(sent.undo).is_none()); | ||
| 1393 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); | ||
| 1394 | } | ||
| 1395 | |||
| 1396 | /// A title given in the page list goes back while the page still has it. | ||
| 1397 | #[test] | ||
| 1398 | fn a_title_goes_back_while_the_page_has_it() { | ||
| 1399 | let fixture = Fixture::new("retitle", &["First", "Renamed"]); | ||
| 1400 | let page = fixture.space("Renamed"); | ||
| 1401 | let back = fixture | ||
| 1402 | .change(Change::Retitle { | ||
| 1403 | page, | ||
| 1404 | from: "Renamed".into(), | ||
| 1405 | to: "Second".into(), | ||
| 1406 | }) | ||
| 1407 | .unwrap(); | ||
| 1408 | assert_eq!(fixture.titles(), titled(&["First", "Second"])); | ||
| 1409 | fixture.retitle(page, "Changed elsewhere"); | ||
| 1410 | assert!(fixture.change(back.undo).is_none()); | ||
| 1411 | } | ||
| 1412 | |||
| 1413 | /// Carries out `change` as `manage::State::restructure` does. | ||
| 1414 | fn carry(notebook: &mut Notebook, change: crate::manage::Structure) { | ||
| 1415 | use crate::manage::Structure; | ||
| 1416 | match change { | ||
| 1417 | Structure::Rename { path, name } => drop(notebook.rename(&path, &name).unwrap()), | ||
| 1418 | Structure::Move { path, folder } => drop(notebook.move_entry(&path, &folder).unwrap()), | ||
| 1419 | Structure::Reorder { folder, paths } => { | ||
| 1420 | let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); | ||
| 1421 | notebook.reorder(&folder, &paths).unwrap(); | ||
| 1422 | } | ||
| 1423 | Structure::Return { | ||
| 1424 | path, | ||
| 1425 | folder, | ||
| 1426 | paths, | ||
| 1427 | } => { | ||
| 1428 | notebook.move_entry(&path, &folder).unwrap(); | ||
| 1429 | let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); | ||
| 1430 | notebook.reorder(&folder, &paths).unwrap(); | ||
| 1431 | } | ||
| 1432 | _ => unreachable!(), | ||
| 1433 | } | ||
| 1434 | } | ||
| 1435 | |||
| 1436 | /// Sections renamed, reordered and moved into a group go back, the moved one to its | ||
| 1437 | /// place among the others; renamed again elsewhere, a section keeps that name. | ||
| 1438 | #[test] | ||
| 1439 | fn sections_go_back_to_their_names_and_places() { | ||
| 1440 | use crate::manage::Structure; | ||
| 1441 | let folder = | ||
| 1442 | std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id())); | ||
| 1443 | let _ = std::fs::remove_dir_all(&folder); | ||
| 1444 | std::fs::create_dir_all(&folder).unwrap(); | ||
| 1445 | let root = folder.join("Notebook"); | ||
| 1446 | let mut notebook = | ||
| 1447 | Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap(); | ||
| 1448 | for name in ["B", "C"] { | ||
| 1449 | notebook.create_section("", name, &dated()).unwrap(); | ||
| 1450 | } | ||
| 1451 | notebook.create_group("", "Group").unwrap(); | ||
| 1452 | let order = |notebook: &Notebook, folder: &str| -> Vec<String> { | ||
| 1453 | let folder = folders(notebook.catalog()) | ||
| 1454 | .into_iter() | ||
| 1455 | .find(|listed| listed.path == folder) | ||
| 1456 | .unwrap(); | ||
| 1457 | entries(folder).into_iter().map(|(_, path)| path).collect() | ||
| 1458 | }; | ||
| 1459 | let opened = Library::created(root.to_str().unwrap(), notebook, &folder.join("cache")); | ||
| 1460 | let library = |notebook: Notebook| opened.with(notebook); | ||
| 1461 | let mut notebook = opened.reopen().unwrap(); | ||
| 1462 | let mut steps = Vec::new(); | ||
| 1463 | for change in [ | ||
| 1464 | Structure::Rename { | ||
| 1465 | path: "B.one".into(), | ||
| 1466 | name: "Kitchen".into(), | ||
| 1467 | }, | ||
| 1468 | Structure::Reorder { | ||
| 1469 | folder: String::new(), | ||
| 1470 | paths: vec!["C.one".into(), "New Section 1.one".into()], | ||
| 1471 | }, | ||
| 1472 | Structure::Move { | ||
| 1473 | path: "New Section 1.one".into(), | ||
| 1474 | folder: "Group".into(), | ||
| 1475 | }, | ||
| 1476 | ] { | ||
| 1477 | let shown = library(notebook); | ||
| 1478 | steps.push(structure_undo(&shown, &change).unwrap()); | ||
| 1479 | notebook = shown.reopen().unwrap(); | ||
| 1480 | carry(&mut notebook, change); | ||
| 1481 | } | ||
| 1482 | assert_eq!(order(&notebook, ""), ["C.one", "Kitchen.one", "Group"]); | ||
| 1483 | assert_eq!(order(&notebook, "Group"), ["Group/New Section 1.one"]); | ||
| 1484 | let mut redo = Vec::new(); | ||
| 1485 | while let Some(step) = steps.pop() { | ||
| 1486 | let shown = library(notebook); | ||
| 1487 | let (structure, undo) = restructuring(&shown, &step.change).unwrap(); | ||
| 1488 | redo.push(undo); | ||
| 1489 | notebook = shown.reopen().unwrap(); | ||
| 1490 | carry(&mut notebook, structure); | ||
| 1491 | } | ||
| 1492 | assert_eq!( | ||
| 1493 | order(&notebook, ""), | ||
| 1494 | ["New Section 1.one", "B.one", "C.one", "Group"] | ||
| 1495 | ); | ||
| 1496 | // Redo, then Undo of a rename another device changed since leaves it. | ||
| 1497 | let shown = library(notebook); | ||
| 1498 | let rename = redo.pop().unwrap(); | ||
| 1499 | let (structure, undo) = restructuring(&shown, &rename.change).unwrap(); | ||
| 1500 | notebook = shown.reopen().unwrap(); | ||
| 1501 | carry(&mut notebook, structure); | ||
| 1502 | notebook.rename("Kitchen.one", "Pantry").unwrap(); | ||
| 1503 | assert!(restructuring(&library(notebook), &undo.change).is_none()); | ||
| 1504 | let _ = std::fs::remove_dir_all(&folder); | ||
| 1505 | } | ||
| 1506 | |||
| 1507 | /// Every kind of step taken back and redone in one notebook, written as the app writes | ||
| 1508 | /// it. `SNOWBOUND_UNDO_EXPORT` names a new directory that receives it for a cold | ||
| 1509 | /// reopen in OneNote 2010: "New Section 1" lists First, Second, Fourth and Recreated, | ||
| 1510 | /// "B" its own page, and Deleted Pages only Third. | ||
| 1511 | #[test] | ||
| 1512 | fn every_step_back_is_stored_as_onenote_stores_one() { | ||
| 1513 | let mut fixture = Fixture::new("export", &["First", "Second", "Third", "Fourth"]); | ||
| 1514 | let back = |fixture: &Fixture, change| { | ||
| 1515 | let done = fixture.change(change).unwrap(); | ||
| 1516 | fixture.change(done.undo).unwrap() | ||
| 1517 | }; | ||
| 1518 | back( | ||
| 1519 | &fixture, | ||
| 1520 | Change::Delete { | ||
| 1521 | pages: vec![fixture.space("Second")], | ||
| 1522 | show: None, | ||
| 1523 | created: false, | ||
| 1524 | }, | ||
| 1525 | ); | ||
| 1526 | fixture.change(Change::Delete { | ||
| 1527 | pages: vec![fixture.space("Third")], | ||
| 1528 | show: None, | ||
| 1529 | created: false, | ||
| 1530 | }); | ||
| 1531 | let order = fixture | ||
| 1532 | .pages() | ||
| 1533 | .into_iter() | ||
| 1534 | .map(|(space, _, level)| (space, level)) | ||
| 1535 | .collect(); | ||
| 1536 | let fourth = fixture.space("Fourth"); | ||
| 1537 | fixture.edit(vec![Op::Section(SectionOp::Pages(vec![ | ||
| 1538 | PageEdit::move_to(fourth, Some(fixture.space("First")), 1).unwrap(), | ||
| 1539 | ]))]); | ||
| 1540 | fixture.change(Change::Arrange { | ||
| 1541 | order, | ||
| 1542 | moved: vec![fourth], | ||
| 1543 | }); | ||
| 1544 | back( | ||
| 1545 | &fixture, | ||
| 1546 | Change::Send { | ||
| 1547 | page: fixture.space("First"), | ||
| 1548 | target: fixture.library.section_identity("B.one").unwrap(), | ||
| 1549 | }, | ||
| 1550 | ); | ||
| 1551 | let creation = dated(); | ||
| 1552 | let created = creation.space(); | ||
| 1553 | fixture.edit(vec![Op::Section(SectionOp::Create(creation))]); | ||
| 1554 | let restored = back( | ||
| 1555 | &fixture, | ||
| 1556 | Change::Delete { | ||
| 1557 | pages: vec![created], | ||
| 1558 | show: None, | ||
| 1559 | created: true, | ||
| 1560 | }, | ||
| 1561 | ); | ||
| 1562 | fixture.retitle(restored.show, "Recreated"); | ||
| 1563 | assert_eq!( | ||
| 1564 | fixture.titles(), | ||
| 1565 | titled(&["First", "Second", "Fourth", "Recreated"]) | ||
| 1566 | ); | ||
| 1567 | assert_eq!(fixture.binned(), ["Third"]); | ||
| 1568 | |||
| 1569 | let mut notebook = fixture.library.reopen().unwrap(); | ||
| 1570 | for change in [ | ||
| 1571 | crate::manage::Structure::Rename { | ||
| 1572 | path: "B.one".into(), | ||
| 1573 | name: "Kitchen".into(), | ||
| 1574 | }, | ||
| 1575 | crate::manage::Structure::Reorder { | ||
| 1576 | folder: String::new(), | ||
| 1577 | paths: vec!["B.one".into(), "New Section 1.one".into()], | ||
| 1578 | }, | ||
| 1579 | ] { | ||
| 1580 | let shown = fixture.library.with(notebook); | ||
| 1581 | let undo = structure_undo(&shown, &change).unwrap(); | ||
| 1582 | notebook = shown.reopen().unwrap(); | ||
| 1583 | carry(&mut notebook, change); | ||
| 1584 | let (structure, _) = | ||
| 1585 | restructuring(&fixture.library.with(notebook), &undo.change).unwrap(); | ||
| 1586 | notebook = fixture.library.reopen().unwrap(); | ||
| 1587 | carry(&mut notebook, structure); | ||
| 1588 | } | ||
| 1589 | let order: Vec<String> = entries(notebook.catalog()) | ||
| 1590 | .into_iter() | ||
| 1591 | .map(|(_, path)| path) | ||
| 1592 | .collect(); | ||
| 1593 | assert_eq!(order, ["New Section 1.one", "B.one"]); | ||
| 1594 | |||
| 1595 | fixture.section.take().unwrap().close().unwrap(); | ||
| 1596 | if let Some(directory) = std::env::var_os("SNOWBOUND_UNDO_EXPORT") { | ||
| 1597 | copy( | ||
| 1598 | &fixture.folder.join("Notebook"), | ||
| 1599 | std::path::Path::new(&directory), | ||
| 1600 | ); | ||
| 1601 | } | ||
| 1602 | } | ||
| 1603 | |||
| 1604 | fn copy(from: &std::path::Path, to: &std::path::Path) { | ||
| 1605 | std::fs::create_dir_all(to).unwrap(); | ||
| 1606 | for entry in std::fs::read_dir(from).unwrap().flatten() { | ||
| 1607 | let target = to.join(entry.file_name()); | ||
| 1608 | if entry.file_type().unwrap().is_dir() { | ||
| 1609 | copy(&entry.path(), &target); | ||
| 1610 | } else { | ||
| 1611 | std::fs::copy(entry.path(), target).unwrap(); | ||
| 1612 | } | ||
| 1613 | } | ||
| 1614 | } | ||
| 1615 | |||
| 1616 | /// Steps a page's editor no longer holds, dropped by a change made elsewhere, leave the | ||
| 1617 | /// timeline; a page gone takes its steps. | ||
| 1618 | #[test] | ||
| 1619 | fn steps_follow_the_page_history() { | ||
| 1620 | let mut timeline = Timeline::default(); | ||
| 1621 | let shown = page(1); | ||
| 1622 | timeline.edited(shown, [3, 0], false); | ||
| 1623 | timeline.record(created(page(2))); | ||
| 1624 | timeline.edited(shown, [1, 0], false); | ||
| 1625 | assert!(is_action(timeline.next(false, shown, [1, 0], &here()))); | ||
| 1626 | assert!(is_edit(timeline.next(false, shown, [1, 0], &here()))); | ||
| 1627 | timeline.forget(&[shown]); | ||
| 1628 | assert!(timeline.next(false, shown, [0, 0], &here()).is_none()); | ||
| 1629 | } | ||
| 1630 | } | ||
crates/snowbound/src/windows.rs+189-3| ... | @@ -158,14 +158,72 @@ fn composed() -> bool { | ... | @@ -158,14 +158,72 @@ fn composed() -> bool { |
| 158 | unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 } | 158 | unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 } |
| 159 | } | 159 | } |
| 160 | 160 | ||
| 161 | pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> { | 161 | pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> { |
| 162 | let event_loop = EventLoop::with_user_event().build()?; | 162 | let event_loop = EventLoop::with_user_event().build()?; |
| 163 | QUIT.set(event_loop.create_proxy()) | 163 | QUIT.set(event_loop.create_proxy()) |
| 164 | .expect("Only one application event loop is created"); | 164 | .expect("Only one application event loop is created"); |
| 165 | COMPOSED.store(composed(), Ordering::Relaxed); | 165 | COMPOSED.store(composed(), Ordering::Relaxed); |
| 166 | if !headless { | ||
| 167 | offer_to_open(); | ||
| 168 | } | ||
| 166 | Ok(event_loop) | 169 | Ok(event_loop) |
| 167 | } | 170 | } |
| 168 | 171 | ||
| 172 | /// Lists this executable under Open with for OneNote's sections and tables of contents, for | ||
| 173 | /// this user, leaving the app that opens them as it was; a moved executable lists its new | ||
| 174 | /// path. One cargo built and runs where it put it, in a folder CACHEDIR.TAG marks, isn't listed. | ||
| 175 | fn offer_to_open() { | ||
| 176 | use windows_sys::Win32::System::Registry; | ||
| 177 | const PROG_ID: &str = "Snowbound.OneNote"; | ||
| 178 | let Ok(executable) = std::env::current_exe() else { | ||
| 179 | return; | ||
| 180 | }; | ||
| 181 | if executable | ||
| 182 | .ancestors() | ||
| 183 | .any(|folder| folder.join("CACHEDIR.TAG").exists()) | ||
| 184 | { | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | let executable = executable.display(); | ||
| 188 | let set = |key: &str, value: Option<&str>, data: &str| { | ||
| 189 | let (data, value) = (wide(data), value.map(wide)); | ||
| 190 | let result = unsafe { | ||
| 191 | Registry::RegSetKeyValueW( | ||
| 192 | Registry::HKEY_CURRENT_USER, | ||
| 193 | wide(format!(r"Software\Classes\{key}")).as_ptr(), | ||
| 194 | value | ||
| 195 | .as_ref() | ||
| 196 | .map_or(std::ptr::null(), |value| value.as_ptr()), | ||
| 197 | Registry::REG_SZ, | ||
| 198 | data.as_ptr().cast(), | ||
| 199 | (data.len() * 2) as u32, | ||
| 200 | ) | ||
| 201 | }; | ||
| 202 | if result != 0 { | ||
| 203 | eprintln!("Cannot register {key}: error {result}"); | ||
| 204 | } | ||
| 205 | }; | ||
| 206 | set(PROG_ID, None, "OneNote File"); | ||
| 207 | set( | ||
| 208 | &format!(r"{PROG_ID}\DefaultIcon"), | ||
| 209 | None, | ||
| 210 | &format!("{executable},0"), | ||
| 211 | ); | ||
| 212 | set( | ||
| 213 | &format!(r"{PROG_ID}\shell\open"), | ||
| 214 | Some("FriendlyAppName"), | ||
| 215 | "Snowbound", | ||
| 216 | ); | ||
| 217 | set( | ||
| 218 | &format!(r"{PROG_ID}\shell\open\command"), | ||
| 219 | None, | ||
| 220 | &format!("\"{executable}\" \"%1\""), | ||
| 221 | ); | ||
| 222 | for extension in [".one", ".onetoc2"] { | ||
| 223 | set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), ""); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 169 | /// The executable's icon, and where the row draws the caption buttons, no system frame: | 227 | /// The executable's icon, and where the row draws the caption buttons, no system frame: |
| 170 | /// the desktop draws the window's shadow. | 228 | /// the desktop draws the window's shadow. |
| 171 | pub fn window_attributes() -> WindowAttributes { | 229 | pub fn window_attributes() -> WindowAttributes { |
| ... | @@ -760,8 +818,132 @@ pub fn move_cursor() -> winit::window::CursorIcon { | ... | @@ -760,8 +818,132 @@ pub fn move_cursor() -> winit::window::CursorIcon { |
| 760 | /// No system border lies under the chrome. | 818 | /// No system border lies under the chrome. |
| 761 | pub fn cover_border_line(_: &mut Ui, _: f32) {} | 819 | pub fn cover_border_line(_: &mut Ui, _: f32) {} |
| 762 | 820 | ||
| 763 | pub fn with_pool<R>(run: impl FnOnce() -> R) -> R { | 821 | /// Runs the app. Where nothing reads stderr, as when Explorer starts it, stderr goes to a |
| 764 | run() | 822 | /// log, with panics' backtraces and the faults that end the process, and a run that ends |
| 823 | /// in an error or a panic says where the log is. | ||
| 824 | pub fn with_pool( | ||
| 825 | run: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>, | ||
| 826 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 827 | use std::panic; | ||
| 828 | let log = log_stderr(); | ||
| 829 | let (major, minor, build) = version(); | ||
| 830 | eprintln!( | ||
| 831 | "Snowbound {} on Windows {major}.{minor}.{build}", | ||
| 832 | option_env!("SNOWBOUND_BUILD").unwrap_or("development") | ||
| 833 | ); | ||
| 834 | panic::set_hook(Box::new(|info| { | ||
| 835 | let thread = std::thread::current(); | ||
| 836 | let backtrace = std::backtrace::Backtrace::force_capture(); | ||
| 837 | eprintln!( | ||
| 838 | "Thread {:?} {info}\n{backtrace}", | ||
| 839 | thread.name().unwrap_or("") | ||
| 840 | ); | ||
| 841 | })); | ||
| 842 | unsafe { | ||
| 843 | windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault)) | ||
| 844 | }; | ||
| 845 | let result = panic::catch_unwind(panic::AssertUnwindSafe(run)); | ||
| 846 | let Some(log) = log else { | ||
| 847 | return result.unwrap_or_else(|payload| panic::resume_unwind(payload)); | ||
| 848 | }; | ||
| 849 | let details = format!("The log is at {}.", log.display()); | ||
| 850 | // The window is gone, and a message box it owned wouldn't show. | ||
| 851 | WINDOW.store(0, Ordering::Relaxed); | ||
| 852 | match result { | ||
| 853 | Ok(Err(error)) => { | ||
| 854 | alert(&error.to_string(), &details); | ||
| 855 | Err(error) | ||
| 856 | } | ||
| 857 | Ok(done) => done, | ||
| 858 | Err(payload) => { | ||
| 859 | alert("Snowbound stopped because of a problem.", &details); | ||
| 860 | panic::resume_unwind(payload) | ||
| 861 | } | ||
| 862 | } | ||
| 863 | } | ||
| 864 | |||
| 865 | /// Points stderr at `snowbound.log` in the cache folder where it goes nowhere, keeping the | ||
| 866 | /// last run's as `snowbound.old.log`. | ||
| 867 | fn log_stderr() -> Option<PathBuf> { | ||
| 868 | use std::os::windows::io::IntoRawHandle; | ||
| 869 | use windows_sys::Win32::{ | ||
| 870 | Storage::FileSystem::{FILE_TYPE_UNKNOWN, GetFileType}, | ||
| 871 | System::Console::{GetStdHandle, STD_ERROR_HANDLE, SetStdHandle}, | ||
| 872 | }; | ||
| 873 | // A console's handles inherited without the console are as good as none. | ||
| 874 | if unsafe { GetFileType(GetStdHandle(STD_ERROR_HANDLE)) } != FILE_TYPE_UNKNOWN { | ||
| 875 | return None; | ||
| 876 | } | ||
| 877 | let folder = cache_dir()?; | ||
| 878 | std::fs::create_dir_all(&folder).ok()?; | ||
| 879 | let log = folder.join("snowbound.log"); | ||
| 880 | let _ = std::fs::rename(&log, folder.join("snowbound.old.log")); | ||
| 881 | let file = std::fs::File::create(&log).ok()?; | ||
| 882 | // Leaked: stderr writes to it until the process ends. | ||
| 883 | unsafe { SetStdHandle(STD_ERROR_HANDLE, file.into_raw_handle()) }; | ||
| 884 | Some(log) | ||
| 885 | } | ||
| 886 | |||
| 887 | /// Logs an access violation or a bad instruction, such as a driver's, by the module it | ||
| 888 | /// happened in. Seen before any handler, as Windows reports one from a window procedure | ||
| 889 | /// to no unhandled-exception filter; it usually ends the process. | ||
| 890 | unsafe extern "system" fn fault( | ||
| 891 | pointers: *mut windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS, | ||
| 892 | ) -> i32 { | ||
| 893 | use windows_sys::Win32::{ | ||
| 894 | Foundation::{ | ||
| 895 | EXCEPTION_ACCESS_VIOLATION, EXCEPTION_ILLEGAL_INSTRUCTION, EXCEPTION_PRIV_INSTRUCTION, | ||
| 896 | }, | ||
| 897 | System::LibraryLoader::{ | ||
| 898 | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, | ||
| 899 | GetModuleFileNameW, GetModuleHandleExW, | ||
| 900 | }, | ||
| 901 | }; | ||
| 902 | let record = unsafe { &*(*pointers).ExceptionRecord }; | ||
| 903 | if ![ | ||
| 904 | EXCEPTION_ACCESS_VIOLATION, | ||
| 905 | EXCEPTION_ILLEGAL_INSTRUCTION, | ||
| 906 | EXCEPTION_PRIV_INSTRUCTION, | ||
| 907 | ] | ||
| 908 | .contains(&record.ExceptionCode) | ||
| 909 | { | ||
| 910 | return 0; | ||
| 911 | } | ||
| 912 | let place = |address: usize| { | ||
| 913 | let mut module = std::ptr::null_mut(); | ||
| 914 | let mut name = [0u16; 260]; | ||
| 915 | let found = unsafe { | ||
| 916 | GetModuleHandleExW( | ||
| 917 | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | ||
| 918 | | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, | ||
| 919 | address as *const u16, | ||
| 920 | &mut module, | ||
| 921 | ) != 0 | ||
| 922 | && GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) != 0 | ||
| 923 | }; | ||
| 924 | if found { | ||
| 925 | format!("{} + {:#x}", narrow(&name), address - module as usize) | ||
| 926 | } else { | ||
| 927 | format!("{address:#x}, in no module") | ||
| 928 | } | ||
| 929 | }; | ||
| 930 | // Where a call went to a bad address, the backtrace can't leave it: the return address | ||
| 931 | // names the caller. | ||
| 932 | let context = unsafe { &*(*pointers).ContextRecord }; | ||
| 933 | #[cfg(target_arch = "x86_64")] | ||
| 934 | let caller = unsafe { *(context.Rsp as *const usize) }; | ||
| 935 | #[cfg(target_arch = "aarch64")] | ||
| 936 | let caller = unsafe { context.Anonymous.Anonymous.Lr } as usize; | ||
| 937 | eprintln!( | ||
| 938 | "Fault {:#010x} at {}, on {:#x}, perhaps called from {}\n{}", | ||
| 939 | record.ExceptionCode as u32, | ||
| 940 | place(record.ExceptionAddress as usize), | ||
| 941 | record.ExceptionInformation[1], | ||
| 942 | place(caller), | ||
| 943 | std::backtrace::Backtrace::force_capture() | ||
| 944 | ); | ||
| 945 | // EXCEPTION_CONTINUE_SEARCH. | ||
| 946 | 0 | ||
| 765 | } | 947 | } |
| 766 | 948 | ||
| 767 | /// The interface keeps its own font, which fontique finds as Segoe UI. | 949 | /// The interface keeps its own font, which fontique finds as Segoe UI. |
| ... | @@ -820,6 +1002,10 @@ impl Clipboard { | ... | @@ -820,6 +1002,10 @@ impl Clipboard { |
| 820 | self.0.get().file_list().unwrap_or_default() | 1002 | self.0.get().file_list().unwrap_or_default() |
| 821 | } | 1003 | } |
| 822 | 1004 | ||
| 1005 | pub fn get_html(&mut self) -> Option<String> { | ||
| 1006 | self.0.get().html().ok() | ||
| 1007 | } | ||
| 1008 | |||
| 823 | /// The clipboard's PNG, or else its device-independent bitmap. | 1009 | /// The clipboard's PNG, or else its device-independent bitmap. |
| 824 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { | 1010 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { |
| 825 | crate::paste::bitmap(self.0.get_image().ok()?) | 1011 | crate::paste::bitmap(self.0.get_image().ok()?) |
crates/snowbound/tests/replay.rs+130| ... | @@ -122,3 +122,133 @@ fn each_toolbar_control_has_a_name_of_its_own_at_every_width() { | ... | @@ -122,3 +122,133 @@ fn each_toolbar_control_has_a_name_of_its_own_at_every_width() { |
| 122 | assert_named_once(&tree); | 122 | assert_named_once(&tree); |
| 123 | } | 123 | } |
| 124 | } | 124 | } |
| 125 | |||
| 126 | /// The menu items listed under the first `Menu` of `tree`, one a line. | ||
| 127 | fn menu_items(tree: &str) -> Vec<&str> { | ||
| 128 | tree.lines() | ||
| 129 | .skip_while(|line| !line.trim_start().starts_with("Menu")) | ||
| 130 | .skip(1) | ||
| 131 | .take_while(|line| !line.trim_start().starts_with("Menu") || line.contains("MenuItem")) | ||
| 132 | .filter(|line| line.contains("MenuItem")) | ||
| 133 | .map(|line| line.split('"').nth(1).unwrap_or_default()) | ||
| 134 | .collect() | ||
| 135 | } | ||
| 136 | |||
| 137 | #[test] | ||
| 138 | fn the_palette_lists_recent_pages_and_the_actions_a_context_menu_offers() { | ||
| 139 | let scratch = Scratch::new("palette-actions"); | ||
| 140 | let notebook = | ||
| 141 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | ||
| 142 | let palette = ["modifiers command", "key p", "modifiers", "wait 400"]; | ||
| 143 | let chord = ["modifiers command", "key k", "modifiers", "wait 400"]; | ||
| 144 | let mut steps = Vec::from(palette); | ||
| 145 | steps.extend(["type type over", "wait 200", "key Enter", "wait 800"]); | ||
| 146 | steps.extend(palette); | ||
| 147 | steps.push("accessibility recent"); | ||
| 148 | steps.extend(chord); | ||
| 149 | steps.push("accessibility actions"); | ||
| 150 | steps.extend(["key Escape", "wait 400", "accessibility back"]); | ||
| 151 | steps.extend(["key Escape", "wait 400", "move 1000 87", "press right"]); | ||
| 152 | steps.extend(["release right", "wait 400", "accessibility context"]); | ||
| 153 | let [recent, actions, back, context] = replay(&scratch, Some(&notebook), &steps) | ||
| 154 | .try_into() | ||
| 155 | .unwrap(); | ||
| 156 | let first = recent | ||
| 157 | .lines() | ||
| 158 | .find(|line| line.contains("ListBoxOption")) | ||
| 159 | .unwrap_or_default(); | ||
| 160 | assert!( | ||
| 161 | first.contains(r#""Delete into a table" -- "Cross" [selected]"#), | ||
| 162 | "the page left starts the list, highlighted:\n{recent}" | ||
| 163 | ); | ||
| 164 | let actions = menu_items(&actions); | ||
| 165 | assert_eq!(actions.first(), Some(&"Open"), "{actions:?}"); | ||
| 166 | assert!( | ||
| 167 | back.contains("Dialog") && menu_items(&back).is_empty(), | ||
| 168 | "{back}" | ||
| 169 | ); | ||
| 170 | // Open leads; rows past what the panel shows unscrolled are not built. | ||
| 171 | let context = menu_items(&context); | ||
| 172 | assert!( | ||
| 173 | actions.len() > 5 && context.starts_with(&actions[1..]), | ||
| 174 | "one list feeds both: {actions:?} {context:?}" | ||
| 175 | ); | ||
| 176 | } | ||
| 177 | |||
| 178 | /// The page tabs listed in `tree`, the selected one marked with `*`. | ||
| 179 | fn page_tabs(tree: &str) -> Vec<String> { | ||
| 180 | tree.lines() | ||
| 181 | .skip_while(|line| line.trim() != r#"TabList "Pages""#) | ||
| 182 | .skip(1) | ||
| 183 | .take_while(|line| line.trim_start().starts_with("Tab ")) | ||
| 184 | .map(|line| { | ||
| 185 | let name = line.split('"').nth(1).unwrap_or_default(); | ||
| 186 | match line.ends_with("[selected]") { | ||
| 187 | true => format!("*{name}"), | ||
| 188 | false => name.to_owned(), | ||
| 189 | } | ||
| 190 | }) | ||
| 191 | .collect() | ||
| 192 | } | ||
| 193 | |||
| 194 | /// Undo walks back through two new pages and what was typed on each, crossing pages as | ||
| 195 | /// it goes: each page keeps its own history once left, and a new page goes once what was | ||
| 196 | /// typed on it has gone. | ||
| 197 | #[test] | ||
| 198 | fn undo_walks_back_through_new_pages_and_their_titles() { | ||
| 199 | let scratch = Scratch::new("undo-pages"); | ||
| 200 | let notebook = | ||
| 201 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | ||
| 202 | let chord = | ||
| 203 | |modifiers: &str, key: &str| [modifiers, key, "modifiers", "wait 800"].map(String::from); | ||
| 204 | let mut steps = Vec::new(); | ||
| 205 | for title in ["type One", "type Two"] { | ||
| 206 | steps.extend(chord("modifiers command", "key n")); | ||
| 207 | steps.extend([title, "wait 300"].map(String::from)); | ||
| 208 | } | ||
| 209 | steps.extend(chord("modifiers command control", "key Left")); | ||
| 210 | steps.push("accessibility back".into()); | ||
| 211 | for name in ["shown", "two", "removed", "one", "first"] { | ||
| 212 | steps.extend(chord("modifiers command", "key z")); | ||
| 213 | steps.push(format!("accessibility {name}")); | ||
| 214 | } | ||
| 215 | steps.extend(chord("modifiers command shift", "key z")); | ||
| 216 | steps.push("accessibility redone".into()); | ||
| 217 | let steps: Vec<&str> = steps.iter().map(String::as_str).collect(); | ||
| 218 | let trees = replay(&scratch, Some(&notebook), &steps); | ||
| 219 | let tabs: Vec<Vec<String>> = trees.iter().map(|tree| page_tabs(tree)).collect(); | ||
| 220 | let corpus = [ | ||
| 221 | "Delete into a table", | ||
| 222 | "Delete out of a table", | ||
| 223 | "Type over a row", | ||
| 224 | "Delete a whole table", | ||
| 225 | ]; | ||
| 226 | let list = |extra: &[&str], selected: &str| -> Vec<String> { | ||
| 227 | corpus | ||
| 228 | .iter() | ||
| 229 | .chain(extra) | ||
| 230 | .map(|name| match *name == selected { | ||
| 231 | true => format!("*{name}"), | ||
| 232 | false => name.to_string(), | ||
| 233 | }) | ||
| 234 | .collect() | ||
| 235 | }; | ||
| 236 | let expected = [ | ||
| 237 | // Back to the first new page. | ||
| 238 | list(&["One", "Two"], "One"), | ||
| 239 | // Undo there shows the newer page, whose typing goes first. | ||
| 240 | list(&["One", "Two"], "Two"), | ||
| 241 | list(&["One", "Untitled page"], "Untitled page"), | ||
| 242 | // Then the page, back to the page shown before it. | ||
| 243 | list(&["One"], "One"), | ||
| 244 | // The first page's own typing, kept while it was left. | ||
| 245 | list(&["Untitled page"], "Untitled page"), | ||
| 246 | list(&[], "Delete into a table"), | ||
| 247 | // Redo brings the page back. | ||
| 248 | list(&["Untitled page"], "Untitled page"), | ||
| 249 | ]; | ||
| 250 | assert_eq!(tabs.len(), expected.len()); | ||
| 251 | for ((tabs, expected), tree) in tabs.iter().zip(&expected).zip(&trees) { | ||
| 252 | assert_eq!(tabs, expected, "{tree}"); | ||
| 253 | } | ||
| 254 | } |
crates/ui/src/layout.rs+61-2| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts}; | 1 | use crate::{ |
| 2 | Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, fitting, text::Texts, | ||
| 3 | }; | ||
| 2 | use std::collections::HashMap; | 4 | use std::collections::HashMap; |
| 3 | 5 | ||
| 4 | /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from | 6 | /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from |
| ... | @@ -49,8 +51,13 @@ pub(crate) fn solve( | ... | @@ -49,8 +51,13 @@ pub(crate) fn solve( |
| 49 | Size::Fraction(_) | Size::Children => 0.0, | 51 | Size::Fraction(_) | Size::Children => 0.0, |
| 50 | }; | 52 | }; |
| 51 | } | 53 | } |
| 54 | fit_popups(nodes, axis); | ||
| 52 | for index in 1..nodes.len() { | 55 | for index in 1..nodes.len() { |
| 53 | if let Size::Fraction(fraction) = nodes[index].size[axis].size { | 56 | if let Size::Fraction(fraction) = nodes[index].size[axis].size { |
| 57 | if stretches(nodes, index, axis) { | ||
| 58 | nodes[index].computed[axis] = 0.0; | ||
| 59 | continue; | ||
| 60 | } | ||
| 54 | let mut ancestor = nodes[index].parent; | 61 | let mut ancestor = nodes[index].parent; |
| 55 | while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children { | 62 | while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children { |
| 56 | ancestor = nodes[ancestor].parent; | 63 | ancestor = nodes[ancestor].parent; |
| ... | @@ -65,6 +72,8 @@ pub(crate) fn solve( | ... | @@ -65,6 +72,8 @@ pub(crate) fn solve( |
| 65 | nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis]; | 72 | nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis]; |
| 66 | } | 73 | } |
| 67 | } | 74 | } |
| 75 | yield_popups(nodes, axis); | ||
| 76 | fit_popups(nodes, axis); | ||
| 68 | for index in 0..nodes.len() { | 77 | for index in 0..nodes.len() { |
| 69 | let node = &nodes[index]; | 78 | let node = &nodes[index]; |
| 70 | if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) { | 79 | if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) { |
| ... | @@ -118,6 +127,12 @@ pub(crate) fn solve( | ... | @@ -118,6 +127,12 @@ pub(crate) fn solve( |
| 118 | for child in children { | 127 | for child in children { |
| 119 | let room = | 128 | let room = |
| 120 | (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0); | 129 | (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0); |
| 130 | if let Size::Fraction(fraction) = nodes[child].size[axis].size | ||
| 131 | && stretches(nodes, child, axis) | ||
| 132 | { | ||
| 133 | nodes[child].computed[axis] = room * fraction; | ||
| 134 | continue; | ||
| 135 | } | ||
| 121 | let over = nodes[child].computed[axis] - room; | 136 | let over = nodes[child].computed[axis] - room; |
| 122 | if over > 0.0 { | 137 | if over > 0.0 { |
| 123 | nodes[child].computed[axis] -= | 138 | nodes[child].computed[axis] -= |
| ... | @@ -197,6 +212,50 @@ pub(crate) fn solve( | ... | @@ -197,6 +212,50 @@ pub(crate) fn solve( |
| 197 | } | 212 | } |
| 198 | } | 213 | } |
| 199 | 214 | ||
| 215 | /// Whether `index`, sized from an ancestor, is across a parent sized by its children: it takes | ||
| 216 | /// no room while the parent sums its other children, then that share of the parent's room. | ||
| 217 | fn stretches(nodes: &[Built], index: usize, axis: usize) -> bool { | ||
| 218 | let node = &nodes[index]; | ||
| 219 | let parent = &nodes[node.parent]; | ||
| 220 | index != 0 | ||
| 221 | && parent.size[axis].size == Size::Children | ||
| 222 | && !along(parent, axis) | ||
| 223 | && !(axis == 1 && parent.flags.contains(Flags::SCROLL)) | ||
| 224 | && !node.flags.contains(Flags::FLOAT) | ||
| 225 | && node.anchor.is_none() | ||
| 226 | } | ||
| 227 | |||
| 228 | /// Lets each popup sized loosely give way to the window, as far as its strictness lets it: | ||
| 229 | /// a dialog keeps below it the margin it opens under. | ||
| 230 | fn yield_popups(nodes: &mut [Built], axis: usize) { | ||
| 231 | let window = nodes[0].computed[axis]; | ||
| 232 | for node in &mut nodes[1..] { | ||
| 233 | let room = match node.anchor { | ||
| 234 | None => continue, | ||
| 235 | Some(Anchor::Dialog | Anchor::Top) if axis == 1 => window * 3.0 / 4.0, | ||
| 236 | Some(_) => fitting(window), | ||
| 237 | }; | ||
| 238 | let over = node.computed[axis] - room; | ||
| 239 | if over > 0.0 { | ||
| 240 | node.computed[axis] -= over * (1.0 - node.size[axis].strictness.clamp(0.0, 1.0)); | ||
| 241 | } | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | /// Shrinks each popup longer than the window lets it be on `axis`; one cut short vertically | ||
| 246 | /// scrolls what it holds. | ||
| 247 | fn fit_popups(nodes: &mut [Built], axis: usize) { | ||
| 248 | let most = fitting(nodes[0].computed[axis]); | ||
| 249 | for node in &mut nodes[1..] { | ||
| 250 | if node.anchor.is_some() && node.computed[axis] > most { | ||
| 251 | node.computed[axis] = most; | ||
| 252 | if axis == 1 { | ||
| 253 | node.flags = node.flags | Flags::SCROLL | Flags::CLIP; | ||
| 254 | } | ||
| 255 | } | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 200 | /// The length `parent` lays `child` out across: a popup widening over its anchor lays its | 259 | /// The length `parent` lays `child` out across: a popup widening over its anchor lays its |
| 201 | /// contents out at its full width, but for boxes standing in for the anchor, which widen with it. | 260 | /// contents out at its full width, but for boxes standing in for the anchor, which widen with it. |
| 202 | fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 { | 261 | fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 { |
| ... | @@ -205,7 +264,7 @@ fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 { | ... | @@ -205,7 +264,7 @@ fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 { |
| 205 | (Some(Anchor::Over(_)), Size::Pixels(full)) | 264 | (Some(Anchor::Over(_)), Size::Pixels(full)) |
| 206 | if axis == 0 && !nodes[child].flags.contains(Flags::STILL) => | 265 | if axis == 0 && !nodes[child].flags.contains(Flags::STILL) => |
| 207 | { | 266 | { |
| 208 | full | 267 | full.min(fitting(nodes[0].computed[axis])) |
| 209 | } | 268 | } |
| 210 | _ => node.computed[axis], | 269 | _ => node.computed[axis], |
| 211 | } | 270 | } |
crates/ui/src/lib.rs+57-17| ... | @@ -85,7 +85,8 @@ pub enum Size { | ... | @@ -85,7 +85,8 @@ pub enum Size { |
| 85 | Pixels(f32), | 85 | Pixels(f32), |
| 86 | /// The label and padding. | 86 | /// The label and padding. |
| 87 | Text, | 87 | Text, |
| 88 | /// A fraction of the nearest ancestor not sized by its children. | 88 | /// A fraction of the nearest ancestor not sized by its children; across a parent sized by |
| 89 | /// its children, a fraction of what the parent's other children make it. | ||
| 89 | Fraction(f32), | 90 | Fraction(f32), |
| 90 | /// The children laid out along this axis, and padding. | 91 | /// The children laid out along this axis, and padding. |
| 91 | Children, | 92 | Children, |
| ... | @@ -207,6 +208,14 @@ pub enum Overflow { | ... | @@ -207,6 +208,14 @@ pub enum Overflow { |
| 207 | Ellipsis, | 208 | Ellipsis, |
| 208 | } | 209 | } |
| 209 | 210 | ||
| 211 | /// The least a popup keeps between itself and each of the window's edges. | ||
| 212 | const POPUP_MARGIN: f32 = 4.0; | ||
| 213 | |||
| 214 | /// The longest a popup may be in a window `room` long; one longer scrolls or squeezes. | ||
| 215 | fn fitting(room: f32) -> f32 { | ||
| 216 | (room - 2.0 * POPUP_MARGIN).max(0.0) | ||
| 217 | } | ||
| 218 | |||
| 210 | /// Where a popup opens, flipping to the far side of its anchor where the window ends first. | 219 | /// Where a popup opens, flipping to the far side of its anchor where the window ends first. |
| 211 | #[derive(Clone, Copy, Debug, PartialEq)] | 220 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 212 | pub enum Anchor { | 221 | pub enum Anchor { |
| ... | @@ -231,13 +240,18 @@ impl Anchor { | ... | @@ -231,13 +240,18 @@ impl Anchor { |
| 231 | /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long | 240 | /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long |
| 232 | /// as it opens, it keeps the edge it would have at full size. | 241 | /// as it opens, it keeps the edge it would have at full size. |
| 233 | fn place(self, axis: usize, size: f32, shown: f32, room: f32) -> f32 { | 242 | fn place(self, axis: usize, size: f32, shown: f32, room: f32) -> f32 { |
| 243 | let size = size.min(fitting(room)); | ||
| 244 | let shown = shown.min(size); | ||
| 245 | let most = (room - POPUP_MARGIN - size).max(POPUP_MARGIN); | ||
| 234 | let (rect, along) = match self { | 246 | let (rect, along) = match self { |
| 235 | Anchor::Below(rect) => (rect, Some(1)), | 247 | Anchor::Below(rect) => (rect, Some(1)), |
| 236 | Anchor::Right(rect) => (rect, Some(0)), | 248 | Anchor::Right(rect) => (rect, Some(0)), |
| 237 | Anchor::Over(rect) => (rect, None), | 249 | Anchor::Over(rect) => (rect, None), |
| 238 | Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), | 250 | Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), |
| 239 | Anchor::Dialog | Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0), | 251 | Anchor::Dialog | Anchor::Top if axis == 0 => { |
| 240 | Anchor::Dialog | Anchor::Top => return (room / 8.0).min(room - size).max(0.0), | 252 | return ((room - size) / 2.0).max(POPUP_MARGIN); |
| 253 | } | ||
| 254 | Anchor::Dialog | Anchor::Top => return (room / 8.0).clamp(POPUP_MARGIN, most), | ||
| 241 | }; | 255 | }; |
| 242 | let [low, high] = [rect[axis], rect[axis + 2]]; | 256 | let [low, high] = [rect[axis], rect[axis + 2]]; |
| 243 | let (first, second) = if along == Some(axis) { | 257 | let (first, second) = if along == Some(axis) { |
| ... | @@ -245,12 +259,12 @@ impl Anchor { | ... | @@ -245,12 +259,12 @@ impl Anchor { |
| 245 | } else { | 259 | } else { |
| 246 | (low, high - size) | 260 | (low, high - size) |
| 247 | }; | 261 | }; |
| 248 | if first + size <= room { | 262 | if first + size <= room - POPUP_MARGIN { |
| 249 | first | 263 | first.max(POPUP_MARGIN) |
| 250 | } else if second >= 0.0 { | 264 | } else if second >= POPUP_MARGIN { |
| 251 | second + size - shown | 265 | second.min(most) + size - shown |
| 252 | } else { | 266 | } else { |
| 253 | first.min(room - size).max(0.0) + size - shown | 267 | first.clamp(POPUP_MARGIN, most) + size - shown |
| 254 | } | 268 | } |
| 255 | } | 269 | } |
| 256 | 270 | ||
| ... | @@ -323,6 +337,8 @@ pub struct Spec<'a> { | ... | @@ -323,6 +337,8 @@ pub struct Spec<'a> { |
| 323 | pub font_size: Option<f32>, | 337 | pub font_size: Option<f32>, |
| 324 | /// Shapes the label semibold. | 338 | /// Shapes the label semibold. |
| 325 | pub bold: bool, | 339 | pub bold: bool, |
| 340 | /// Shapes the label italic. | ||
| 341 | pub italic: bool, | ||
| 326 | pub font: Option<&'a str>, | 342 | pub font: Option<&'a str>, |
| 327 | pub overflow: Overflow, | 343 | pub overflow: Overflow, |
| 328 | /// The label's colour; the theme's text colour otherwise. | 344 | /// The label's colour; the theme's text colour otherwise. |
| ... | @@ -548,6 +564,8 @@ struct State { | ... | @@ -548,6 +564,8 @@ struct State { |
| 548 | held: Option<(ScrollerPart, Instant)>, | 564 | held: Option<(ScrollerPart, Instant)>, |
| 549 | /// An animated value and its target, for `Ui::animate`. | 565 | /// An animated value and its target, for `Ui::animate`. |
| 550 | tween: Option<[f32; 2]>, | 566 | tween: Option<[f32; 2]>, |
| 567 | /// When the condition `Ui::lasted` follows last became true. | ||
| 568 | since: Option<Instant>, | ||
| 551 | } | 569 | } |
| 552 | 570 | ||
| 553 | /// An open popup, above the one opened before it. | 571 | /// An open popup, above the one opened before it. |
| ... | @@ -839,6 +857,18 @@ impl Ui { | ... | @@ -839,6 +857,18 @@ impl Ui { |
| 839 | self.states.get(&id).map(|state| state.rect) | 857 | self.states.get(&id).map(|state| state.rect) |
| 840 | } | 858 | } |
| 841 | 859 | ||
| 860 | /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, both | ||
| 861 | /// as last laid out; the wheel's easing carries it there. | ||
| 862 | pub fn scroll_to(&mut self, scroller: Id, target: Id) { | ||
| 863 | let (Some(top), Some(start)) = (self.rect(target), self.rect(scroller)) else { | ||
| 864 | return; | ||
| 865 | }; | ||
| 866 | let state = self.states.entry(scroller).or_default(); | ||
| 867 | let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0); | ||
| 868 | state.scroll_target = (state.scroll + top[1] - start[1]).clamp(0.0, most); | ||
| 869 | self.animating |= state.scroll != state.scroll_target; | ||
| 870 | } | ||
| 871 | |||
| 842 | /// The pointer's cursor, or None over a custom box, whose host chooses. | 872 | /// The pointer's cursor, or None over a custom box, whose host chooses. |
| 843 | pub fn cursor(&self) -> Option<CursorIcon> { | 873 | pub fn cursor(&self) -> Option<CursorIcon> { |
| 844 | let hit = self | 874 | let hit = self |
| ... | @@ -1180,7 +1210,7 @@ impl Ui { | ... | @@ -1180,7 +1210,7 @@ impl Ui { |
| 1180 | let label = spec.text.map(|text| { | 1210 | let label = spec.text.map(|text| { |
| 1181 | let size = spec.font_size.unwrap_or(self.theme.font_size); | 1211 | let size = spec.font_size.unwrap_or(self.theme.font_size); |
| 1182 | self.texts | 1212 | self.texts |
| 1183 | .label(text, size, spec.bold, spec.font, self.frame) | 1213 | .styled(text, size, spec.bold, spec.italic, spec.font, self.frame) |
| 1184 | }); | 1214 | }); |
| 1185 | // Popups hang from the root, outside the clips and flow of where they are built. | 1215 | // Popups hang from the root, outside the clips and flow of where they are built. |
| 1186 | let parent = if spec.anchor.is_some() { | 1216 | let parent = if spec.anchor.is_some() { |
| ... | @@ -1236,6 +1266,18 @@ impl Ui { | ... | @@ -1236,6 +1266,18 @@ impl Ui { |
| 1236 | *value | 1266 | *value |
| 1237 | } | 1267 | } |
| 1238 | 1268 | ||
| 1269 | /// How long `held` has stayed true through the frames that ask of `id`; zero while | ||
| 1270 | /// false, as a status waits before showing a state that may pass at once. | ||
| 1271 | pub fn lasted(&mut self, id: Id, held: bool) -> Duration { | ||
| 1272 | let now = self.now; | ||
| 1273 | let state = self.states.entry(id).or_default(); | ||
| 1274 | state.touched = self.frame; | ||
| 1275 | state.since = held.then(|| state.since.unwrap_or(now)); | ||
| 1276 | state | ||
| 1277 | .since | ||
| 1278 | .map_or(Duration::ZERO, |since| now.saturating_duration_since(since)) | ||
| 1279 | } | ||
| 1280 | |||
| 1239 | /// Makes `family` the interface's font, where fontique doesn't know the system's, as | 1281 | /// Makes `family` the interface's font, where fontique doesn't know the system's, as |
| 1240 | /// with Mac OS X 10.6's Lucida Grande. | 1282 | /// with Mac OS X 10.6's Lucida Grande. |
| 1241 | pub fn set_system_font(&mut self, family: &str) { | 1283 | pub fn set_system_font(&mut self, family: &str) { |
| ... | @@ -1627,10 +1669,12 @@ impl Ui { | ... | @@ -1627,10 +1669,12 @@ impl Ui { |
| 1627 | radius: *radius, | 1669 | radius: *radius, |
| 1628 | }); | 1670 | }); |
| 1629 | } | 1671 | } |
| 1672 | // A box squeezed narrower than its padding keeps an empty inside, not an inverted one. | ||
| 1673 | let left = (painted[0] + node.pad[0]).min(painted[2]); | ||
| 1630 | let inner = [ | 1674 | let inner = [ |
| 1631 | painted[0] + node.pad[0], | 1675 | left, |
| 1632 | painted[1], | 1676 | painted[1], |
| 1633 | painted[2] - node.pad[0], | 1677 | (painted[2] - node.pad[0]).max(left), |
| 1634 | painted[3], | 1678 | painted[3], |
| 1635 | ]; | 1679 | ]; |
| 1636 | let mut x = if node.center { | 1680 | let mut x = if node.center { |
| ... | @@ -1650,12 +1694,8 @@ impl Ui { | ... | @@ -1650,12 +1694,8 @@ impl Ui { |
| 1650 | let rect = if node.label.is_some() { | 1694 | let rect = if node.label.is_some() { |
| 1651 | [x, top, x + ICON, top + ICON] | 1695 | [x, top, x + ICON, top + ICON] |
| 1652 | } else { | 1696 | } else { |
| 1653 | [ | 1697 | let top = (painted[1] + node.pad[1]).min(painted[3]); |
| 1654 | inner[0], | 1698 | [inner[0], top, inner[2], (painted[3] - node.pad[1]).max(top)] |
| 1655 | painted[1] + node.pad[1], | ||
| 1656 | inner[2], | ||
| 1657 | painted[3] - node.pad[1], | ||
| 1658 | ] | ||
| 1659 | }; | 1699 | }; |
| 1660 | self.display.push(Display::Image { | 1700 | self.display.push(Display::Image { |
| 1661 | image: image.clone(), | 1701 | image: image.clone(), |
crates/ui/src/list.rs+7| ... | @@ -55,6 +55,13 @@ pub(crate) struct State { | ... | @@ -55,6 +55,13 @@ pub(crate) struct State { |
| 55 | selected: Option<u64>, | 55 | selected: Option<u64>, |
| 56 | } | 56 | } |
| 57 | 57 | ||
| 58 | impl State { | ||
| 59 | /// The keys of the rows built last frame. | ||
| 60 | pub(crate) fn shown(&self) -> impl Iterator<Item = u64> + '_ { | ||
| 61 | self.shown.iter().map(|(key, _)| *key) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 58 | /// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves | 65 | /// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves |
| 59 | /// `selected` with the list's keys. Returns the index of the row clicked. | 66 | /// `selected` with the list's keys. Returns the index of the row clicked. |
| 60 | pub fn list<R: Rows>( | 67 | pub fn list<R: Rows>( |
crates/ui/src/popup.rs+60-10| ... | @@ -75,6 +75,8 @@ pub struct Item<'a> { | ... | @@ -75,6 +75,8 @@ pub struct Item<'a> { |
| 75 | pub separated: bool, | 75 | pub separated: bool, |
| 76 | /// Names the group it starts: shown only while unfiltered, never chosen. | 76 | /// Names the group it starts: shown only while unfiltered, never chosen. |
| 77 | pub heading: bool, | 77 | pub heading: bool, |
| 78 | /// Repeats an item listed further on, so shows only while unfiltered. | ||
| 79 | pub repeated: bool, | ||
| 78 | } | 80 | } |
| 79 | 81 | ||
| 80 | impl Item<'_> { | 82 | impl Item<'_> { |
| ... | @@ -327,22 +329,38 @@ fn tooltip_below( | ... | @@ -327,22 +329,38 @@ fn tooltip_below( |
| 327 | ui.close(); | 329 | ui.close(); |
| 328 | } | 330 | } |
| 329 | 331 | ||
| 332 | /// A palette's item picked, by mode and index: to run, or for its actions, which the menu | ||
| 333 | /// `actions` names lists beside its row. | ||
| 334 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 335 | pub enum Pick { | ||
| 336 | Run(usize, usize), | ||
| 337 | Actions(usize, usize), | ||
| 338 | } | ||
| 339 | |||
| 340 | /// The menu of the actions on palette `id`'s item, which Escape closes back to the palette. | ||
| 341 | pub fn actions(id: Id) -> Id { | ||
| 342 | id.child("actions") | ||
| 343 | } | ||
| 344 | |||
| 330 | /// Builds popup `id` as a command palette across the top of the window while it is open, | 345 | /// Builds popup `id` as a command palette across the top of the window while it is open, |
| 331 | /// under a filter field showing `placeholder` while empty. What is typed picks the first of | 346 | /// under a filter field showing `placeholder` while empty. What is typed picks the first of |
| 332 | /// `modes` whose prefix it starts with, and the rest of it narrows that mode's items, most | 347 | /// `modes` whose prefix it starts with, and the rest of it narrows that mode's items, most |
| 333 | /// relevant first. Returns the mode and the index of the item chosen. | 348 | /// relevant first. Enter or a click runs the item; ⌘K (Ctrl+K elsewhere) on the highlighted |
| 334 | pub fn palette( | 349 | /// one or a right-click on any opens its `actions` menu. |
| 335 | ui: &mut Ui, | 350 | pub fn palette(ui: &mut Ui, id: Id, modes: &[(&str, &[Item])], placeholder: &str) -> Option<Pick> { |
| 336 | id: Id, | ||
| 337 | modes: &[(&str, &[Item])], | ||
| 338 | placeholder: &str, | ||
| 339 | ) -> Option<(usize, usize)> { | ||
| 340 | if !ui.popup_open(id) { | 351 | if !ui.popup_open(id) { |
| 341 | return None; | 352 | return None; |
| 342 | } | 353 | } |
| 354 | let rows = id.child("rows"); | ||
| 355 | let clicked = (ui.lists.get(&rows).into_iter()) | ||
| 356 | .flat_map(crate::list::State::shown) | ||
| 357 | .find(|key| { | ||
| 358 | (ui.signals.get(&rows.child(*key))).is_some_and(|signal| signal.context.is_some()) | ||
| 359 | }); | ||
| 360 | let chord = chord(ui, id.child("filter"), "k"); | ||
| 343 | let window = ui.rect(Id::ROOT).unwrap_or_default(); | 361 | let window = ui.rect(Id::ROOT).unwrap_or_default(); |
| 344 | let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST); | 362 | let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST); |
| 345 | choose( | 363 | if let Some((mode, index)) = choose( |
| 346 | ui, | 364 | ui, |
| 347 | id, | 365 | id, |
| 348 | Role::Dialog, | 366 | Role::Dialog, |
| ... | @@ -351,7 +369,39 @@ pub fn palette( | ... | @@ -351,7 +369,39 @@ pub fn palette( |
| 351 | Some(placeholder), | 369 | Some(placeholder), |
| 352 | width, | 370 | width, |
| 353 | ROW, | 371 | ROW, |
| 354 | ) | 372 | ) { |
| 373 | return Some(Pick::Run(mode, index)); | ||
| 374 | } | ||
| 375 | if !ui.popup_open(id) { | ||
| 376 | return None; | ||
| 377 | } | ||
| 378 | let popup = state(ui, id); | ||
| 379 | popup.highlight = clicked.or(popup.highlight); | ||
| 380 | let key = clicked.or(popup.highlight.filter(|_| chord))?; | ||
| 381 | let mode = modes | ||
| 382 | .iter() | ||
| 383 | .position(|(prefix, _)| popup.query.starts_with(prefix))?; | ||
| 384 | if modes[mode].1.get(key as usize)?.heading { | ||
| 385 | return None; | ||
| 386 | } | ||
| 387 | ui.open_submenu(actions(id), id, rows.child(key)); | ||
| 388 | Some(Pick::Actions(mode, key as usize)) | ||
| 389 | } | ||
| 390 | |||
| 391 | /// Takes a press of `character` with the shortcut modifier, ⌘ or Ctrl, routed to `owner`. | ||
| 392 | fn chord(ui: &mut Ui, owner: Id, character: &str) -> bool { | ||
| 393 | if !crate::edit_modifiers(ui.modifiers).command { | ||
| 394 | return false; | ||
| 395 | } | ||
| 396 | let Some(signal) = ui.signals.get_mut(&owner) else { | ||
| 397 | return false; | ||
| 398 | }; | ||
| 399 | let before = signal.events.len(); | ||
| 400 | signal.events.retain(|event| { | ||
| 401 | !matches!(event, Event::Key { key: Key::Character(typed), .. } | ||
| 402 | if typed.eq_ignore_ascii_case(character)) | ||
| 403 | }); | ||
| 404 | signal.events.len() != before | ||
| 355 | } | 405 | } |
| 356 | 406 | ||
| 357 | /// Opens popup `id` with `query` typed in its filter field and the caret after it, or | 407 | /// Opens popup `id` with `query` typed in its filter field and the caret after it, or |
| ... | @@ -1409,7 +1459,7 @@ impl<'a> Matches<'a> { | ... | @@ -1409,7 +1459,7 @@ impl<'a> Matches<'a> { |
| 1409 | items | 1459 | items |
| 1410 | .iter() | 1460 | .iter() |
| 1411 | .enumerate() | 1461 | .enumerate() |
| 1412 | .filter(|(_, item)| !item.heading) | 1462 | .filter(|(_, item)| !item.heading && !item.repeated) |
| 1413 | .filter_map(|(index, item)| { | 1463 | .filter_map(|(index, item)| { |
| 1414 | let text = Utf32Str::new(item.text, &mut chars); | 1464 | let text = Utf32Str::new(item.text, &mut chars); |
| 1415 | Some((pattern.score(text, matcher)?, index)) | 1465 | Some((pattern.score(text, matcher)?, index)) |
crates/ui/src/tests.rs+295-8| ... | @@ -15,14 +15,19 @@ thread_local! { | ... | @@ -15,14 +15,19 @@ thread_local! { |
| 15 | static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) }; | 15 | static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) }; |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | /// Builds a frame 16 ms after this thread's previous one. | 18 | /// Builds a frame in a 400 by 300 window, 16 ms after this thread's previous one. |
| 19 | fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) { | 19 | fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) { |
| 20 | sized_frame(ui, [400.0, 300.0], build); | ||
| 21 | } | ||
| 22 | |||
| 23 | /// Builds a frame in a window `size` large, 16 ms after this thread's previous one. | ||
| 24 | fn sized_frame(ui: &mut Ui, size: [f32; 2], build: impl FnOnce(&mut Ui)) { | ||
| 20 | let frames = FRAMES.with(|frames| { | 25 | let frames = FRAMES.with(|frames| { |
| 21 | frames.set(frames.get() + 1); | 26 | frames.set(frames.get() + 1); |
| 22 | frames.get() | 27 | frames.get() |
| 23 | }); | 28 | }); |
| 24 | let now = START.with(|start| *start) + Duration::from_millis(16) * frames; | 29 | let now = START.with(|start| *start) + Duration::from_millis(16) * frames; |
| 25 | ui.begin([400.0, 300.0], 2.0, now); | 30 | ui.begin(size, 2.0, now); |
| 26 | build(ui); | 31 | build(ui); |
| 27 | ui.end(); | 32 | ui.end(); |
| 28 | } | 33 | } |
| ... | @@ -1087,13 +1092,17 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() { | ... | @@ -1087,13 +1092,17 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() { |
| 1087 | let [Some(first), None] = rest(&mut ui, 200) else { | 1092 | let [Some(first), None] = rest(&mut ui, 200) else { |
| 1088 | panic!("the first tooltip shows after its delay"); | 1093 | panic!("the first tooltip shows after its delay"); |
| 1089 | }; | 1094 | }; |
| 1090 | assert_eq!(first[..2], [0.0, 20.0 + popup::PAD]); | 1095 | assert_eq!( |
| 1096 | first[..2], | ||
| 1097 | [POPUP_MARGIN, 20.0 + popup::PAD], | ||
| 1098 | "off the window's edge" | ||
| 1099 | ); | ||
| 1091 | 1100 | ||
| 1092 | ui.event(Event::PointerMoved([390.0, 10.0])); | 1101 | ui.event(Event::PointerMoved([390.0, 10.0])); |
| 1093 | let [None, Some(second)] = tip_frame(&mut ui) else { | 1102 | let [None, Some(second)] = tip_frame(&mut ui) else { |
| 1094 | panic!("moving to another shows its tooltip at once"); | 1103 | panic!("moving to another shows its tooltip at once"); |
| 1095 | }; | 1104 | }; |
| 1096 | assert_eq!(second[2], 400.0, "kept inside the window"); | 1105 | assert_eq!(second[2], 400.0 - POPUP_MARGIN, "kept inside the window"); |
| 1097 | 1106 | ||
| 1098 | press(&mut ui, Instant::now(), true); | 1107 | press(&mut ui, Instant::now(), true); |
| 1099 | press(&mut ui, Instant::now(), false); | 1108 | press(&mut ui, Instant::now(), false); |
| ... | @@ -1140,6 +1149,96 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { | ... | @@ -1140,6 +1149,96 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { |
| 1140 | ); | 1149 | ); |
| 1141 | } | 1150 | } |
| 1142 | 1151 | ||
| 1152 | #[test] | ||
| 1153 | fn popups_too_large_for_a_narrow_window_shrink_inside_its_margin() { | ||
| 1154 | let inside = |rect: [f32; 4], window: [f32; 2]| { | ||
| 1155 | rect[0] >= POPUP_MARGIN | ||
| 1156 | && rect[1] >= POPUP_MARGIN | ||
| 1157 | && rect[2] <= window[0] - POPUP_MARGIN | ||
| 1158 | && rect[3] <= window[1] - POPUP_MARGIN | ||
| 1159 | }; | ||
| 1160 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | ||
| 1161 | let narrow = [100.0, 300.0]; | ||
| 1162 | let menu = |ui: &mut Ui| { | ||
| 1163 | sized_frame(ui, narrow, |ui| { | ||
| 1164 | popup::menu(ui, menu_id(), BELOW, &items(), None); | ||
| 1165 | }); | ||
| 1166 | }; | ||
| 1167 | menu(&mut ui); | ||
| 1168 | ui.open_popup(menu_id()); | ||
| 1169 | for _ in 0..40 { | ||
| 1170 | menu(&mut ui); | ||
| 1171 | } | ||
| 1172 | let rect = ui.rect(menu_id()).unwrap(); | ||
| 1173 | assert!(inside(rect, narrow), "{rect:?} leaves the window's margin"); | ||
| 1174 | assert_eq!( | ||
| 1175 | rect[2] - rect[0], | ||
| 1176 | 100.0 - 2.0 * POPUP_MARGIN, | ||
| 1177 | "as wide as fits" | ||
| 1178 | ); | ||
| 1179 | let row = ui.rect(menu_id().child("rows").child(0_u64)).unwrap(); | ||
| 1180 | assert!(row[2] <= rect[2], "its rows narrow with it"); | ||
| 1181 | |||
| 1182 | // A dialog taller than a short window scrolls what it holds. | ||
| 1183 | let short = [400.0, 120.0]; | ||
| 1184 | let dialog = Id::ROOT.child("dialog"); | ||
| 1185 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | ||
| 1186 | let build = |ui: &mut Ui| { | ||
| 1187 | sized_frame(ui, short, |ui| { | ||
| 1188 | ui.open_as( | ||
| 1189 | dialog, | ||
| 1190 | Spec { | ||
| 1191 | axis: Axis::Y, | ||
| 1192 | size: [px(600.0), children()], | ||
| 1193 | anchor: Some(Anchor::Dialog), | ||
| 1194 | ..Spec::default() | ||
| 1195 | }, | ||
| 1196 | ); | ||
| 1197 | for line in 0..10 { | ||
| 1198 | ui.leaf( | ||
| 1199 | line, | ||
| 1200 | Spec { | ||
| 1201 | size: [fill(), px(30.0)], | ||
| 1202 | text: Some("Line"), | ||
| 1203 | // Wider than the squeezed dialog leaves it. | ||
| 1204 | pad: [250.0, 0.0], | ||
| 1205 | ..Spec::default() | ||
| 1206 | }, | ||
| 1207 | ); | ||
| 1208 | } | ||
| 1209 | ui.close(); | ||
| 1210 | }); | ||
| 1211 | }; | ||
| 1212 | build(&mut ui); | ||
| 1213 | ui.open_popup(dialog); | ||
| 1214 | for _ in 0..40 { | ||
| 1215 | build(&mut ui); | ||
| 1216 | } | ||
| 1217 | let rect = ui.rect(dialog).unwrap(); | ||
| 1218 | assert!(inside(rect, short), "{rect:?} leaves the window's margin"); | ||
| 1219 | let first = ui.rect(dialog.child(0)).unwrap(); | ||
| 1220 | ui.event(Event::PointerMoved([200.0, 60.0])); | ||
| 1221 | ui.event(Event::Wheel([0.0, -100.0])); | ||
| 1222 | for _ in 0..40 { | ||
| 1223 | build(&mut ui); | ||
| 1224 | } | ||
| 1225 | let scrolled = ui.rect(dialog.child(0)).unwrap(); | ||
| 1226 | assert!(scrolled[1] < first[1], "the wheel scrolls it"); | ||
| 1227 | for layer in ui.layers() { | ||
| 1228 | let Layer::Primitives(layer) = layer else { | ||
| 1229 | continue; | ||
| 1230 | }; | ||
| 1231 | for primitive in &layer.primitives { | ||
| 1232 | if let draw::Primitive::Text { | ||
| 1233 | clip: Some(clip), .. | ||
| 1234 | } = primitive | ||
| 1235 | { | ||
| 1236 | assert!(clip[0] <= clip[2], "{clip:?} is inverted"); | ||
| 1237 | } | ||
| 1238 | } | ||
| 1239 | } | ||
| 1240 | } | ||
| 1241 | |||
| 1143 | #[test] | 1242 | #[test] |
| 1144 | fn popups_show_at_once_by_their_anchor() { | 1243 | fn popups_show_at_once_by_their_anchor() { |
| 1145 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | 1244 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| ... | @@ -1348,7 +1447,10 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() { | ... | @@ -1348,7 +1447,10 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() { |
| 1348 | frame(ui, |ui| { | 1447 | frame(ui, |ui| { |
| 1349 | chosen = popup::palette(ui, palette, &[("", &commands)], "Search commands") | 1448 | chosen = popup::palette(ui, palette, &[("", &commands)], "Search commands") |
| 1350 | }); | 1449 | }); |
| 1351 | chosen.map(|(_, index)| index) | 1450 | match chosen { |
| 1451 | Some(popup::Pick::Run(_, index)) => Some(index), | ||
| 1452 | _ => None, | ||
| 1453 | } | ||
| 1352 | }; | 1454 | }; |
| 1353 | build(&mut ui); | 1455 | build(&mut ui); |
| 1354 | ui.open_popup(palette); | 1456 | ui.open_popup(palette); |
| ... | @@ -1385,7 +1487,11 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() { | ... | @@ -1385,7 +1487,11 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() { |
| 1385 | build(&mut ui); | 1487 | build(&mut ui); |
| 1386 | ui.event(typed("sel")); | 1488 | ui.event(typed("sel")); |
| 1387 | ui.event(key(NamedKey::Enter)); | 1489 | ui.event(key(NamedKey::Enter)); |
| 1388 | assert_eq!(build(&mut ui), Some((0, 3)), "typed after the prefix"); | 1490 | assert_eq!( |
| 1491 | build(&mut ui), | ||
| 1492 | Some(popup::Pick::Run(0, 3)), | ||
| 1493 | "typed after the prefix" | ||
| 1494 | ); | ||
| 1389 | popup::open_with(&mut ui, palette, ">"); | 1495 | popup::open_with(&mut ui, palette, ">"); |
| 1390 | build(&mut ui); | 1496 | build(&mut ui); |
| 1391 | ui.event(key(NamedKey::Backspace)); | 1497 | ui.event(key(NamedKey::Backspace)); |
| ... | @@ -1393,7 +1499,75 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() { | ... | @@ -1393,7 +1499,75 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() { |
| 1393 | assert_eq!(popup::query(&ui, palette), Some("")); | 1499 | assert_eq!(popup::query(&ui, palette), Some("")); |
| 1394 | ui.event(typed("tnr")); | 1500 | ui.event(typed("tnr")); |
| 1395 | ui.event(key(NamedKey::Enter)); | 1501 | ui.event(key(NamedKey::Enter)); |
| 1396 | assert_eq!(build(&mut ui), Some((1, 42)), "back among the places"); | 1502 | assert_eq!( |
| 1503 | build(&mut ui), | ||
| 1504 | Some(popup::Pick::Run(1, 42)), | ||
| 1505 | "back among the places" | ||
| 1506 | ); | ||
| 1507 | } | ||
| 1508 | |||
| 1509 | #[test] | ||
| 1510 | fn the_palette_opens_an_items_actions_on_command_k_or_a_right_click() { | ||
| 1511 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | ||
| 1512 | let palette = Id::ROOT.child("palette"); | ||
| 1513 | let actions = popup::actions(palette); | ||
| 1514 | let mut places = items(); | ||
| 1515 | // The first repeats an item further on, as a recent page does. | ||
| 1516 | places[0].repeated = true; | ||
| 1517 | let build = |ui: &mut Ui| { | ||
| 1518 | let mut picked = None; | ||
| 1519 | frame(ui, |ui| { | ||
| 1520 | picked = popup::palette(ui, palette, &[("", &places)], "Go to"); | ||
| 1521 | popup::menu( | ||
| 1522 | ui, | ||
| 1523 | actions, | ||
| 1524 | Anchor::Point([0.0; 2]), | ||
| 1525 | &items(), | ||
| 1526 | Some("Actions"), | ||
| 1527 | ); | ||
| 1528 | }); | ||
| 1529 | picked | ||
| 1530 | }; | ||
| 1531 | build(&mut ui); | ||
| 1532 | ui.open_popup(palette); | ||
| 1533 | build(&mut ui); | ||
| 1534 | ui.event(key(NamedKey::ArrowDown)); | ||
| 1535 | build(&mut ui); | ||
| 1536 | ui.event(Event::Modifiers(SHORTCUT)); | ||
| 1537 | ui.event(typed("k")); | ||
| 1538 | assert_eq!(build(&mut ui), Some(popup::Pick::Actions(0, 0))); | ||
| 1539 | assert!(ui.popup_open(actions)); | ||
| 1540 | assert_eq!( | ||
| 1541 | popup::query(&ui, palette), | ||
| 1542 | Some(""), | ||
| 1543 | "the chord types nothing" | ||
| 1544 | ); | ||
| 1545 | ui.event(Event::Modifiers(ModifiersState::empty())); | ||
| 1546 | build(&mut ui); | ||
| 1547 | ui.event(key(NamedKey::Escape)); | ||
| 1548 | build(&mut ui); | ||
| 1549 | assert!( | ||
| 1550 | ui.popup_open(palette) && !ui.popup_open(actions), | ||
| 1551 | "Escape goes back to the palette" | ||
| 1552 | ); | ||
| 1553 | let row = ui.rect(palette.child("rows").child(3_u64)).unwrap(); | ||
| 1554 | ui.event(Event::PointerMoved([row[0] + 4.0, row[1] + 4.0])); | ||
| 1555 | ui.event(Event::Button { | ||
| 1556 | button: MouseButton::Right, | ||
| 1557 | pressed: true, | ||
| 1558 | at: Instant::now(), | ||
| 1559 | }); | ||
| 1560 | assert_eq!(build(&mut ui), Some(popup::Pick::Actions(0, 3))); | ||
| 1561 | assert!(ui.popup_open(actions)); | ||
| 1562 | ui.event(key(NamedKey::Escape)); | ||
| 1563 | build(&mut ui); | ||
| 1564 | ui.event(typed("c")); | ||
| 1565 | build(&mut ui); | ||
| 1566 | assert_eq!( | ||
| 1567 | ui.rect(palette.child("rows").child(0_u64)), | ||
| 1568 | None, | ||
| 1569 | "a repeated item shows only while unfiltered" | ||
| 1570 | ); | ||
| 1397 | } | 1571 | } |
| 1398 | 1572 | ||
| 1399 | #[test] | 1573 | #[test] |
| ... | @@ -2297,11 +2471,21 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() { | ... | @@ -2297,11 +2471,21 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() { |
| 2297 | fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() { | 2471 | fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() { |
| 2298 | let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); | 2472 | let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); |
| 2299 | let fonts = fonts(); | 2473 | let fonts = fonts(); |
| 2300 | let combo = Id::ROOT.child("combo"); | 2474 | // Inset, as a popup keeps off the window's edges. |
| 2475 | let combo = Id::ROOT.child("inset").child("combo"); | ||
| 2301 | let mut chosen = None; | 2476 | let mut chosen = None; |
| 2302 | let mut build = |ui: &mut Ui| { | 2477 | let mut build = |ui: &mut Ui| { |
| 2303 | frame(ui, |ui| { | 2478 | frame(ui, |ui| { |
| 2479 | ui.open( | ||
| 2480 | "inset", | ||
| 2481 | Spec { | ||
| 2482 | size: [children(), children()], | ||
| 2483 | pad: [10.0, 10.0], | ||
| 2484 | ..Spec::default() | ||
| 2485 | }, | ||
| 2486 | ); | ||
| 2304 | shell::combo(ui, "combo", "Font", "Calibri", 120.0, menu_id(), true); | 2487 | shell::combo(ui, "combo", "Font", "Calibri", 120.0, menu_id(), true); |
| 2488 | ui.close(); | ||
| 2305 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); | 2489 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); |
| 2306 | chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font")); | 2490 | chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font")); |
| 2307 | }); | 2491 | }); |
| ... | @@ -2754,3 +2938,106 @@ fn password_fields_edit_the_characters_behind_their_bullets() { | ... | @@ -2754,3 +2938,106 @@ fn password_fields_edit_the_characters_behind_their_bullets() { |
| 2754 | edit(&mut ui, &mut text); | 2938 | edit(&mut ui, &mut text); |
| 2755 | assert_eq!(text, "pß"); | 2939 | assert_eq!(text, "pß"); |
| 2756 | } | 2940 | } |
| 2941 | |||
| 2942 | #[test] | ||
| 2943 | fn a_loose_dialog_fits_its_contents_to_the_window_and_its_side_stretches_to_its_list() { | ||
| 2944 | let dialog = Id::ROOT.child("dialog"); | ||
| 2945 | let side = dialog.child("body").child("side"); | ||
| 2946 | let list = dialog.child("body").child("list"); | ||
| 2947 | let loose = Extent { | ||
| 2948 | size: Size::Children, | ||
| 2949 | strictness: 0.0, | ||
| 2950 | }; | ||
| 2951 | let build = |ui: &mut Ui, rows: usize| { | ||
| 2952 | frame(ui, |ui| { | ||
| 2953 | ui.open_as( | ||
| 2954 | dialog, | ||
| 2955 | Spec { | ||
| 2956 | axis: Axis::Y, | ||
| 2957 | size: [px(300.0), loose], | ||
| 2958 | anchor: Some(Anchor::Dialog), | ||
| 2959 | ..Spec::default() | ||
| 2960 | }, | ||
| 2961 | ); | ||
| 2962 | ui.leaf( | ||
| 2963 | "header", | ||
| 2964 | Spec { | ||
| 2965 | size: [fill(), px(30.0)], | ||
| 2966 | ..Spec::default() | ||
| 2967 | }, | ||
| 2968 | ); | ||
| 2969 | ui.open( | ||
| 2970 | "body", | ||
| 2971 | Spec { | ||
| 2972 | size: [fill(), loose], | ||
| 2973 | ..Spec::default() | ||
| 2974 | }, | ||
| 2975 | ); | ||
| 2976 | ui.leaf( | ||
| 2977 | "side", | ||
| 2978 | Spec { | ||
| 2979 | size: [px(100.0), fill()], | ||
| 2980 | ..Spec::default() | ||
| 2981 | }, | ||
| 2982 | ); | ||
| 2983 | ui.open( | ||
| 2984 | "list", | ||
| 2985 | Spec { | ||
| 2986 | flags: Flags::SCROLL | Flags::CLIP, | ||
| 2987 | axis: Axis::Y, | ||
| 2988 | size: [fill(), loose], | ||
| 2989 | ..Spec::default() | ||
| 2990 | }, | ||
| 2991 | ); | ||
| 2992 | for row in 0..rows { | ||
| 2993 | ui.leaf( | ||
| 2994 | row, | ||
| 2995 | Spec { | ||
| 2996 | size: [fill(), px(30.0)], | ||
| 2997 | ..Spec::default() | ||
| 2998 | }, | ||
| 2999 | ); | ||
| 3000 | } | ||
| 3001 | ui.close(); | ||
| 3002 | ui.close(); | ||
| 3003 | ui.leaf( | ||
| 3004 | "footer", | ||
| 3005 | Spec { | ||
| 3006 | size: [fill(), px(30.0)], | ||
| 3007 | ..Spec::default() | ||
| 3008 | }, | ||
| 3009 | ); | ||
| 3010 | ui.close(); | ||
| 3011 | }); | ||
| 3012 | }; | ||
| 3013 | let height = |ui: &Ui, id: Id| ui.rect(id).map(|rect| rect[3] - rect[1]).unwrap(); | ||
| 3014 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | ||
| 3015 | build(&mut ui, 2); | ||
| 3016 | ui.open_popup(dialog); | ||
| 3017 | for _ in 0..40 { | ||
| 3018 | build(&mut ui, 2); | ||
| 3019 | } | ||
| 3020 | assert_eq!(height(&ui, dialog), 120.0, "as tall as what it holds"); | ||
| 3021 | assert_eq!(height(&ui, side), 60.0, "the side as tall as the list"); | ||
| 3022 | |||
| 3023 | for _ in 0..40 { | ||
| 3024 | build(&mut ui, 20); | ||
| 3025 | } | ||
| 3026 | assert_eq!( | ||
| 3027 | height(&ui, dialog), | ||
| 3028 | 225.0, | ||
| 3029 | "no taller than the window lets a dialog be" | ||
| 3030 | ); | ||
| 3031 | assert_eq!(height(&ui, list), 165.0, "the list gives way"); | ||
| 3032 | assert_eq!(height(&ui, side), 165.0); | ||
| 3033 | |||
| 3034 | ui.scroll_to(list, list.child(10_usize)); | ||
| 3035 | for _ in 0..60 { | ||
| 3036 | build(&mut ui, 20); | ||
| 3037 | } | ||
| 3038 | let [top, row] = [list, list.child(10_usize)].map(|id| ui.rect(id).unwrap()[1]); | ||
| 3039 | assert!( | ||
| 3040 | (row - top).abs() < 0.5, | ||
| 3041 | "row 10 at {row}, the list's top at {top}" | ||
| 3042 | ); | ||
| 3043 | } |
crates/ui/src/text.rs+21-4| ... | @@ -12,7 +12,7 @@ pub(crate) struct Label { | ... | @@ -12,7 +12,7 @@ pub(crate) struct Label { |
| 12 | pub key: LabelKey, | 12 | pub key: LabelKey, |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | type LabelKey = (String, u32, bool, Option<String>); | 15 | type LabelKey = (String, u32, bool, bool, Option<String>); |
| 16 | 16 | ||
| 17 | /// Labels shaped this frame or the previous one, keyed by their text and size. | 17 | /// Labels shaped this frame or the previous one, keyed by their text and size. |
| 18 | #[derive(Default)] | 18 | #[derive(Default)] |
| ... | @@ -32,11 +32,25 @@ impl Texts { | ... | @@ -32,11 +32,25 @@ impl Texts { |
| 32 | bold: bool, | 32 | bold: bool, |
| 33 | font: Option<&str>, | 33 | font: Option<&str>, |
| 34 | frame: u64, | 34 | frame: u64, |
| 35 | ) -> Rc<Label> { | ||
| 36 | self.styled(text, size, bold, false, font, frame) | ||
| 37 | } | ||
| 38 | |||
| 39 | /// `label`, slanted where `italic`. | ||
| 40 | pub fn styled( | ||
| 41 | &mut self, | ||
| 42 | text: &str, | ||
| 43 | size: f32, | ||
| 44 | bold: bool, | ||
| 45 | italic: bool, | ||
| 46 | font: Option<&str>, | ||
| 47 | frame: u64, | ||
| 35 | ) -> Rc<Label> { | 48 | ) -> Rc<Label> { |
| 36 | let key = ( | 49 | let key = ( |
| 37 | text.to_owned(), | 50 | text.to_owned(), |
| 38 | size.to_bits(), | 51 | size.to_bits(), |
| 39 | bold, | 52 | bold, |
| 53 | italic, | ||
| 40 | font.map(str::to_owned), | 54 | font.map(str::to_owned), |
| 41 | ); | 55 | ); |
| 42 | if let Some((label, touched)) = self.cache.get_mut(&key) { | 56 | if let Some((label, touched)) = self.cache.get_mut(&key) { |
| ... | @@ -62,6 +76,9 @@ impl Texts { | ... | @@ -62,6 +76,9 @@ impl Texts { |
| 62 | if bold { | 76 | if bold { |
| 63 | builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD)); | 77 | builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD)); |
| 64 | } | 78 | } |
| 79 | if italic { | ||
| 80 | builder.push_default(StyleProperty::FontStyle(parley::FontStyle::Italic)); | ||
| 81 | } | ||
| 65 | let mut layout = builder.build(text); | 82 | let mut layout = builder.build(text); |
| 66 | layout.break_all_lines(None); | 83 | layout.break_all_lines(None); |
| 67 | let size = [layout.full_width(), layout.height()]; | 84 | let size = [layout.full_width(), layout.height()]; |
| ... | @@ -76,9 +93,9 @@ impl Texts { | ... | @@ -76,9 +93,9 @@ impl Texts { |
| 76 | 93 | ||
| 77 | /// `label` cut short to fit `width` with an ellipsis. | 94 | /// `label` cut short to fit `width` with an ellipsis. |
| 78 | pub fn ellipsis(&mut self, label: &Label, width: f32, frame: u64) -> Rc<Label> { | 95 | pub fn ellipsis(&mut self, label: &Label, width: f32, frame: u64) -> Rc<Label> { |
| 79 | let (text, size, bold, font) = &label.key; | 96 | let (text, size, bold, italic, font) = &label.key; |
| 80 | let (size, font) = (f32::from_bits(*size), font.as_deref()); | 97 | let (size, font) = (f32::from_bits(*size), font.as_deref()); |
| 81 | let room = width - self.label("…", size, *bold, font, frame).size[0]; | 98 | let room = width - self.styled("…", size, *bold, *italic, font, frame).size[0]; |
| 82 | let mut end = 0; | 99 | let mut end = 0; |
| 83 | let mut advance = 0.0; | 100 | let mut advance = 0.0; |
| 84 | let mut cluster = Cluster::from_byte_index(&label.layout, 0); | 101 | let mut cluster = Cluster::from_byte_index(&label.layout, 0); |
| ... | @@ -91,7 +108,7 @@ impl Texts { | ... | @@ -91,7 +108,7 @@ impl Texts { |
| 91 | cluster = next.next_logical(); | 108 | cluster = next.next_logical(); |
| 92 | } | 109 | } |
| 93 | let cut = format!("{}…", text[..end].trim_end()); | 110 | let cut = format!("{}…", text[..end].trim_end()); |
| 94 | self.label(&cut, size, *bold, font, frame) | 111 | self.styled(&cut, size, *bold, *italic, font, frame) |
| 95 | } | 112 | } |
| 96 | 113 | ||
| 97 | /// Makes `family` the interface's font, where fontique doesn't know the system's. | 114 | /// Makes `family` the interface's font, where fontique doesn't know the system's. |
tools/RELEASE.md+1-1| ... | @@ -55,7 +55,7 @@ the account holder's legal name, which nothing here prints or stores. | ... | @@ -55,7 +55,7 @@ the account holder's legal name, which nothing here prints or stores. |
| 55 | provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID", | 55 | provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID", |
| 56 | found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with | 56 | found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with |
| 57 | hardened runtime, a secure timestamp, the production iCloud container | 57 | hardened runtime, a secure timestamp, the production iCloud container |
| 58 | `iCloud.net.paperclover.snowbound` that Use iCloud Drive needs, and the | 58 | `iCloud.net.paperclover.snowbound` that iCloud notebooks need, and the |
| 59 | microphone and camera entitlements recording needs. It fails rather than fall | 59 | microphone and camera entitlements recording needs. It fails rather than fall |
| 60 | back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle | 60 | back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle |
| 61 | stays unsigned, as it predates Developer ID. codesign fails with | 61 | stays unsigned, as it predates Developer ID. codesign fails with |
tools/canvas/README.md+2-2| ... | @@ -14,7 +14,7 @@ cargo test -p draw -- --ignored | ... | @@ -14,7 +14,7 @@ cargo test -p draw -- --ignored |
| 14 | cargo test -p canvas --features gpu gpu:: -- --ignored | 14 | cargo test -p canvas --features gpu gpu:: -- --ignored |
| 15 | ``` | 15 | ``` |
| 16 | 16 | ||
| 17 | The builder signs and verifies the local bundle: with team `9R7DPNW28H`'s Developer ID Application identity (found in the keychain by team, chosen by SHA-1, or `--sign-identity SHA1`) and a Developer ID provisioning profile for `net.paperclover.snowbound` naming `iCloud.net.paperclover.snowbound` (found in Xcode's profile folders, or `--profile PATH`), with hardened runtime and the iCloud container, which Use iCloud Drive needs; without both, ad hoc. `--sign developer-id` fails instead of falling back, and `--sign ad-hoc` skips Developer ID. To preserve an existing app during review, provide a new bundle path and a distinct identifier together: | 17 | The builder signs and verifies the local bundle: with team `9R7DPNW28H`'s Developer ID Application identity (found in the keychain by team, chosen by SHA-1, or `--sign-identity SHA1`) and a Developer ID provisioning profile for `net.paperclover.snowbound` naming `iCloud.net.paperclover.snowbound` (found in Xcode's profile folders, or `--profile PATH`), with hardened runtime and the iCloud container, which iCloud notebooks need; without both, ad hoc. `--sign developer-id` fails instead of falling back, and `--sign ad-hoc` skips Developer ID. To preserve an existing app during review, provide a new bundle path and a distinct identifier together: |
| 18 | 18 | ||
| 19 | ```sh | 19 | ```sh |
| 20 | python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review | 20 | python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review |
| ... | @@ -52,7 +52,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a | ... | @@ -52,7 +52,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a |
| 52 | 52 | ||
| 53 | Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings… (⌘,; Options in the sidebar and Ctrl+, on Linux) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). | 53 | Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings… (⌘,; Options in the sidebar and Ctrl+, on Linux) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). |
| 54 | 54 | ||
| 55 | The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. | 55 | The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. Its tab is selected at once, and an open that takes over 80 ms shows a page's outline in place of the page leaving, which takes no input. The pages beside the open one, the sections beside the open section and a hovered page or section tab are read ahead on a thread of their own. A notebook keeps the last three sections left or read ahead open, which `Library::open` hands out, and the last 32 pages shown or read ahead keep their scenes, with up to 128 MiB of decoded pictures, so a page shown again is laid out afresh around pictures already drawn. |
| 56 | 56 | ||
| 57 | A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. | 57 | A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. |
| 58 | 58 |
tools/canvas/build_macos.py+18-1| ... | @@ -39,11 +39,14 @@ if args.output and (args.output.suffix != '.app' or args.output.exists()): | ... | @@ -39,11 +39,14 @@ if args.output and (args.output.suffix != '.app' or args.output.exists()): |
| 39 | root = Path(__file__).resolve().parents[2] | 39 | root = Path(__file__).resolve().parents[2] |
| 40 | # The Apple Developer team and what is registered under it. With the team's Developer ID | 40 | # The Apple Developer team and what is registered under it. With the team's Developer ID |
| 41 | # Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is | 41 | # Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is |
| 42 | # signed with hardened runtime and the iCloud container (Use iCloud Drive, NSUbiquitousContainers); | 42 | # signed with hardened runtime and the iCloud container (iCloud notebooks, NSUbiquitousContainers); |
| 43 | # without them, ad hoc, and iCloud Drive works through folders the user picks. | 43 | # without them, ad hoc, and iCloud Drive works through folders the user picks. |
| 44 | TEAM = '9R7DPNW28H' | 44 | TEAM = '9R7DPNW28H' |
| 45 | BUNDLE_ID = 'net.paperclover.snowbound' | 45 | BUNDLE_ID = 'net.paperclover.snowbound' |
| 46 | CONTAINER = 'iCloud.net.paperclover.snowbound' | 46 | CONTAINER = 'iCloud.net.paperclover.snowbound' |
| 47 | # OneNote's sections and tables of contents, which no Mac app declares; OneNote may open them too. | ||
| 48 | ONENOTE_TYPES = [('com.microsoft.onenote.section', 'OneNote Section', 'one'), | ||
| 49 | ('com.microsoft.onenote.table-of-contents', 'OneNote Table of Contents', 'onetoc2')] | ||
| 47 | 50 | ||
| 48 | 51 | ||
| 49 | def developer_id(): | 52 | def developer_id(): |
| ... | @@ -147,6 +150,20 @@ if build: | ... | @@ -147,6 +150,20 @@ if build: |
| 147 | 'NSUbiquitousContainerSupportedFolderLevels': 'Any', | 150 | 'NSUbiquitousContainerSupportedFolderLevels': 'Any', |
| 148 | }}, | 151 | }}, |
| 149 | 'LSMinimumSystemVersion': minimum, | 152 | 'LSMinimumSystemVersion': minimum, |
| 153 | 'CFBundleDocumentTypes': [{ | ||
| 154 | 'CFBundleTypeName': 'OneNote Notebook', | ||
| 155 | 'CFBundleTypeRole': 'Editor', | ||
| 156 | 'LSHandlerRank': 'Alternate', | ||
| 157 | 'LSItemContentTypes': [identifier for identifier, _, _ in ONENOTE_TYPES], | ||
| 158 | 'CFBundleTypeIconSystemGenerated': 1, | ||
| 159 | }], | ||
| 160 | 'UTImportedTypeDeclarations': [{ | ||
| 161 | 'UTTypeIdentifier': identifier, | ||
| 162 | 'UTTypeDescription': description, | ||
| 163 | 'UTTypeConformsTo': ['public.data'], | ||
| 164 | 'UTTypeTagSpecification': {'public.filename-extension': [extension], | ||
| 165 | 'public.mime-type': 'application/onenote'}, | ||
| 166 | } for identifier, description, extension in ONENOTE_TYPES], | ||
| 150 | } | versions)) | 167 | } | versions)) |
| 151 | # 10.6 runs the bundle unsigned. | 168 | # 10.6 runs the bundle unsigned. |
| 152 | if not args.snow_leopard: | 169 | if not args.snow_leopard: |
tools/native/styles.ps1 created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost | ||
| 5 | # Snowbound makes its folder hidden, which the transfer's archive does not keep. | ||
| 6 | $sidecar = Get-Item -LiteralPath (Join-Path $Root 'notebook\.snowbound') -Force | ||
| 7 | $sidecar.Attributes = $sidecar.Attributes -bor [IO.FileAttributes]::Hidden | ||
tools/native_styles.py created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | """Cold-open a notebook of themed pages in OneNote 2010 and capture what it draws and | ||
| 2 | reports of each page's styles. | ||
| 3 | |||
| 4 | Usage: native_styles.py NOTEBOOK_DIR OUTPUT_DIR | ||
| 5 | """ | ||
| 6 | import sys | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | sys.path.insert(0, str(Path(__file__).resolve().parent)) | ||
| 10 | import native_runner as runner | ||
| 11 | |||
| 12 | if __name__ == '__main__': | ||
| 13 | notebook, output = Path(sys.argv[1]), Path(sys.argv[2]) | ||
| 14 | runner.capture(notebook, output, expected_pages=4, author=Path(__file__).resolve().parent / 'native/styles.ps1', | ||
| 15 | screenshots=True, collect_notebook=True) | ||
tools/test_default_font.py created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | """VM-free gate for corpus/default-font: OneNote 2010 reads the editor's new text and title | ||
| 2 | in the Default font, colour included, as the quick styles it writes itself.""" | ||
| 3 | |||
| 4 | import pathlib | ||
| 5 | import unittest | ||
| 6 | import xml.etree.ElementTree as ElementTree | ||
| 7 | |||
| 8 | ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "default-font" | ||
| 9 | ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}" | ||
| 10 | |||
| 11 | |||
| 12 | class DefaultFont(unittest.TestCase): | ||
| 13 | def test_cold_read_keeps_the_default_font_styles(self): | ||
| 14 | page = ElementTree.parse(ROOT / "cold" / "page.xml").getroot() | ||
| 15 | styles = { | ||
| 16 | style.get("index"): ( | ||
| 17 | style.get("name"), | ||
| 18 | style.get("font"), | ||
| 19 | style.get("fontSize"), | ||
| 20 | style.get("fontColor"), | ||
| 21 | ) | ||
| 22 | for style in page.iter(ONE + "QuickStyleDef") | ||
| 23 | } | ||
| 24 | self.assertIn(("PageTitle", "Georgia", "17.0", "#0070C0"), styles.values()) | ||
| 25 | bodies = [ | ||
| 26 | element | ||
| 27 | for outline in page.iter(ONE + "Outline") | ||
| 28 | for element in outline.iter(ONE + "OE") | ||
| 29 | ] | ||
| 30 | self.assertEqual(len(bodies), 2) | ||
| 31 | for body in bodies: | ||
| 32 | self.assertEqual(styles[body.get("quickStyleIndex")], ("p", "Georgia", "14.0", "#0070C0")) | ||
| 33 | self.assertIsNone(body.find(ONE + "T").get("style")) | ||
| 34 | |||
| 35 | |||
| 36 | if __name__ == "__main__": | ||
| 37 | unittest.main() | ||
tools/test_object_tags.py created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import re | ||
| 3 | import runpy | ||
| 4 | import shutil | ||
| 5 | from tempfile import TemporaryDirectory | ||
| 6 | import unittest | ||
| 7 | |||
| 8 | ROOT = Path(__file__).resolve().parent.parent | ||
| 9 | FIXTURE = ROOT / 'corpus/object-tags' | ||
| 10 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 11 | |||
| 12 | |||
| 13 | def page(read, title): | ||
| 14 | for path in sorted(read.glob('page-*.xml')): | ||
| 15 | xml = path.read_text(encoding='utf-8-sig') | ||
| 16 | if re.search(r'<one:Page [^>]*name="%s"' % re.escape(title), xml): | ||
| 17 | return xml | ||
| 18 | raise AssertionError(title) | ||
| 19 | |||
| 20 | |||
| 21 | def tags(xml): | ||
| 22 | """Each object's check box state in page order: the outline's picture and file, then | ||
| 23 | the picture and file on the page.""" | ||
| 24 | return [m == 'true' for m in re.findall(r'<one:Tag index="0" completed="(\w+)"', xml)] | ||
| 25 | |||
| 26 | |||
| 27 | class ObjectTagsTest(unittest.TestCase): | ||
| 28 | def test_tags_on_pictures_and_files_reopen_as_checked(self): | ||
| 29 | with TemporaryDirectory() as temporary: | ||
| 30 | read = Path(temporary) / 'read' | ||
| 31 | shutil.copytree(FIXTURE / 'cold/read', read) | ||
| 32 | compare(FIXTURE / 'candidate', read) | ||
| 33 | native = page(FIXTURE / 'native/read', 'Pictures and files') | ||
| 34 | cold = page(FIXTURE / 'cold/read', 'Pictures and files') | ||
| 35 | self.assertEqual(tags(native), [False] * 4) | ||
| 36 | self.assertEqual(tags(cold), [True, False, True, True]) | ||
| 37 | # OneNote keeps its tags on the objects themselves, and the picture's recognised text. | ||
| 38 | for xml in (native, cold): | ||
| 39 | self.assertRegex(xml, r'<one:Image [^>]*>(<one:Position[^>]*/>)?<one:Size[^>]*/><one:Tag ') | ||
| 40 | self.assertRegex(xml, r'<one:InsertedFile [^>]*>(<one:Position[^>]*/>)?<one:Size[^>]*/><one:Tag ') | ||
| 41 | self.assertIn('<one:OCRText><![CDATA[GLACIER HARBOR\nInvoice 4471]]></one:OCRText>', xml) | ||
| 42 | |||
| 43 | def test_restored_picture_reopens_with_its_recognised_text(self): | ||
| 44 | restored = FIXTURE / 'restored' | ||
| 45 | with TemporaryDirectory() as temporary: | ||
| 46 | read = Path(temporary) / 'read' | ||
| 47 | shutil.copytree(restored / 'cold/read', read) | ||
| 48 | compare(restored / 'candidate', read) | ||
| 49 | ocr = lambda xml: re.findall(r'<one:OCRData.*?</one:OCRData>', xml, re.S) | ||
| 50 | native = ocr(page(FIXTURE / 'native/read', 'Pictures and files')) | ||
| 51 | self.assertEqual(len(native), 1) | ||
| 52 | self.assertEqual(ocr(page(restored / 'cold/read', 'Pictures and files')), native) | ||
| 53 | |||
| 54 | |||
| 55 | if __name__ == '__main__': | ||
| 56 | unittest.main() | ||
tools/test_picture_link.py created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import re | ||
| 3 | import runpy | ||
| 4 | import shutil | ||
| 5 | from tempfile import TemporaryDirectory | ||
| 6 | import unittest | ||
| 7 | |||
| 8 | ROOT = Path(__file__).resolve().parent.parent | ||
| 9 | FIXTURE = ROOT / 'corpus/picture-link' | ||
| 10 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 11 | |||
| 12 | |||
| 13 | class PictureLinkTest(unittest.TestCase): | ||
| 14 | def test_restored_picture_reopens_with_its_link(self): | ||
| 15 | with TemporaryDirectory() as temporary: | ||
| 16 | read = Path(temporary) / 'read' | ||
| 17 | shutil.copytree(FIXTURE / 'cold/read', read) | ||
| 18 | compare(FIXTURE / 'candidate', read) | ||
| 19 | links = sorted(m for path in (FIXTURE / 'cold/read').glob('page-*.xml') | ||
| 20 | for m in re.findall(r'<one:Image [^>]*hyperlink="([^"]*)"', | ||
| 21 | path.read_text(encoding='utf-8-sig'))) | ||
| 22 | self.assertIn('https://example.invalid/image/png', links) | ||
| 23 | self.assertEqual(len(links), 5) | ||
| 24 | |||
| 25 | |||
| 26 | if __name__ == '__main__': | ||
| 27 | unittest.main() | ||
tools/test_printout.py+2-2| ... | @@ -32,9 +32,9 @@ class PrintoutTest(unittest.TestCase): | ... | @@ -32,9 +32,9 @@ class PrintoutTest(unittest.TestCase): |
| 32 | native = pictures(FIXTURE / 'native/read') | 32 | native = pictures(FIXTURE / 'native/read') |
| 33 | self.assertEqual([(p, n) for p, n, _ in native], [('true', '0'), ('true', '1')]) | 33 | self.assertEqual([(p, n) for p, n, _ in native], [('true', '0'), ('true', '1')]) |
| 34 | # The first page moved half an inch down stays a printout; the second, deleted and | 34 | # The first page moved half an inch down stays a printout; the second, deleted and |
| 35 | # restored, is the picture OneNote showed of it. | 35 | # restored, is the printout page it was. |
| 36 | cold = pictures(FIXTURE / 'cold/read') | 36 | cold = pictures(FIXTURE / 'cold/read') |
| 37 | self.assertEqual(cold, [('true', '0', str(float(native[0][2]) + 36)), (None, None, native[1][2])]) | 37 | self.assertEqual(cold, [('true', '0', str(float(native[0][2]) + 36)), ('true', '1', native[1][2])]) |
| 38 | 38 | ||
| 39 | 39 | ||
| 40 | if __name__ == '__main__': | 40 | if __name__ == '__main__': |
tools/test_recycle_purge.py created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | """VM-free gate for corpus/recycle-purge: OneNote 2010 reads a recycle bin Snowbound purged | ||
| 2 | as empty, without repairing it.""" | ||
| 3 | |||
| 4 | import pathlib | ||
| 5 | import unittest | ||
| 6 | import xml.etree.ElementTree as ElementTree | ||
| 7 | |||
| 8 | ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "recycle-purge" | ||
| 9 | ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}" | ||
| 10 | |||
| 11 | |||
| 12 | class RecyclePurge(unittest.TestCase): | ||
| 13 | def test_cold_read_finds_the_bin_empty(self): | ||
| 14 | notebook = ElementTree.parse(ROOT / "cold" / "hierarchy.xml").getroot() | ||
| 15 | bin_ = next( | ||
| 16 | group | ||
| 17 | for group in notebook.iter(ONE + "SectionGroup") | ||
| 18 | if group.get("isRecycleBin") == "true" | ||
| 19 | ) | ||
| 20 | sections = list(bin_.iter(ONE + "Section")) | ||
| 21 | self.assertEqual([section.get("name") for section in sections], ["Deleted Pages"]) | ||
| 22 | self.assertEqual(list(sections[0].iter(ONE + "Page")), []) | ||
| 23 | kept = [section.get("name") for section in notebook.findall(ONE + "Section")] | ||
| 24 | self.assertEqual(kept, ["New Section 1"]) | ||
| 25 | self.assertFalse((ROOT / "candidate" / "OneNote_RecycleBin" / "Old.one").exists()) | ||
| 26 | |||
| 27 | |||
| 28 | if __name__ == "__main__": | ||
| 29 | unittest.main() | ||
tools/test_section_color.py created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | """VM-free gate for corpus/section-color: OneNote 2010 reads each section the app coloured, None | ||
| 2 | included, and the notebook's colour as its own menus set them.""" | ||
| 3 | |||
| 4 | import pathlib | ||
| 5 | import unittest | ||
| 6 | import xml.etree.ElementTree as ElementTree | ||
| 7 | |||
| 8 | ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "section-color" | ||
| 9 | ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}" | ||
| 10 | |||
| 11 | |||
| 12 | class SectionColor(unittest.TestCase): | ||
| 13 | def test_cold_read_keeps_every_colour(self): | ||
| 14 | menu = [ | ||
| 15 | line.rsplit(" ", 1) | ||
| 16 | for line in (ROOT / "native" / "colors.txt").read_text().splitlines() | ||
| 17 | ] | ||
| 18 | hierarchy = ElementTree.parse(ROOT / "cold" / "read" / "hierarchy.xml").getroot() | ||
| 19 | notebook = next(hierarchy.iter(ONE + "Notebook")) | ||
| 20 | self.assertEqual(notebook.get("color"), "#4DBCCA") | ||
| 21 | sections = { | ||
| 22 | section.get("name"): section.get("color") | ||
| 23 | for section in notebook.iter(ONE + "Section") | ||
| 24 | } | ||
| 25 | self.assertEqual(sections.pop("New Section 1"), "#8AA8E4") | ||
| 26 | self.assertEqual(sections, dict(menu)) | ||
| 27 | |||
| 28 | |||
| 29 | if __name__ == "__main__": | ||
| 30 | unittest.main() | ||
tools/test_styles.py created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import runpy | ||
| 3 | import shutil | ||
| 4 | from tempfile import TemporaryDirectory | ||
| 5 | import unittest | ||
| 6 | import xml.etree.ElementTree as ET | ||
| 7 | |||
| 8 | from native_xml import ns | ||
| 9 | |||
| 10 | ROOT = Path(__file__).resolve().parent.parent | ||
| 11 | FIXTURE = ROOT / 'corpus/styles' | ||
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 13 | GALLERY = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'PageTitle', 'cite', 'blockquote', 'code', 'p'} | ||
| 14 | |||
| 15 | |||
| 16 | def styles(path): | ||
| 17 | """The page's name and its quick styles by name: font, size, bold, colour.""" | ||
| 18 | page = ET.parse(path).getroot() | ||
| 19 | defined = {} | ||
| 20 | for style in page.findall('one:QuickStyleDef', ns): | ||
| 21 | defined.setdefault(style.get('name'), []).append( | ||
| 22 | (style.get('font'), float(style.get('fontSize')), style.get('bold') == 'true', style.get('fontColor'))) | ||
| 23 | return page.get('name'), defined | ||
| 24 | |||
| 25 | |||
| 26 | class StylesTest(unittest.TestCase): | ||
| 27 | def test_themed_pages_cold_open_as_written(self): | ||
| 28 | with TemporaryDirectory() as temporary: | ||
| 29 | read = Path(temporary) / 'read' | ||
| 30 | shutil.copytree(FIXTURE / 'cold/read', read) | ||
| 31 | compare(FIXTURE / 'candidate', read) | ||
| 32 | |||
| 33 | def test_onenote_lists_each_theme_under_its_names(self): | ||
| 34 | heading = { | ||
| 35 | 'OneNote': ('Calibri', 16.0, True, '#17365D'), | ||
| 36 | 'Manuscript': ('Georgia', 20.0, False, '#3B2F2A'), | ||
| 37 | 'Editorial': ('Georgia', 18.0, True, '#9A3B1F'), | ||
| 38 | 'Modern': ('Arial', 16.0, True, '#0E6E6E'), | ||
| 39 | } | ||
| 40 | seen = set() | ||
| 41 | for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')): | ||
| 42 | name, defined = styles(path) | ||
| 43 | theme = name.removeprefix('Heading 1 in ') | ||
| 44 | seen.add(theme) | ||
| 45 | self.assertEqual(set(defined), GALLERY, theme) | ||
| 46 | self.assertEqual(defined['h1'], [heading[theme]], theme) | ||
| 47 | self.assertEqual(seen, set(heading)) | ||
| 48 | |||
| 49 | def test_onenote_s_own_styles_read_under_the_same_names(self): | ||
| 50 | _, applied = styles(FIXTURE / 'onenote/applied.xml') | ||
| 51 | self.assertEqual(set(applied), GALLERY) | ||
| 52 | # OneNote's gallery on a themed page adds its own h2, and Enter its own p. | ||
| 53 | _, edited = styles(FIXTURE / 'onenote-edit/Manuscript.xml') | ||
| 54 | self.assertEqual(sorted(edited['h2']), [('Calibri', 13.0, True, '#366092'), ('Georgia', 16.0, True, '#3B2F2A')]) | ||
| 55 | self.assertEqual(edited['p'], [('Calibri', 11.0, False, 'automatic')]) | ||
| 56 | |||
| 57 | |||
| 58 | if __name__ == '__main__': | ||
| 59 | unittest.main() | ||
tools/test_table_widths.py created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import runpy | ||
| 3 | import shutil | ||
| 4 | from tempfile import TemporaryDirectory | ||
| 5 | import unittest | ||
| 6 | import xml.etree.ElementTree as ET | ||
| 7 | |||
| 8 | from native_xml import ns | ||
| 9 | |||
| 10 | ROOT = Path(__file__).resolve().parent.parent | ||
| 11 | FIXTURE = ROOT / 'corpus/table-widths' | ||
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 13 | |||
| 14 | |||
| 15 | def tables(read): | ||
| 16 | """Each page's tables by title: their first cell text and (width, locked) columns.""" | ||
| 17 | result = {} | ||
| 18 | for path in sorted(read.glob('page-*.xml')): | ||
| 19 | page = ET.parse(path).getroot() | ||
| 20 | title = page.find('one:Title/one:OE/one:T', ns).text | ||
| 21 | result[title] = [ | ||
| 22 | (table.find('.//one:T', ns).text, | ||
| 23 | [(round(float(column.get('width')), 2), column.get('isLocked') == 'true') | ||
| 24 | for column in table.findall('one:Columns/one:Column', ns)]) | ||
| 25 | for table in page.iter('{%s}Table' % ns['one'])] | ||
| 26 | return result | ||
| 27 | |||
| 28 | |||
| 29 | class TableWidthsTest(unittest.TestCase): | ||
| 30 | def test_typed_and_dragged_widths_reopen_natively(self): | ||
| 31 | with TemporaryDirectory() as temporary: | ||
| 32 | read = Path(temporary) / 'read' | ||
| 33 | shutil.copytree(FIXTURE / 'cold/read', read) | ||
| 34 | compare(FIXTURE / 'candidate', read) | ||
| 35 | cold = tables(FIXTURE / 'cold/read') | ||
| 36 | # OneNote 2010's own widths for the same table typed in it (lab, 2026-09-30). | ||
| 37 | self.assertEqual(cold['Check a table'][1], | ||
| 38 | ('Fruit', [(60.76, False), (37.11, False), (139.09, False)])) | ||
| 39 | self.assertEqual(cold['Edit a tagged table'][0][1], [(180.25, False), (37.11, False)]) | ||
| 40 | dragged = cold['Delete and undo'][-1] | ||
| 41 | self.assertEqual(dragged[0], 'Dragged') | ||
| 42 | self.assertEqual(dragged[1][0], (96.0, True)) | ||
| 43 | self.assertFalse(dragged[1][1][1]) | ||
| 44 | self.assertGreater(dragged[1][1][0], 300) | ||
| 45 | |||
| 46 | |||
| 47 | if __name__ == '__main__': | ||
| 48 | unittest.main() | ||