| author | |
| committer | |
| log | 216e64b3c750a6df75ac13a7f3779327f01cbe9e |
| tree | 2b425e0f21e068c09a07ce3ae206e502a3d76b14 |
| parent | 878b4a52ab2bcb1aa6ef93be7f006594429deb4a |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
The caret fades on TextEdit's timing with system selection colours; font menus
preview each family, hide private ones, scroll by thumb and fade at the edges;
sizes can be typed; popups draw above page edges and open on press; the
titlebar zoom draws every step. Pages switch without stalling or losing their
pictures, which decode near the view within a memory budget. Tags size and sit
like OneNote's, the outline box contains them, To Do boxes check on click,
drags snap to the margin grid, and pages open and scroll where OneNote puts
them. Emptying a box no longer freezes drawing, saves run before accessibility
updates, black highlight censors in both themes, and pasted text takes the
keyboard language. apps/ios and crates/mobile render and edit pages on iPhone.
Assisted-by: claude-opus-5.5
Assisted-by: claude-opus-547 files changed, 5026 insertions(+), 540 deletions(-)
Cargo.lock+13| ... | @@ -735,6 +735,7 @@ version = "0.1.0" | ... | @@ -735,6 +735,7 @@ version = "0.1.0" |
| 735 | dependencies = [ | 735 | dependencies = [ |
| 736 | "bytemuck", | 736 | "bytemuck", |
| 737 | "image", | 737 | "image", |
| 738 | "linebender_resource_handle", | ||
| 738 | "parley", | 739 | "parley", |
| 739 | "png", | 740 | "png", |
| 740 | "pollster", | 741 | "pollster", |
| ... | @@ -1550,6 +1551,18 @@ dependencies = [ | ... | @@ -1550,6 +1551,18 @@ dependencies = [ |
| 1550 | "windows-sys 0.61.2", | 1551 | "windows-sys 0.61.2", |
| 1551 | ] | 1552 | ] |
| 1552 | 1553 | ||
| 1554 | [[package]] | ||
| 1555 | name = "mobile" | ||
| 1556 | version = "0.1.0" | ||
| 1557 | dependencies = [ | ||
| 1558 | "canvas", | ||
| 1559 | "draw", | ||
| 1560 | "onestore", | ||
| 1561 | "parley", | ||
| 1562 | "pollster", | ||
| 1563 | "wgpu", | ||
| 1564 | ] | ||
| 1565 | |||
| 1553 | [[package]] | 1566 | [[package]] |
| 1554 | name = "moxcms" | 1567 | name = "moxcms" |
| 1555 | version = "0.8.1" | 1568 | version = "0.8.1" |
apps/ios/Info.plist created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | <plist version="1.0"> | ||
| 4 | <dict> | ||
| 5 | 	<key>CADisableMinimumFrameDurationOnPhone</key> | ||
| 6 | 	<true/> | ||
| 7 | 	<key>UIApplicationSceneManifest</key> | ||
| 8 | 	<dict> | ||
| 9 | 		<key>UIApplicationSupportsMultipleScenes</key> | ||
| 10 | 		<false/> | ||
| 11 | 	</dict> | ||
| 12 | 	<key>UILaunchScreen</key> | ||
| 13 | 	<dict/> | ||
| 14 | 	<key>UISupportedInterfaceOrientations</key> | ||
| 15 | 	<array> | ||
| 16 | 		<string>UIInterfaceOrientationPortrait</string> | ||
| 17 | 		<string>UIInterfaceOrientationPortraitUpsideDown</string> | ||
| 18 | 		<string>UIInterfaceOrientationLandscapeLeft</string> | ||
| 19 | 		<string>UIInterfaceOrientationLandscapeRight</string> | ||
| 20 | 	</array> | ||
| 21 | </dict> | ||
| 22 | </plist> | ||
apps/ios/Snowbound-Bridging-Header.h created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | #include "snowbound.h" | ||
apps/ios/Snowbound.xcodeproj/project.pbxproj created+291| ... | @@ -0,0 +1,291 @@ | ||
| 1 | // !$*UTF8*$! | ||
| 2 | { | ||
| 3 | 	archiveVersion = 1; | ||
| 4 | 	classes = { | ||
| 5 | 	}; | ||
| 6 | 	objectVersion = 77; | ||
| 7 | 	objects = { | ||
| 8 | |||
| 9 | /* Begin PBXBuildFile section */ | ||
| 10 | 		5B0000000000000000000101 /* Features.one in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Features.one */; }; | ||
| 11 | /* End PBXBuildFile section */ | ||
| 12 | |||
| 13 | /* Begin PBXFileReference section */ | ||
| 14 | 		5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; | ||
| 15 | 		5B0000000000000000000201 /* Features.one */ = {isa = PBXFileReference; lastKnownFileType = file; name = Features.one; path = "../../corpus/media-edit/candidate/Features.one"; sourceTree = SOURCE_ROOT; }; | ||
| 16 | 		5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | ||
| 17 | 		5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; }; | ||
| 18 | 		5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; }; | ||
| 19 | /* End PBXFileReference section */ | ||
| 20 | |||
| 21 | /* Begin PBXFileSystemSynchronizedRootGroup section */ | ||
| 22 | 		5B0000000000000000000300 /* Snowbound */ = { | ||
| 23 | 			isa = PBXFileSystemSynchronizedRootGroup; | ||
| 24 | 			path = Snowbound; | ||
| 25 | 			sourceTree = "<group>"; | ||
| 26 | 		}; | ||
| 27 | /* End PBXFileSystemSynchronizedRootGroup section */ | ||
| 28 | |||
| 29 | /* Begin PBXFrameworksBuildPhase section */ | ||
| 30 | 		5B0000000000000000000400 /* Frameworks */ = { | ||
| 31 | 			isa = PBXFrameworksBuildPhase; | ||
| 32 | 			buildActionMask = 2147483647; | ||
| 33 | 			files = ( | ||
| 34 | 			); | ||
| 35 | 			runOnlyForDeploymentPostprocessing = 0; | ||
| 36 | 		}; | ||
| 37 | /* End PBXFrameworksBuildPhase section */ | ||
| 38 | |||
| 39 | /* Begin PBXGroup section */ | ||
| 40 | 		5B0000000000000000000500 = { | ||
| 41 | 			isa = PBXGroup; | ||
| 42 | 			children = ( | ||
| 43 | 				5B0000000000000000000300 /* Snowbound */, | ||
| 44 | 				5B0000000000000000000201 /* Features.one */, | ||
| 45 | 				5B0000000000000000000202 /* Info.plist */, | ||
| 46 | 				5B0000000000000000000203 /* Snowbound-Bridging-Header.h */, | ||
| 47 | 				5B0000000000000000000204 /* build-rust.sh */, | ||
| 48 | 				5B0000000000000000000501 /* Products */, | ||
| 49 | 			); | ||
| 50 | 			sourceTree = "<group>"; | ||
| 51 | 		}; | ||
| 52 | 		5B0000000000000000000501 /* Products */ = { | ||
| 53 | 			isa = PBXGroup; | ||
| 54 | 			children = ( | ||
| 55 | 				5B0000000000000000000200 /* Snowbound.app */, | ||
| 56 | 			); | ||
| 57 | 			name = Products; | ||
| 58 | 			sourceTree = "<group>"; | ||
| 59 | 		}; | ||
| 60 | /* End PBXGroup section */ | ||
| 61 | |||
| 62 | /* Begin PBXNativeTarget section */ | ||
| 63 | 		5B0000000000000000000600 /* Snowbound */ = { | ||
| 64 | 			isa = PBXNativeTarget; | ||
| 65 | 			buildConfigurationList = 5B0000000000000000000901 /* Build configuration list for PBXNativeTarget "Snowbound" */; | ||
| 66 | 			buildPhases = ( | ||
| 67 | 				5B0000000000000000000700 /* Build Rust */, | ||
| 68 | 				5B0000000000000000000701 /* Sources */, | ||
| 69 | 				5B0000000000000000000400 /* Frameworks */, | ||
| 70 | 				5B0000000000000000000702 /* Resources */, | ||
| 71 | 			); | ||
| 72 | 			buildRules = ( | ||
| 73 | 			); | ||
| 74 | 			dependencies = ( | ||
| 75 | 			); | ||
| 76 | 			fileSystemSynchronizedGroups = ( | ||
| 77 | 				5B0000000000000000000300 /* Snowbound */, | ||
| 78 | 			); | ||
| 79 | 			name = Snowbound; | ||
| 80 | 			productName = Snowbound; | ||
| 81 | 			productReference = 5B0000000000000000000200 /* Snowbound.app */; | ||
| 82 | 			productType = "com.apple.product-type.application"; | ||
| 83 | 		}; | ||
| 84 | /* End PBXNativeTarget section */ | ||
| 85 | |||
| 86 | /* Begin PBXProject section */ | ||
| 87 | 		5B0000000000000000000800 /* Project object */ = { | ||
| 88 | 			isa = PBXProject; | ||
| 89 | 			attributes = { | ||
| 90 | 				BuildIndependentTargetsInParallel = 1; | ||
| 91 | 				LastSwiftUpdateCheck = 2630; | ||
| 92 | 				LastUpgradeCheck = 2630; | ||
| 93 | 				TargetAttributes = { | ||
| 94 | 					5B0000000000000000000600 = { | ||
| 95 | 						CreatedOnToolsVersion = 26.3; | ||
| 96 | 					}; | ||
| 97 | 				}; | ||
| 98 | 			}; | ||
| 99 | 			buildConfigurationList = 5B0000000000000000000900 /* Build configuration list for PBXProject "Snowbound" */; | ||
| 100 | 			developmentRegion = en; | ||
| 101 | 			hasScannedForEncodings = 0; | ||
| 102 | 			knownRegions = ( | ||
| 103 | 				en, | ||
| 104 | 				Base, | ||
| 105 | 			); | ||
| 106 | 			mainGroup = 5B0000000000000000000500; | ||
| 107 | 			minimizedProjectReferenceProxies = 1; | ||
| 108 | 			preferredProjectObjectVersion = 77; | ||
| 109 | 			productRefGroup = 5B0000000000000000000501 /* Products */; | ||
| 110 | 			projectDirPath = ""; | ||
| 111 | 			projectRoot = ""; | ||
| 112 | 			targets = ( | ||
| 113 | 				5B0000000000000000000600 /* Snowbound */, | ||
| 114 | 			); | ||
| 115 | 		}; | ||
| 116 | /* End PBXProject section */ | ||
| 117 | |||
| 118 | /* Begin PBXResourcesBuildPhase section */ | ||
| 119 | 		5B0000000000000000000702 /* Resources */ = { | ||
| 120 | 			isa = PBXResourcesBuildPhase; | ||
| 121 | 			buildActionMask = 2147483647; | ||
| 122 | 			files = ( | ||
| 123 | 				5B0000000000000000000101 /* Features.one in Resources */, | ||
| 124 | 			); | ||
| 125 | 			runOnlyForDeploymentPostprocessing = 0; | ||
| 126 | 		}; | ||
| 127 | /* End PBXResourcesBuildPhase section */ | ||
| 128 | |||
| 129 | /* Begin PBXShellScriptBuildPhase section */ | ||
| 130 | 		5B0000000000000000000700 /* Build Rust */ = { | ||
| 131 | 			isa = PBXShellScriptBuildPhase; | ||
| 132 | 			alwaysOutOfDate = 1; | ||
| 133 | 			buildActionMask = 2147483647; | ||
| 134 | 			files = ( | ||
| 135 | 			); | ||
| 136 | 			inputFileListPaths = ( | ||
| 137 | 			); | ||
| 138 | 			inputPaths = ( | ||
| 139 | 			); | ||
| 140 | 			name = "Build Rust"; | ||
| 141 | 			outputFileListPaths = ( | ||
| 142 | 			); | ||
| 143 | 			outputPaths = ( | ||
| 144 | 			); | ||
| 145 | 			runOnlyForDeploymentPostprocessing = 0; | ||
| 146 | 			shellPath = /bin/sh; | ||
| 147 | 			shellScript = "\"$SRCROOT/build-rust.sh\"\n"; | ||
| 148 | 		}; | ||
| 149 | /* End PBXShellScriptBuildPhase section */ | ||
| 150 | |||
| 151 | /* Begin PBXSourcesBuildPhase section */ | ||
| 152 | 		5B0000000000000000000701 /* Sources */ = { | ||
| 153 | 			isa = PBXSourcesBuildPhase; | ||
| 154 | 			buildActionMask = 2147483647; | ||
| 155 | 			files = ( | ||
| 156 | 			); | ||
| 157 | 			runOnlyForDeploymentPostprocessing = 0; | ||
| 158 | 		}; | ||
| 159 | /* End PBXSourcesBuildPhase section */ | ||
| 160 | |||
| 161 | /* Begin XCBuildConfiguration section */ | ||
| 162 | 		5B0000000000000000000A00 /* Debug */ = { | ||
| 163 | 			isa = XCBuildConfiguration; | ||
| 164 | 			buildSettings = { | ||
| 165 | 				ALWAYS_SEARCH_USER_PATHS = NO; | ||
| 166 | 				CLANG_ENABLE_MODULES = YES; | ||
| 167 | 				CODE_SIGN_STYLE = Automatic; | ||
| 168 | 				DEBUG_INFORMATION_FORMAT = dwarf; | ||
| 169 | 				DEVELOPMENT_TEAM = 9R7DPNW28H; | ||
| 170 | 				ENABLE_TESTABILITY = YES; | ||
| 171 | 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||
| 172 | 				GCC_OPTIMIZATION_LEVEL = 0; | ||
| 173 | 				IPHONEOS_DEPLOYMENT_TARGET = 17.0; | ||
| 174 | 				ONLY_ACTIVE_ARCH = YES; | ||
| 175 | 				SDKROOT = iphoneos; | ||
| 176 | 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; | ||
| 177 | 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||
| 178 | 			}; | ||
| 179 | 			name = Debug; | ||
| 180 | 		}; | ||
| 181 | 		5B0000000000000000000A01 /* Release */ = { | ||
| 182 | 			isa = XCBuildConfiguration; | ||
| 183 | 			buildSettings = { | ||
| 184 | 				ALWAYS_SEARCH_USER_PATHS = NO; | ||
| 185 | 				CLANG_ENABLE_MODULES = YES; | ||
| 186 | 				CODE_SIGN_STYLE = Automatic; | ||
| 187 | 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; | ||
| 188 | 				DEVELOPMENT_TEAM = 9R7DPNW28H; | ||
| 189 | 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||
| 190 | 				IPHONEOS_DEPLOYMENT_TARGET = 17.0; | ||
| 191 | 				SDKROOT = iphoneos; | ||
| 192 | 				SWIFT_COMPILATION_MODE = wholemodule; | ||
| 193 | 				VALIDATE_PRODUCT = YES; | ||
| 194 | 			}; | ||
| 195 | 			name = Release; | ||
| 196 | 		}; | ||
| 197 | 		5B0000000000000000000A02 /* Debug */ = { | ||
| 198 | 			isa = XCBuildConfiguration; | ||
| 199 | 			buildSettings = { | ||
| 200 | 				ARCHS = arm64; | ||
| 201 | 				CURRENT_PROJECT_VERSION = 1; | ||
| 202 | 				GENERATE_INFOPLIST_FILE = YES; | ||
| 203 | 				HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include"; | ||
| 204 | 				INFOPLIST_FILE = Info.plist; | ||
| 205 | 				INFOPLIST_KEY_CFBundleDisplayName = Snowbound; | ||
| 206 | 				LD_RUNPATH_SEARCH_PATHS = ( | ||
| 207 | 					"$(inherited)", | ||
| 208 | 					"@executable_path/Frameworks", | ||
| 209 | 				); | ||
| 210 | 				"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(SRCROOT)/../../target/ios/aarch64-apple-ios/debug"; | ||
| 211 | 				"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(SRCROOT)/../../target/ios/aarch64-apple-ios-sim/debug"; | ||
| 212 | 				MARKETING_VERSION = 0.1; | ||
| 213 | 				OTHER_LDFLAGS = ( | ||
| 214 | 					"-lmobile", | ||
| 215 | 					"-framework", | ||
| 216 | 					Metal, | ||
| 217 | 					"-framework", | ||
| 218 | 					QuartzCore, | ||
| 219 | 					"-framework", | ||
| 220 | 					CoreText, | ||
| 221 | 				); | ||
| 222 | 				PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound; | ||
| 223 | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||
| 224 | 				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; | ||
| 225 | 				SWIFT_EMIT_LOC_STRINGS = NO; | ||
| 226 | 				SWIFT_OBJC_BRIDGING_HEADER = "Snowbound-Bridging-Header.h"; | ||
| 227 | 				SWIFT_VERSION = 5.0; | ||
| 228 | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||
| 229 | 			}; | ||
| 230 | 			name = Debug; | ||
| 231 | 		}; | ||
| 232 | 		5B0000000000000000000A03 /* Release */ = { | ||
| 233 | 			isa = XCBuildConfiguration; | ||
| 234 | 			buildSettings = { | ||
| 235 | 				ARCHS = arm64; | ||
| 236 | 				CURRENT_PROJECT_VERSION = 1; | ||
| 237 | 				GENERATE_INFOPLIST_FILE = YES; | ||
| 238 | 				HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include"; | ||
| 239 | 				INFOPLIST_FILE = Info.plist; | ||
| 240 | 				INFOPLIST_KEY_CFBundleDisplayName = Snowbound; | ||
| 241 | 				LD_RUNPATH_SEARCH_PATHS = ( | ||
| 242 | 					"$(inherited)", | ||
| 243 | 					"@executable_path/Frameworks", | ||
| 244 | 				); | ||
| 245 | 				"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(SRCROOT)/../../target/ios/aarch64-apple-ios/release"; | ||
| 246 | 				"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(SRCROOT)/../../target/ios/aarch64-apple-ios-sim/release"; | ||
| 247 | 				MARKETING_VERSION = 0.1; | ||
| 248 | 				OTHER_LDFLAGS = ( | ||
| 249 | 					"-lmobile", | ||
| 250 | 					"-framework", | ||
| 251 | 					Metal, | ||
| 252 | 					"-framework", | ||
| 253 | 					QuartzCore, | ||
| 254 | 					"-framework", | ||
| 255 | 					CoreText, | ||
| 256 | 				); | ||
| 257 | 				PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound; | ||
| 258 | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||
| 259 | 				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; | ||
| 260 | 				SWIFT_EMIT_LOC_STRINGS = NO; | ||
| 261 | 				SWIFT_OBJC_BRIDGING_HEADER = "Snowbound-Bridging-Header.h"; | ||
| 262 | 				SWIFT_VERSION = 5.0; | ||
| 263 | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||
| 264 | 			}; | ||
| 265 | 			name = Release; | ||
| 266 | 		}; | ||
| 267 | /* End XCBuildConfiguration section */ | ||
| 268 | |||
| 269 | /* Begin XCConfigurationList section */ | ||
| 270 | 		5B0000000000000000000900 /* Build configuration list for PBXProject "Snowbound" */ = { | ||
| 271 | 			isa = XCConfigurationList; | ||
| 272 | 			buildConfigurations = ( | ||
| 273 | 				5B0000000000000000000A00 /* Debug */, | ||
| 274 | 				5B0000000000000000000A01 /* Release */, | ||
| 275 | 			); | ||
| 276 | 			defaultConfigurationIsVisible = 0; | ||
| 277 | 			defaultConfigurationName = Release; | ||
| 278 | 		}; | ||
| 279 | 		5B0000000000000000000901 /* Build configuration list for PBXNativeTarget "Snowbound" */ = { | ||
| 280 | 			isa = XCConfigurationList; | ||
| 281 | 			buildConfigurations = ( | ||
| 282 | 				5B0000000000000000000A02 /* Debug */, | ||
| 283 | 				5B0000000000000000000A03 /* Release */, | ||
| 284 | 			); | ||
| 285 | 			defaultConfigurationIsVisible = 0; | ||
| 286 | 			defaultConfigurationName = Release; | ||
| 287 | 		}; | ||
| 288 | /* End XCConfigurationList section */ | ||
| 289 | 	}; | ||
| 290 | 	rootObject = 5B0000000000000000000800 /* Project object */; | ||
| 291 | } | ||
apps/ios/Snowbound/App.swift created+130| ... | @@ -0,0 +1,130 @@ | ||
| 1 | import UIKit | ||
| 2 | import UniformTypeIdentifiers | ||
| 3 | |||
| 4 | @main | ||
| 5 | final class AppDelegate: UIResponder, UIApplicationDelegate { | ||
| 6 | func application( | ||
| 7 | _ application: UIApplication, | ||
| 8 | configurationForConnecting session: UISceneSession, | ||
| 9 | options: UIScene.ConnectionOptions | ||
| 10 | ) -> UISceneConfiguration { | ||
| 11 | let configuration = UISceneConfiguration(name: nil, sessionRole: session.role) | ||
| 12 | configuration.delegateClass = SceneDelegate.self | ||
| 13 | return configuration | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { | ||
| 18 | var window: UIWindow? | ||
| 19 | |||
| 20 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { | ||
| 21 | guard let scene = scene as? UIWindowScene else { return } | ||
| 22 | let window = UIWindow(windowScene: scene) | ||
| 23 | let path = ProcessInfo.processInfo.environment["SNOWBOUND_SECTION"] | ||
| 24 | ?? Bundle.main.path(forResource: "Features", ofType: "one")! | ||
| 25 | let pages = PagesViewController(section: Section(path: path)) | ||
| 26 | window.rootViewController = UINavigationController(rootViewController: pages) | ||
| 27 | window.makeKeyAndVisible() | ||
| 28 | self.window = window | ||
| 29 | if let page = ProcessInfo.processInfo.environment["SNOWBOUND_PAGE"].flatMap(Int.init) { | ||
| 30 | pages.show(page: page, animated: false) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | /// A parsed .one section; pages are read-only copies until saving lands. | ||
| 36 | final class Section { | ||
| 37 | let handle: OpaquePointer? | ||
| 38 | let name: String | ||
| 39 | |||
| 40 | init(path: String) { | ||
| 41 | handle = sb_section_open(path) | ||
| 42 | name = (path as NSString).lastPathComponent.replacingOccurrences(of: ".one", with: "") | ||
| 43 | } | ||
| 44 | |||
| 45 | deinit { if let handle { sb_section_free(handle) } } | ||
| 46 | |||
| 47 | var titles: [String] { | ||
| 48 | guard let handle else { return [] } | ||
| 49 | return (0..<sb_section_count(handle)).map { String(cString: sb_section_title(handle, $0)) } | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | final class PagesViewController: UITableViewController, UIDocumentPickerDelegate { | ||
| 54 | private var section: Section | ||
| 55 | private var titles: [String] | ||
| 56 | |||
| 57 | init(section: Section) { | ||
| 58 | self.section = section | ||
| 59 | titles = section.titles | ||
| 60 | super.init(style: .insetGrouped) | ||
| 61 | title = section.name | ||
| 62 | } | ||
| 63 | |||
| 64 | required init?(coder: NSCoder) { fatalError() } | ||
| 65 | |||
| 66 | override func viewDidLoad() { | ||
| 67 | super.viewDidLoad() | ||
| 68 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") | ||
| 69 | navigationItem.rightBarButtonItem = UIBarButtonItem( | ||
| 70 | title: "Open", image: UIImage(systemName: "folder"), target: self, action: #selector(open)) | ||
| 71 | } | ||
| 72 | |||
| 73 | @objc private func open() { | ||
| 74 | let picker = UIDocumentPickerViewController( | ||
| 75 | forOpeningContentTypes: [UTType(filenameExtension: "one") ?? .data], asCopy: true) | ||
| 76 | picker.delegate = self | ||
| 77 | present(picker, animated: true) | ||
| 78 | } | ||
| 79 | |||
| 80 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { | ||
| 81 | guard let url = urls.first else { return } | ||
| 82 | section = Section(path: url.path) | ||
| 83 | titles = section.titles | ||
| 84 | title = section.name | ||
| 85 | tableView.reloadData() | ||
| 86 | } | ||
| 87 | |||
| 88 | func show(page: Int, animated: Bool) { | ||
| 89 | guard page < titles.count else { return } | ||
| 90 | navigationController?.pushViewController( | ||
| 91 | PageViewController(section: section, page: page, title: titles[page]), animated: animated) | ||
| 92 | } | ||
| 93 | |||
| 94 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { | ||
| 95 | titles.count | ||
| 96 | } | ||
| 97 | |||
| 98 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | ||
| 99 | let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath) | ||
| 100 | var content = cell.defaultContentConfiguration() | ||
| 101 | content.text = titles[indexPath.row].isEmpty ? "Untitled page" : titles[indexPath.row] | ||
| 102 | cell.contentConfiguration = content | ||
| 103 | cell.accessoryType = .disclosureIndicator | ||
| 104 | return cell | ||
| 105 | } | ||
| 106 | |||
| 107 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | ||
| 108 | tableView.deselectRow(at: indexPath, animated: true) | ||
| 109 | show(page: indexPath.row, animated: true) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | final class PageViewController: UIViewController { | ||
| 114 | private let section: Section | ||
| 115 | private let page: Int | ||
| 116 | |||
| 117 | init(section: Section, page: Int, title: String) { | ||
| 118 | self.section = section | ||
| 119 | self.page = page | ||
| 120 | super.init(nibName: nil, bundle: nil) | ||
| 121 | self.title = title | ||
| 122 | navigationItem.largeTitleDisplayMode = .never | ||
| 123 | } | ||
| 124 | |||
| 125 | required init?(coder: NSCoder) { fatalError() } | ||
| 126 | |||
| 127 | override func loadView() { | ||
| 128 | view = CanvasView(section: section, page: page) | ||
| 129 | } | ||
| 130 | } | ||
apps/ios/Snowbound/CanvasView.swift created+449| ... | @@ -0,0 +1,449 @@ | ||
| 1 | import UIKit | ||
| 2 | |||
| 3 | private final class MetalView: UIView { | ||
| 4 | override class var layerClass: AnyClass { CAMetalLayer.self } | ||
| 5 | } | ||
| 6 | |||
| 7 | /// An offset into the active outline's text in UTF-16 units, paragraphs joined by a newline. | ||
| 8 | private final class Position: UITextPosition { | ||
| 9 | let value: Int | ||
| 10 | init(_ value: Int) { self.value = value } | ||
| 11 | } | ||
| 12 | |||
| 13 | private final class Range: UITextRange { | ||
| 14 | let lo: Int | ||
| 15 | let hi: Int | ||
| 16 | init(_ a: Int, _ b: Int) { (lo, hi) = (min(a, b), max(a, b)) } | ||
| 17 | override var start: UITextPosition { Position(lo) } | ||
| 18 | override var end: UITextPosition { Position(hi) } | ||
| 19 | override var isEmpty: Bool { lo == hi } | ||
| 20 | } | ||
| 21 | |||
| 22 | private final class SelectionRect: UITextSelectionRect { | ||
| 23 | private let frame: CGRect | ||
| 24 | private let first: Bool | ||
| 25 | private let last: Bool | ||
| 26 | init(_ frame: CGRect, first: Bool, last: Bool) { (self.frame, self.first, self.last) = (frame, first, last) } | ||
| 27 | override var rect: CGRect { frame } | ||
| 28 | override var writingDirection: NSWritingDirection { .leftToRight } | ||
| 29 | override var containsStart: Bool { first } | ||
| 30 | override var containsEnd: Bool { last } | ||
| 31 | override var isVertical: Bool { false } | ||
| 32 | } | ||
| 33 | |||
| 34 | private extension UITextPosition { var offset: Int { (self as! Position).value } } | ||
| 35 | private extension UITextRange { | ||
| 36 | var lower: Int { (self as! Range).lo } | ||
| 37 | var upper: Int { (self as! Range).hi } | ||
| 38 | } | ||
| 39 | |||
| 40 | /// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page | ||
| 41 | /// drawn into a Metal layer pinned to the visible bounds, and the active outline's text | ||
| 42 | /// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, | ||
| 43 | /// selection handles and loupe work on it. | ||
| 44 | final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate, | ||
| 45 | UITextSelectionDisplayInteractionDelegate | ||
| 46 | { | ||
| 47 | private let section: Section | ||
| 48 | private let page: Int | ||
| 49 | private let metal = MetalView() | ||
| 50 | /// Sized to the page content at 100%, so the scroll view's zoom scale is the page zoom. | ||
| 51 | private let content = UIView() | ||
| 52 | private var handle: OpaquePointer? | ||
| 53 | private var link: CADisplayLink? | ||
| 54 | private var dirty = true | ||
| 55 | /// The content's corner at 100%, from which the scroll offset is measured. | ||
| 56 | private var origin = CGPoint.zero | ||
| 57 | private var syncing = false | ||
| 58 | private var scrolled = false | ||
| 59 | private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) | ||
| 60 | private let interaction = UITextInteraction(for: .editable) | ||
| 61 | /// Draws the system caret, selection highlight and handles; the canvas paints neither. | ||
| 62 | private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self) | ||
| 63 | |||
| 64 | weak var inputDelegate: UITextInputDelegate? | ||
| 65 | lazy var tokenizer: UITextInputTokenizer = UITextInputStringTokenizer(textInput: self) | ||
| 66 | |||
| 67 | init(section: Section, page: Int) { | ||
| 68 | self.section = section | ||
| 69 | self.page = page | ||
| 70 | super.init(frame: .zero) | ||
| 71 | backgroundColor = .white | ||
| 72 | delegate = self | ||
| 73 | minimumZoomScale = 0.25 | ||
| 74 | maximumZoomScale = 4 | ||
| 75 | keyboardDismissMode = .interactive | ||
| 76 | addSubview(content) | ||
| 77 | insertSubview(metal, at: 0) | ||
| 78 | metal.isUserInteractionEnabled = false | ||
| 79 | let layer = metal.layer as! CAMetalLayer | ||
| 80 | layer.isOpaque = true | ||
| 81 | // Frames reach the screen with the transaction that moves UIKit's caret and handles. | ||
| 82 | layer.presentsWithTransaction = true | ||
| 83 | addGestureRecognizer(tap) | ||
| 84 | interaction.textInput = self | ||
| 85 | interaction.delegate = self | ||
| 86 | addInteraction(interaction) | ||
| 87 | addInteraction(display) | ||
| 88 | NotificationCenter.default.addObserver( | ||
| 89 | self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification, | ||
| 90 | object: nil) | ||
| 91 | } | ||
| 92 | |||
| 93 | required init?(coder: NSCoder) { fatalError() } | ||
| 94 | |||
| 95 | deinit { | ||
| 96 | link?.invalidate() | ||
| 97 | if let handle { sb_view_free(handle) } | ||
| 98 | } | ||
| 99 | |||
| 100 | // MARK: Drawing | ||
| 101 | |||
| 102 | override func layoutSubviews() { | ||
| 103 | super.layoutSubviews() | ||
| 104 | metal.frame = bounds | ||
| 105 | let scale = window?.screen.scale ?? 3 | ||
| 106 | metal.layer.contentsScale = scale | ||
| 107 | guard bounds.width > 0, bounds.height > 0 else { return } | ||
| 108 | let pixels = CGSize(width: (bounds.width * scale).rounded(), height: (bounds.height * scale).rounded()) | ||
| 109 | if let handle { | ||
| 110 | // Scrolling lays the view out too; only a new size reconfigures the surface. | ||
| 111 | guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return } | ||
| 112 | sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale)) | ||
| 113 | transform() | ||
| 114 | } else if let sectionHandle = section.handle { | ||
| 115 | handle = sb_view_new( | ||
| 116 | Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page, | ||
| 117 | Float(bounds.width), Float(bounds.height), Float(scale)) | ||
| 118 | guard let handle else { return } | ||
| 119 | sync() | ||
| 120 | let fit = (bounds.width - safeAreaInsets.left - safeAreaInsets.right) / content.bounds.width | ||
| 121 | zoomScale = min(1, max(0.5, fit)) | ||
| 122 | home() | ||
| 123 | #if DEBUG | ||
| 124 | runScript() | ||
| 125 | #endif | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | override func didMoveToWindow() { | ||
| 130 | super.didMoveToWindow() | ||
| 131 | link?.invalidate() | ||
| 132 | link = nil | ||
| 133 | guard window != nil else { return } | ||
| 134 | let link = CADisplayLink(target: self, selector: #selector(frame(_:))) | ||
| 135 | link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) | ||
| 136 | link.add(to: .main, forMode: .common) | ||
| 137 | self.link = link | ||
| 138 | } | ||
| 139 | |||
| 140 | @objc private func frame(_ link: CADisplayLink) { | ||
| 141 | guard let handle else { return } | ||
| 142 | if sb_view_frame_pending(handle) { dirty = true } | ||
| 143 | guard dirty else { return } | ||
| 144 | dirty = false | ||
| 145 | sb_view_render(handle) | ||
| 146 | } | ||
| 147 | |||
| 148 | // MARK: Scrolling and zoom | ||
| 149 | |||
| 150 | /// Sizes the scroll content to the page's after an edit, keeping the page point at the | ||
| 151 | /// view's corner. The scroll view owns the offset: the canvas's own clamping and caret | ||
| 152 | /// reveal know neither the navigation bar nor the keyboard, so `transform` overrides them. | ||
| 153 | private func sync() { | ||
| 154 | guard let handle else { return } | ||
| 155 | var bounds: [Float] = [0, 0, 0, 0] | ||
| 156 | sb_view_content(handle, &bounds) | ||
| 157 | let corner = CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale) | ||
| 158 | origin = CGPoint(x: CGFloat(bounds[0]), y: CGFloat(bounds[1])) | ||
| 159 | let size = CGSize(width: CGFloat(bounds[2]) - origin.x, height: CGFloat(bounds[3]) - origin.y) | ||
| 160 | syncing = true | ||
| 161 | content.bounds = CGRect(origin: .zero, size: size) | ||
| 162 | content.center = CGPoint(x: size.width * zoomScale / 2, y: size.height * zoomScale / 2) | ||
| 163 | contentSize = CGSize(width: size.width * zoomScale, height: size.height * zoomScale) | ||
| 164 | contentOffset = CGPoint(x: (corner.x - origin.x) * zoomScale, y: (corner.y - origin.y) * zoomScale) | ||
| 165 | syncing = false | ||
| 166 | transform() | ||
| 167 | } | ||
| 168 | |||
| 169 | private func transform() { | ||
| 170 | guard let handle, !syncing else { return } | ||
| 171 | sb_view_set_transform( | ||
| 172 | handle, Float(zoomScale), Float(origin.x + contentOffset.x / zoomScale), | ||
| 173 | Float(origin.y + contentOffset.y / zoomScale)) | ||
| 174 | metal.frame = bounds | ||
| 175 | dirty = true | ||
| 176 | display.setNeedsSelectionUpdate() | ||
| 177 | } | ||
| 178 | |||
| 179 | /// Until the reader scrolls, the page's corner stays below the navigation bar as its | ||
| 180 | /// insets settle. | ||
| 181 | override func adjustedContentInsetDidChange() { | ||
| 182 | super.adjustedContentInsetDidChange() | ||
| 183 | home() | ||
| 184 | } | ||
| 185 | |||
| 186 | private func home() { | ||
| 187 | if !scrolled { contentOffset = CGPoint(x: -adjustedContentInset.left, y: -adjustedContentInset.top) } | ||
| 188 | } | ||
| 189 | |||
| 190 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true } | ||
| 191 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { content } | ||
| 192 | func scrollViewDidScroll(_ scrollView: UIScrollView) { transform() } | ||
| 193 | func scrollViewDidZoom(_ scrollView: UIScrollView) { transform() } | ||
| 194 | |||
| 195 | @objc private func keyboardChanged(_ notification: Notification) { | ||
| 196 | guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, | ||
| 197 | let window | ||
| 198 | else { return } | ||
| 199 | let keyboard = convert(frame, from: window.screen.coordinateSpace) | ||
| 200 | let overlap = max(0, bounds.maxY - keyboard.minY - safeAreaInsets.bottom) | ||
| 201 | contentInset.bottom = overlap | ||
| 202 | verticalScrollIndicatorInsets.bottom = overlap | ||
| 203 | revealCaret() | ||
| 204 | } | ||
| 205 | |||
| 206 | // MARK: Touch | ||
| 207 | |||
| 208 | /// The canvas places the caret, focuses outlines and starts new ones; the system's text | ||
| 209 | /// interaction takes over inside the outline already taking input. | ||
| 210 | private func inActiveText(_ point: CGPoint) -> Bool { | ||
| 211 | guard let handle else { return false } | ||
| 212 | let local = visible(point) | ||
| 213 | return isFirstResponder && sb_view_in_active_text(handle, Float(local.x), Float(local.y)) | ||
| 214 | } | ||
| 215 | |||
| 216 | override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { | ||
| 217 | recognizer === tap | ||
| 218 | ? !inActiveText(recognizer.location(in: self)) : super.gestureRecognizerShouldBegin(recognizer) | ||
| 219 | } | ||
| 220 | |||
| 221 | func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { | ||
| 222 | inActiveText(point) | ||
| 223 | } | ||
| 224 | |||
| 225 | @objc private func tapped(_ recognizer: UITapGestureRecognizer) { | ||
| 226 | tap(at: recognizer.location(in: self)) | ||
| 227 | } | ||
| 228 | |||
| 229 | /// A tap at `point` in the scroll view's bounds. | ||
| 230 | func tap(at point: CGPoint) { | ||
| 231 | guard let handle else { return } | ||
| 232 | let point = visible(point) | ||
| 233 | edit(external: true) { sb_view_tap(handle, Float(point.x), Float(point.y)) } | ||
| 234 | if !isFirstResponder { _ = becomeFirstResponder() } | ||
| 235 | } | ||
| 236 | |||
| 237 | /// Runs a change to the page, telling the system when it did not ask for it. | ||
| 238 | private func edit(external: Bool = false, _ change: () -> Bool) { | ||
| 239 | if external { | ||
| 240 | inputDelegate?.selectionWillChange(self) | ||
| 241 | inputDelegate?.textWillChange(self) | ||
| 242 | } | ||
| 243 | let changed = change() | ||
| 244 | if external { | ||
| 245 | inputDelegate?.textDidChange(self) | ||
| 246 | inputDelegate?.selectionDidChange(self) | ||
| 247 | } | ||
| 248 | if changed { | ||
| 249 | sync() | ||
| 250 | revealCaret() | ||
| 251 | } | ||
| 252 | } | ||
| 253 | |||
| 254 | private func revealCaret() { | ||
| 255 | guard let handle, isFirstResponder else { return } | ||
| 256 | var range: [UInt32] = [0, 0] | ||
| 257 | sb_selection(handle, &range) | ||
| 258 | let caret = caretRect(for: Position(Int(range[1]))) | ||
| 259 | scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false) | ||
| 260 | } | ||
| 261 | |||
| 262 | override var canBecomeFirstResponder: Bool { handle != nil } | ||
| 263 | |||
| 264 | override func becomeFirstResponder() -> Bool { | ||
| 265 | guard super.becomeFirstResponder() else { return false } | ||
| 266 | display.isActivated = true | ||
| 267 | return true | ||
| 268 | } | ||
| 269 | |||
| 270 | override func resignFirstResponder() -> Bool { | ||
| 271 | guard super.resignFirstResponder() else { return false } | ||
| 272 | display.isActivated = false | ||
| 273 | return true | ||
| 274 | } | ||
| 275 | |||
| 276 | /// Pasted text takes the keyboard's language, as Windows gives the clipboard. | ||
| 277 | override func paste(_ sender: Any?) { | ||
| 278 | guard let handle, let text = UIPasteboard.general.string else { return } | ||
| 279 | edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") } | ||
| 280 | } | ||
| 281 | |||
| 282 | // MARK: Coordinates | ||
| 283 | |||
| 284 | /// A point in the scroll view's bounds as the canvas view's points from its corner. | ||
| 285 | private func visible(_ point: CGPoint) -> CGPoint { | ||
| 286 | CGPoint(x: point.x - contentOffset.x, y: point.y - contentOffset.y) | ||
| 287 | } | ||
| 288 | |||
| 289 | private func bounded(_ rect: [Float]) -> CGRect { | ||
| 290 | CGRect( | ||
| 291 | x: CGFloat(rect[0]) + contentOffset.x, y: CGFloat(rect[1]) + contentOffset.y, | ||
| 292 | width: CGFloat(rect[2]), height: CGFloat(rect[3])) | ||
| 293 | } | ||
| 294 | |||
| 295 | // MARK: UIKeyInput | ||
| 296 | |||
| 297 | var hasText: Bool { handle.map { sb_text_length($0) > 0 } ?? false } | ||
| 298 | |||
| 299 | func insertText(_ text: String) { | ||
| 300 | guard let handle else { return } | ||
| 301 | edit { sb_insert(handle, text) } | ||
| 302 | } | ||
| 303 | |||
| 304 | func deleteBackward() { | ||
| 305 | guard let handle else { return } | ||
| 306 | edit { sb_delete_backward(handle) } | ||
| 307 | } | ||
| 308 | |||
| 309 | // MARK: UITextInput | ||
| 310 | |||
| 311 | func text(in range: UITextRange) -> String? { | ||
| 312 | guard let handle, let text = sb_text(handle, UInt32(range.lower), UInt32(range.upper)) else { return nil } | ||
| 313 | defer { sb_string_free(text) } | ||
| 314 | return String(cString: text) | ||
| 315 | } | ||
| 316 | |||
| 317 | func replace(_ range: UITextRange, withText text: String) { | ||
| 318 | guard let handle else { return } | ||
| 319 | edit { sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text) } | ||
| 320 | } | ||
| 321 | |||
| 322 | var selectedTextRange: UITextRange? { | ||
| 323 | get { | ||
| 324 | guard let handle else { return nil } | ||
| 325 | var range: [UInt32] = [0, 0] | ||
| 326 | sb_selection(handle, &range) | ||
| 327 | return Range(Int(range[0]), Int(range[1])) | ||
| 328 | } | ||
| 329 | set { | ||
| 330 | guard let handle, let newValue else { return } | ||
| 331 | _ = sb_select(handle, UInt32(newValue.lower), UInt32(newValue.upper)) | ||
| 332 | dirty = true | ||
| 333 | display.setNeedsSelectionUpdate() | ||
| 334 | } | ||
| 335 | } | ||
| 336 | |||
| 337 | var markedTextRange: UITextRange? { | ||
| 338 | guard let handle else { return nil } | ||
| 339 | var range: [UInt32] = [0, 0] | ||
| 340 | return sb_marked(handle, &range) ? Range(Int(range[0]), Int(range[1])) : nil | ||
| 341 | } | ||
| 342 | |||
| 343 | var markedTextStyle: [NSAttributedString.Key: Any]? | ||
| 344 | |||
| 345 | func setMarkedText(_ markedText: String?, selectedRange: NSRange) { | ||
| 346 | guard let handle else { return } | ||
| 347 | edit { | ||
| 348 | sb_set_marked( | ||
| 349 | handle, markedText ?? "", UInt32(selectedRange.location), | ||
| 350 | UInt32(selectedRange.location + selectedRange.length)) | ||
| 351 | } | ||
| 352 | } | ||
| 353 | |||
| 354 | func unmarkText() { | ||
| 355 | guard let handle else { return } | ||
| 356 | sb_unmark(handle) | ||
| 357 | dirty = true | ||
| 358 | } | ||
| 359 | |||
| 360 | var beginningOfDocument: UITextPosition { Position(0) } | ||
| 361 | var endOfDocument: UITextPosition { Position(handle.map { Int(sb_text_length($0)) } ?? 0) } | ||
| 362 | |||
| 363 | func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { | ||
| 364 | Range(fromPosition.offset, toPosition.offset) | ||
| 365 | } | ||
| 366 | |||
| 367 | func position(from position: UITextPosition, offset: Int) -> UITextPosition? { | ||
| 368 | let target = position.offset + offset | ||
| 369 | guard target >= 0, target <= endOfDocument.offset else { return nil } | ||
| 370 | return Position(target) | ||
| 371 | } | ||
| 372 | |||
| 373 | func position( | ||
| 374 | from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int | ||
| 375 | ) -> UITextPosition? { | ||
| 376 | switch direction { | ||
| 377 | case .left: return self.position(from: position, offset: -offset) | ||
| 378 | case .right: return self.position(from: position, offset: offset) | ||
| 379 | default: | ||
| 380 | let caret = caretRect(for: position) | ||
| 381 | let step = (direction == .up ? -1 : 1) * CGFloat(offset) * caret.height | ||
| 382 | return closestPosition(to: CGPoint(x: caret.midX, y: caret.midY + step)) | ||
| 383 | } | ||
| 384 | } | ||
| 385 | |||
| 386 | func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { | ||
| 387 | position.offset < other.offset | ||
| 388 | ? .orderedAscending : position.offset > other.offset ? .orderedDescending : .orderedSame | ||
| 389 | } | ||
| 390 | |||
| 391 | func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { toPosition.offset - from.offset } | ||
| 392 | |||
| 393 | func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { | ||
| 394 | direction == .left || direction == .up ? range.start : range.end | ||
| 395 | } | ||
| 396 | |||
| 397 | func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) | ||
| 398 | -> UITextRange? | ||
| 399 | { | ||
| 400 | direction == .left || direction == .up | ||
| 401 | ? Range(0, position.offset) : Range(position.offset, endOfDocument.offset) | ||
| 402 | } | ||
| 403 | |||
| 404 | func baseWritingDirection( | ||
| 405 | for position: UITextPosition, in direction: UITextStorageDirection | ||
| 406 | ) -> NSWritingDirection { .leftToRight } | ||
| 407 | |||
| 408 | func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {} | ||
| 409 | |||
| 410 | func firstRect(for range: UITextRange) -> CGRect { | ||
| 411 | selectionRects(for: range).first?.rect ?? caretRect(for: range.start) | ||
| 412 | } | ||
| 413 | |||
| 414 | func caretRect(for position: UITextPosition) -> CGRect { | ||
| 415 | guard let handle else { return .zero } | ||
| 416 | var rect: [Float] = [0, 0, 0, 0] | ||
| 417 | guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero } | ||
| 418 | var caret = bounded(rect) | ||
| 419 | caret.size.width = 2 | ||
| 420 | return caret | ||
| 421 | } | ||
| 422 | |||
| 423 | func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { | ||
| 424 | guard let handle else { return [] } | ||
| 425 | var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: 64) | ||
| 426 | let count = min(64, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, 64)) | ||
| 427 | return (0..<count).map { | ||
| 428 | let rect = rects[$0] | ||
| 429 | return SelectionRect( | ||
| 430 | bounded([rect.0, rect.1, rect.2, rect.3]), first: $0 == 0, last: $0 == count - 1) | ||
| 431 | } | ||
| 432 | } | ||
| 433 | |||
| 434 | func closestPosition(to point: CGPoint) -> UITextPosition? { | ||
| 435 | guard let handle else { return nil } | ||
| 436 | let local = visible(point) | ||
| 437 | return Position(Int(sb_closest(handle, Float(local.x), Float(local.y)))) | ||
| 438 | } | ||
| 439 | |||
| 440 | func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { | ||
| 441 | let offset = closestPosition(to: point)?.offset ?? range.lower | ||
| 442 | return Position(min(max(offset, range.lower), range.upper)) | ||
| 443 | } | ||
| 444 | |||
| 445 | func characterRange(at point: CGPoint) -> UITextRange? { | ||
| 446 | guard let position = closestPosition(to: point) else { return nil } | ||
| 447 | return Range(position.offset, min(position.offset + 1, endOfDocument.offset)) | ||
| 448 | } | ||
| 449 | } | ||
apps/ios/Snowbound/Script.swift created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | #if DEBUG | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | extension CanvasView { | ||
| 5 | /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check | ||
| 6 | /// input without a finger: steps joined by `|`, such as | ||
| 7 | /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`. | ||
| 8 | /// `tap` takes points from the view's corner, `select` text offsets, `scroll` a content | ||
| 9 | /// offset; `shot:a` saves the window to Documents/a.png, as a device has no screenshot command. | ||
| 10 | func runScript() { | ||
| 11 | guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } | ||
| 12 | for (index, step) in script.split(separator: "|").enumerated() { | ||
| 13 | DispatchQueue.main.asyncAfter(deadline: .now() + 1 + Double(index) * 0.4) { [weak self] in | ||
| 14 | self?.perform(String(step)) | ||
| 15 | } | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | private func perform(_ step: String) { | ||
| 20 | let (command, argument) = step.firstIndex(of: ":").map { | ||
| 21 | (String(step[..<$0]), String(step[step.index(after: $0)...])) | ||
| 22 | } ?? (step, "") | ||
| 23 | NSLog("script \(command) \(argument)") | ||
| 24 | switch command { | ||
| 25 | case "tap": | ||
| 26 | let values = argument.split(separator: ",").compactMap { Double($0) } | ||
| 27 | tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) | ||
| 28 | case "type": insertText(argument) | ||
| 29 | case "return": insertText("\n") | ||
| 30 | case "delete": deleteBackward() | ||
| 31 | case "mark": setMarkedText(argument, selectedRange: NSRange(location: argument.utf16.count, length: 0)) | ||
| 32 | case "unmark": unmarkText() | ||
| 33 | case "select": | ||
| 34 | let values = argument.split(separator: ",").compactMap { Int($0) } | ||
| 35 | selectedTextRange = textRange( | ||
| 36 | from: position(from: beginningOfDocument, offset: values[0])!, | ||
| 37 | to: position(from: beginningOfDocument, offset: values[1])!) | ||
| 38 | case "scroll": | ||
| 39 | let values = argument.split(separator: ",").compactMap { Double($0) } | ||
| 40 | setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true) | ||
| 41 | case "zoom": setZoomScale(Double(argument) ?? 1, animated: false) | ||
| 42 | case "shot": | ||
| 43 | guard let window else { break } | ||
| 44 | let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in | ||
| 45 | window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) | ||
| 46 | } | ||
| 47 | let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] | ||
| 48 | try? image.pngData()?.write(to: documents.appendingPathComponent("\(argument).png")) | ||
| 49 | default: NSLog("script: unknown step \(step)") | ||
| 50 | } | ||
| 51 | if let range = selectedTextRange, let all = textRange(from: beginningOfDocument, to: endOfDocument) { | ||
| 52 | NSLog("script selection \(offset(from: beginningOfDocument, to: range.start))..\(offset(from: beginningOfDocument, to: range.end)) text \(text(in: all) ?? "nil")") | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | #endif | ||
apps/ios/build-rust.sh created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | #!/bin/sh | ||
| 2 | # Builds crates/mobile for the platform Xcode is building; Xcode links the static library. | ||
| 3 | set -eu | ||
| 4 | case "$PLATFORM_NAME" in | ||
| 5 | iphonesimulator) target=aarch64-apple-ios-sim ;; | ||
| 6 | *) target=aarch64-apple-ios ;; | ||
| 7 | esac | ||
| 8 | release= | ||
| 9 | [ "$CONFIGURATION" = Release ] && release=--release | ||
| 10 | root="$SRCROOT/../.." | ||
| 11 | # Xcode's SDK variables would point host build scripts at the iOS SDK. | ||
| 12 | exec env -i HOME="$HOME" PATH="$HOME/.cargo/bin:/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:/usr/bin:/bin:/usr/sbin:/sbin" USER="$USER" \ | ||
| 13 | 	CARGO_TARGET_DIR="$root/target/ios" \ | ||
| 14 | 	cargo build --manifest-path "$root/Cargo.toml" -p mobile --target "$target" $release | ||
crates/canvas/examples/render_page.rs+1-3| ... | @@ -39,9 +39,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -39,9 +39,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 39 | let margin_origin = page.margin_origin; | 39 | let margin_origin = page.margin_origin; |
| 40 | let object_count = page.objects.len(); | 40 | let object_count = page.objects.len(); |
| 41 | let mut scene = PageScene::new(page, &mut engine)?; | 41 | let mut scene = PageScene::new(page, &mut engine)?; |
| 42 | while !scene.update_backgrounds(Paper::WHITE, 96.0 / 72.0, std::task::Waker::noop()) { | 42 | scene.settle(None, 96.0 / 72.0, Paper::WHITE); |
| 43 | std::thread::sleep(Duration::from_millis(1)); | ||
| 44 | } | ||
| 45 | let mut primitives = Vec::new(); | 43 | let mut primitives = Vec::new(); |
| 46 | scene.append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)?; | 44 | scene.append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)?; |
| 47 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | 45 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); |
crates/canvas/src/document.rs+91-6| ... | @@ -234,6 +234,39 @@ fn validate_text(nodes: &[PageParagraph]) -> Result<(), EditError> { | ... | @@ -234,6 +234,39 @@ fn validate_text(nodes: &[PageParagraph]) -> Result<(), EditError> { |
| 234 | Ok(()) | 234 | Ok(()) |
| 235 | } | 235 | } |
| 236 | 236 | ||
| 237 | /// Whether UTF-16 `offset` lies inside a hyperlink field, which OneNote has not been seen | ||
| 238 | /// splitting between paragraphs. | ||
| 239 | fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> { | ||
| 240 | let at = text.byte_offset(offset)?; | ||
| 241 | let link = |byte: usize| { | ||
| 242 | text.spans() | ||
| 243 | .iter() | ||
| 244 | .find(|span| byte < span.end) | ||
| 245 | .is_some_and(|span| span.format.hyperlink == Some(true)) | ||
| 246 | }; | ||
| 247 | Ok(at > 0 && link(at - 1) && link(at) && !text.text()[at..].starts_with('\u{fddf}')) | ||
| 248 | } | ||
| 249 | |||
| 250 | /// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets | ||
| 251 | /// an equation or embedded object at the seam or carries one to another paragraph: their run | ||
| 252 | /// data belongs to the whole paragraph, and OneNote has not been seen dividing them. | ||
| 253 | fn moves_object(first: &Paragraph, start: u32, last: &Paragraph, end: u32) -> Result<bool, EditError> { | ||
| 254 | let object = |text: &Paragraph, span: usize| { | ||
| 255 | let format = &text.spans()[span].format; | ||
| 256 | [format.math, format.embedded_object].contains(&Some(true)) | ||
| 257 | }; | ||
| 258 | let (before, after) = (first.byte_offset(start)?, last.byte_offset(end)?); | ||
| 259 | Ok(first.text()[..before].ends_with('\u{fffc}') | ||
| 260 | || last.text()[after..].contains('\u{fffc}') | ||
| 261 | || before > 0 | ||
| 262 | && first | ||
| 263 | .spans() | ||
| 264 | .iter() | ||
| 265 | .position(|span| before - 1 < span.end) | ||
| 266 | .is_some_and(|span| object(first, span)) | ||
| 267 | || (0..last.spans().len()).any(|span| last.spans()[span].end > after && object(last, span))) | ||
| 268 | } | ||
| 269 | |||
| 237 | /// Paragraph positions of each node's first text leaf, counting from `first`. | 270 | /// Paragraph positions of each node's first text leaf, counting from `first`. |
| 238 | fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize> + '_ { | 271 | fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize> + '_ { |
| 239 | nodes.iter().scan(first, |next, node| { | 272 | nodes.iter().scan(first, |next, node| { |
| ... | @@ -610,8 +643,18 @@ impl TextDocument { | ... | @@ -610,8 +643,18 @@ impl TextDocument { |
| 610 | return Err(EditError::UnsupportedContent); | 643 | return Err(EditError::UnsupportedContent); |
| 611 | } | 644 | } |
| 612 | let nodes = self.container(container)?; | 645 | let nodes = self.container(container)?; |
| 613 | let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?; | 646 | let first_text = &first.text().unwrap().text; |
| 614 | let last_text = &last.text().unwrap().text; | 647 | let last_text = &last.text().unwrap().text; |
| 648 | let split = replacement.len() > 1; | ||
| 649 | if (split || start != end) | ||
| 650 | && (moves_object(first_text, range.start.offset, last_text, range.end.offset)? | ||
| 651 | || split | ||
| 652 | && (divides_link(first_text, range.start.offset)? | ||
| 653 | || divides_link(last_text, range.end.offset)?)) | ||
| 654 | { | ||
| 655 | return Err(EditError::UnsupportedContent); | ||
| 656 | } | ||
| 657 | let mut prefix = first_text.slice(0..range.start.offset)?; | ||
| 615 | let suffix = | 658 | let suffix = |
| 616 | last_text.slice(range.end.offset..last_text.utf16_offset(last_text.text().len())?)?; | 659 | last_text.slice(range.end.offset..last_text.utf16_offset(last_text.text().len())?)?; |
| 617 | let mut replacement = replacement.into_iter(); | 660 | let mut replacement = replacement.into_iter(); |
| ... | @@ -666,28 +709,70 @@ impl TextDocument { | ... | @@ -666,28 +709,70 @@ impl TextDocument { |
| 666 | 709 | ||
| 667 | /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties | 710 | /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties |
| 668 | /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree | 711 | /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree |
| 669 | /// lies between them in one container. | 712 | /// lies between them in one container. The lower text keeps its look under `base`, the |
| 713 | /// upper paragraph's style: as OneNote stores it, a flag it leaves unset becomes false and | ||
| 714 | /// an unset colour automatic where the style sets them. An emptied upper paragraph takes | ||
| 715 | /// the lower text whole. | ||
| 670 | pub(crate) fn join( | 716 | pub(crate) fn join( |
| 671 | &self, | 717 | &self, |
| 672 | upper: usize, | 718 | upper: usize, |
| 673 | lower: usize, | 719 | lower: usize, |
| 720 | base: &Format, | ||
| 674 | ) -> Result<Option<DocumentEdit>, EditError> { | 721 | ) -> Result<Option<DocumentEdit>, EditError> { |
| 675 | let (container, first, top) = self.leaf(upper).ok_or(EditError::InvalidRange)?; | 722 | let (container, first, top) = self.leaf(upper).ok_or(EditError::InvalidRange)?; |
| 676 | let (end_container, last, bottom) = self.leaf(lower).ok_or(EditError::InvalidRange)?; | 723 | let (end_container, last, bottom) = self.leaf(lower).ok_or(EditError::InvalidRange)?; |
| 677 | let nodes = self.container(container)?; | 724 | let nodes = self.container(container)?; |
| 725 | let (above, below) = (&top.text().unwrap().text, &bottom.text().unwrap().text); | ||
| 678 | if container != end_container | 726 | if container != end_container |
| 679 | || last <= first | 727 | || last <= first |
| 680 | || last > first + 1 && !(top.collapsed && subtree_end(nodes, first) == last) | 728 | || last > first + 1 && !(top.collapsed && subtree_end(nodes, first) == last) |
| 729 | || moves_object(above, above.utf16_offset(above.text().len())?, below, 0)? | ||
| 681 | { | 730 | { |
| 682 | return Ok(None); | 731 | return Ok(None); |
| 683 | } | 732 | } |
| 684 | let mut head = top.clone(); | 733 | let mut head = top.clone(); |
| 685 | let text = head.text_mut().unwrap(); | 734 | let text = head.text_mut().unwrap(); |
| 686 | // OneNote moves the lower text object into an emptied upper paragraph. | 735 | if above.text().is_empty() { |
| 687 | if text.text.text().is_empty() { | 736 | // OneNote moves the lower text object, with its style and any recording link, into |
| 688 | text.id = bottom.text().unwrap().id; | 737 | // an emptied upper paragraph, which keeps its own note tags. |
| 738 | let tags = std::mem::take(&mut text.tags); | ||
| 739 | *text = bottom.text().unwrap().clone(); | ||
| 740 | text.tags = tags; | ||
| 741 | head.style = bottom.style; | ||
| 742 | head.media.clone_from(&bottom.media); | ||
| 743 | } else { | ||
| 744 | let automatic = |color: Option<u32>| color.map(|_| 0xff000000); | ||
| 745 | let reset = Format { | ||
| 746 | bold: base.bold.map(|_| false), | ||
| 747 | italic: base.italic.map(|_| false), | ||
| 748 | underline: base.underline.map(|_| false), | ||
| 749 | strike: base.strike.map(|_| false), | ||
| 750 | superscript: base.superscript.map(|_| false), | ||
| 751 | subscript: base.subscript.map(|_| false), | ||
| 752 | hidden: base.hidden.map(|_| false), | ||
| 753 | hyperlink: base.hyperlink.map(|_| false), | ||
| 754 | math: base.math.map(|_| false), | ||
| 755 | color: automatic(base.color), | ||
| 756 | highlight: automatic(base.highlight), | ||
| 757 | ..Format::default() | ||
| 758 | }; | ||
| 759 | // The joined text lies in the upper paragraph, whose spacing and alignment it takes. | ||
| 760 | let paragraph = &above.spans()[0].format; | ||
| 761 | let mut start = 0; | ||
| 762 | text.text.append(Paragraph::from_runs(below.spans().iter().map(|span| { | ||
| 763 | let run = below.text()[start..span.end].to_owned(); | ||
| 764 | start = span.end; | ||
| 765 | let format = Format { | ||
| 766 | alignment: paragraph.alignment, | ||
| 767 | space_before: paragraph.space_before, | ||
| 768 | space_after: paragraph.space_after, | ||
| 769 | line_spacing: paragraph.line_spacing, | ||
| 770 | list_spacing: paragraph.list_spacing, | ||
| 771 | ..span.format.inherit(&reset) | ||
| 772 | }; | ||
| 773 | (run, format) | ||
| 774 | })))?; | ||
| 689 | } | 775 | } |
| 690 | text.text.append(bottom.text().unwrap().text.clone())?; | ||
| 691 | let moves = BTreeMap::from([(bottom.id, &head)]); | 776 | let moves = BTreeMap::from([(bottom.id, &head)]); |
| 692 | let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?; | 777 | let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?; |
| 693 | Ok(Some(DocumentEdit { | 778 | Ok(Some(DocumentEdit { |
crates/canvas/src/editor.rs+104-28| ... | @@ -8,7 +8,9 @@ use draw::edit::{self, Movement, SelectionUnit}; | ... | @@ -8,7 +8,9 @@ use draw::edit::{self, Movement, SelectionUnit}; |
| 8 | use onestore::ExGuid; | 8 | use onestore::ExGuid; |
| 9 | use onestore::document::{Format, Kind}; | 9 | use onestore::document::{Format, Kind}; |
| 10 | use onestore::page::text::{EditError, Paragraph}; | 10 | use onestore::page::text::{EditError, Paragraph}; |
| 11 | use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title}; | 11 | use onestore::page::{ |
| 12 | Definition, Outline, Page, PageObject, PageParagraph, ParagraphContent, Title, | ||
| 13 | }; | ||
| 12 | use parley::{ | 14 | use parley::{ |
| 13 | Affinity, BoundingBox, | 15 | Affinity, BoundingBox, |
| 14 | editing::{Cursor, Selection as ParagraphSelection}, | 16 | editing::{Cursor, Selection as ParagraphSelection}, |
| ... | @@ -766,6 +768,35 @@ impl CanvasEditor { | ... | @@ -766,6 +768,35 @@ impl CanvasEditor { |
| 766 | .filter(|outline| !self.has_page_outline(outline.id)) | 768 | .filter(|outline| !self.has_page_outline(outline.id)) |
| 767 | .map(|outline| PageObject::Outline(outline.snapshot())), | 769 | .map(|outline| PageObject::Outline(outline.snapshot())), |
| 768 | ); | 770 | ); |
| 771 | // Definitions stay for undo after their last paragraph lets go; the page, as | ||
| 772 | // OneNote stores it, holds only those its paragraphs reference. | ||
| 773 | let mut referenced = BTreeSet::new(); | ||
| 774 | for object in &objects { | ||
| 775 | let outlines = match object { | ||
| 776 | PageObject::Outline(outline) => std::slice::from_ref(outline), | ||
| 777 | PageObject::Title(title) => title.outlines.as_slice(), | ||
| 778 | _ => &[], | ||
| 779 | }; | ||
| 780 | for (_, _, node) in outlines | ||
| 781 | .iter() | ||
| 782 | .flat_map(|outline| descendants(&outline.paragraphs, None)) | ||
| 783 | { | ||
| 784 | let content_tags = match &node.content { | ||
| 785 | ParagraphContent::Text(text) => text.tags.as_slice(), | ||
| 786 | ParagraphContent::Table(table) => table.tags.as_slice(), | ||
| 787 | _ => &[], | ||
| 788 | }; | ||
| 789 | referenced.extend(node.lists.iter().copied().chain(node.style)); | ||
| 790 | referenced.extend( | ||
| 791 | node.tags | ||
| 792 | .iter() | ||
| 793 | .chain(content_tags) | ||
| 794 | .filter_map(|tag| tag.definition), | ||
| 795 | ); | ||
| 796 | } | ||
| 797 | } | ||
| 798 | let mut definitions = self.definitions.clone(); | ||
| 799 | definitions.retain(|id, _| referenced.contains(id)); | ||
| 769 | Ok(Page { | 800 | Ok(Page { |
| 770 | title: self.header.title.clone(), | 801 | title: self.header.title.clone(), |
| 771 | identity: self.header.identity, | 802 | identity: self.header.identity, |
| ... | @@ -776,7 +807,7 @@ impl CanvasEditor { | ... | @@ -776,7 +807,7 @@ impl CanvasEditor { |
| 776 | .or(self.header.created), | 807 | .or(self.header.created), |
| 777 | margin_origin: self.header.margin_origin, | 808 | margin_origin: self.header.margin_origin, |
| 778 | objects, | 809 | objects, |
| 779 | definitions: self.definitions.clone(), | 810 | definitions, |
| 780 | }) | 811 | }) |
| 781 | } | 812 | } |
| 782 | 813 | ||
| ... | @@ -1099,10 +1130,14 @@ impl CanvasEditor { | ... | @@ -1099,10 +1130,14 @@ impl CanvasEditor { |
| 1099 | }) | 1130 | }) |
| 1100 | } | 1131 | } |
| 1101 | 1132 | ||
| 1133 | /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do. | ||
| 1102 | fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> { | 1134 | fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> { |
| 1103 | let format = self.style_format(base.style)?; | 1135 | let mut format = self.style_format(base.style)?; |
| 1136 | format.language = format.language.or(base | ||
| 1137 | .text() | ||
| 1138 | .and_then(|text| text.text.spans()[0].format.language)); | ||
| 1104 | let mut node = | 1139 | let mut node = |
| 1105 | crate::document::node(Paragraph::new(String::new(), format.clone()), format)?; | 1140 | crate::document::node(Paragraph::new(String::new(), format), base.format.clone())?; |
| 1106 | node.level = base.level; | 1141 | node.level = base.level; |
| 1107 | node.style = base.style; | 1142 | node.style = base.style; |
| 1108 | Ok(node) | 1143 | Ok(node) |
| ... | @@ -1449,8 +1484,13 @@ impl CanvasEditor { | ... | @@ -1449,8 +1484,13 @@ impl CanvasEditor { |
| 1449 | .iter() | 1484 | .iter() |
| 1450 | .position(|object| matches!(object, page::Content::Image(image) if image.id == id)) | 1485 | .position(|object| matches!(object, page::Content::Image(image) if image.id == id)) |
| 1451 | .ok_or(EditError::InvalidRange)?; | 1486 | .ok_or(EditError::InvalidRange)?; |
| 1487 | // An emptied outline keeps its slot for undo after leaving `outlines`. | ||
| 1452 | let editable = |object: &page::Content| match object { | 1488 | let editable = |object: &page::Content| match object { |
| 1453 | page::Content::Editable(outline) => Some(*outline), | 1489 | page::Content::Editable(outline) |
| 1490 | if self.outlines.iter().any(|item| item.id == *outline) => | ||
| 1491 | { | ||
| 1492 | Some(*outline) | ||
| 1493 | } | ||
| 1454 | _ => None, | 1494 | _ => None, |
| 1455 | }; | 1495 | }; |
| 1456 | let outline = if forward { | 1496 | let outline = if forward { |
| ... | @@ -2226,21 +2266,31 @@ impl CanvasEditor { | ... | @@ -2226,21 +2266,31 @@ impl CanvasEditor { |
| 2226 | 2266 | ||
| 2227 | /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style | 2267 | /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style |
| 2228 | /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`). | 2268 | /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`). |
| 2229 | pub fn paste(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { | 2269 | /// Pasted runs take the clipboard's `language`, an LCID, not the caret's run's as typing |
| 2270 | /// does; Windows derives it from the keyboard language at copy time. | ||
| 2271 | pub fn paste( | ||
| 2272 | &mut self, | ||
| 2273 | engine: &mut TextEngine, | ||
| 2274 | text: &str, | ||
| 2275 | language: u32, | ||
| 2276 | ) -> Result<(), EditorError> { | ||
| 2230 | let lines = text | 2277 | let lines = text |
| 2231 | .split('\n') | 2278 | .split('\n') |
| 2232 | .map(|line| line.strip_suffix('\r').unwrap_or(line)) | 2279 | .map(|line| line.strip_suffix('\r').unwrap_or(line)) |
| 2233 | .collect::<Vec<_>>(); | 2280 | .collect::<Vec<_>>(); |
| 2234 | let last = lines[lines.len() - 1]; | 2281 | let last = lines[lines.len() - 1]; |
| 2235 | if lines.len() == 1 { | ||
| 2236 | return self.insert(engine, last); | ||
| 2237 | } | ||
| 2238 | let [anchor, focus] = self.active_outline().selection.positions; | 2282 | let [anchor, focus] = self.active_outline().selection.positions; |
| 2239 | let (start, end) = (anchor.min(focus), anchor.max(focus)); | 2283 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| 2284 | if lines.len() == 1 { | ||
| 2285 | let mut format = self.typing_format(start)?; | ||
| 2286 | format.language = Some(language); | ||
| 2287 | return self.replace(engine, vec![Paragraph::new(last.to_owned(), format)]); | ||
| 2288 | } | ||
| 2240 | let edge = Paragraph::new(String::new(), self.typing_format(start)?); | 2289 | let edge = Paragraph::new(String::new(), self.typing_format(start)?); |
| 2241 | let pasted = Format { | 2290 | let pasted = Format { |
| 2242 | font: Some("Calibri".into()), | 2291 | font: Some("Calibri".into()), |
| 2243 | font_size: Some(11.0), | 2292 | font_size: Some(11.0), |
| 2293 | language: Some(language), | ||
| 2244 | ..Format::default() | 2294 | ..Format::default() |
| 2245 | }; | 2295 | }; |
| 2246 | let mut edit = self.active_outline().document.replace( | 2296 | let mut edit = self.active_outline().document.replace( |
| ... | @@ -2283,7 +2333,7 @@ impl CanvasEditor { | ... | @@ -2283,7 +2333,7 @@ impl CanvasEditor { |
| 2283 | let format = self.typing_format(start)?; | 2333 | let format = self.typing_format(start)?; |
| 2284 | let document = &self.active_outline().document; | 2334 | let document = &self.active_outline().document; |
| 2285 | let mut edit = | 2335 | let mut edit = |
| 2286 | document.replace(start..end, vec![Paragraph::new(String::new(), format); 2])?; | 2336 | document.replace(start..end, vec![Paragraph::new(String::new(), format.clone()); 2])?; |
| 2287 | let nodes = document.container(edit.container)?; | 2337 | let nodes = document.container(edit.container)?; |
| 2288 | let next = nodes | 2338 | let next = nodes |
| 2289 | .get(crate::document::subtree_end(nodes, edit.range.start)) | 2339 | .get(crate::document::subtree_end(nodes, edit.range.start)) |
| ... | @@ -2319,8 +2369,12 @@ impl CanvasEditor { | ... | @@ -2319,8 +2369,12 @@ impl CanvasEditor { |
| 2319 | .keys() | 2369 | .keys() |
| 2320 | .copied() | 2370 | .copied() |
| 2321 | .find(|id| style(Some(*id)) == Some("p")); | 2371 | .find(|id| style(Some(*id)) == Some("p")); |
| 2322 | tail.text_mut().unwrap().text = | 2372 | // The heading's run formatting carries over under the body style (`c9-h1-*`). |
| 2323 | Paragraph::new(String::new(), self.style_format(tail.style)?); | 2373 | let run = format.over(&self.style_format(head.style)?); |
| 2374 | tail.text_mut().unwrap().text = Paragraph::new( | ||
| 2375 | String::new(), | ||
| 2376 | run.inherit(&self.style_format(tail.style)?), | ||
| 2377 | ); | ||
| 2324 | } | 2378 | } |
| 2325 | let caret = TextPosition { | 2379 | let caret = TextPosition { |
| 2326 | paragraph: start.paragraph + 1, | 2380 | paragraph: start.paragraph + 1, |
| ... | @@ -2438,7 +2492,12 @@ impl CanvasEditor { | ... | @@ -2438,7 +2492,12 @@ impl CanvasEditor { |
| 2438 | } else { | 2492 | } else { |
| 2439 | [focus.paragraph, neighbor] | 2493 | [focus.paragraph, neighbor] |
| 2440 | }; | 2494 | }; |
| 2441 | let Some(edit) = outline.document.join(upper, lower)? else { | 2495 | let (_, _, top) = outline |
| 2496 | .document | ||
| 2497 | .leaf(upper) | ||
| 2498 | .ok_or(EditError::InvalidRange)?; | ||
| 2499 | let base = self.style_format(top.style)?; | ||
| 2500 | let Some(edit) = outline.document.join(upper, lower, &base)? else { | ||
| 2442 | return Ok(false); | 2501 | return Ok(false); |
| 2443 | }; | 2502 | }; |
| 2444 | let text = outline.document.paragraph(upper).unwrap(); | 2503 | let text = outline.document.paragraph(upper).unwrap(); |
| ... | @@ -2982,23 +3041,33 @@ impl CanvasEditor { | ... | @@ -2982,23 +3041,33 @@ impl CanvasEditor { |
| 2982 | /// Gives each paragraph an edit adds its own copy of the lists it carries, as OneNote keeps | 3041 | /// Gives each paragraph an edit adds its own copy of the lists it carries, as OneNote keeps |
| 2983 | /// a list node per paragraph. | 3042 | /// a list node per paragraph. |
| 2984 | fn own_lists(&mut self, edit: &mut DocumentEdit) -> Result<(), EditError> { | 3043 | fn own_lists(&mut self, edit: &mut DocumentEdit) -> Result<(), EditError> { |
| 2985 | let existing = self.active_outline().document.container(edit.container)? | 3044 | let existing = descendants( |
| 2986 | [edit.range.clone()] | 3045 | &self.active_outline().document.container(edit.container)?[edit.range.clone()], |
| 2987 | .iter() | 3046 | None, |
| 2988 | .map(|node| node.id) | 3047 | ) |
| 3048 | .map(|(_, _, node)| node.id) | ||
| 2989 | .collect::<BTreeSet<_>>(); | 3049 | .collect::<BTreeSet<_>>(); |
| 2990 | for node in &mut edit.replacement { | 3050 | let mut pending = edit.replacement.iter_mut().collect::<Vec<_>>(); |
| 2991 | if existing.contains(&node.id) { | 3051 | while let Some(node) = pending.pop() { |
| 2992 | continue; | 3052 | if !existing.contains(&node.id) { |
| 3053 | for list in &mut node.lists { | ||
| 3054 | let definition = self | ||
| 3055 | .definitions | ||
| 3056 | .get(list) | ||
| 3057 | .ok_or(EditError::InvalidStructure)? | ||
| 3058 | .clone(); | ||
| 3059 | *list = onestore::page::text::new_id()?; | ||
| 3060 | self.definitions.insert(*list, definition); | ||
| 3061 | } | ||
| 2993 | } | 3062 | } |
| 2994 | for list in &mut node.lists { | 3063 | if let ParagraphContent::Table(table) = &mut node.content { |
| 2995 | let definition = self | 3064 | pending.extend( |
| 2996 | .definitions | 3065 | table |
| 2997 | .get(list) | 3066 | .rows |
| 2998 | .ok_or(EditError::InvalidStructure)? | 3067 | .iter_mut() |
| 2999 | .clone(); | 3068 | .flat_map(|row| &mut row.cells) |
| 3000 | *list = onestore::page::text::new_id()?; | 3069 | .flat_map(|cell| &mut cell.paragraphs), |
| 3001 | self.definitions.insert(*list, definition); | 3070 | ); |
| 3002 | } | 3071 | } |
| 3003 | } | 3072 | } |
| 3004 | Ok(()) | 3073 | Ok(()) |
| ... | @@ -4778,6 +4847,13 @@ mod tests { | ... | @@ -4778,6 +4847,13 @@ mod tests { |
| 4778 | assert!(editor.step_from_image(&mut engine, id, true).unwrap()); | 4847 | assert!(editor.step_from_image(&mut engine, id, true).unwrap()); |
| 4779 | assert_eq!(editor.active_outline().id, after); | 4848 | assert_eq!(editor.active_outline().id, after); |
| 4780 | assert_eq!(editor.selection().positions, caret(0, 0)); | 4849 | assert_eq!(editor.selection().positions, caret(0, 0)); |
| 4850 | |||
| 4851 | editor.select_all().unwrap(); | ||
| 4852 | assert!(editor.delete(&mut engine, true).unwrap()); | ||
| 4853 | editor.focus_outline(before).unwrap(); | ||
| 4854 | assert!(editor.outlines().iter().all(|outline| outline.id != after)); | ||
| 4855 | assert!(!editor.step_from_image(&mut engine, id, true).unwrap()); | ||
| 4856 | assert_eq!(editor.active_outline().id, before); | ||
| 4781 | } | 4857 | } |
| 4782 | 4858 | ||
| 4783 | #[test] | 4859 | #[test] |
crates/canvas/src/editor/evidence.rs+59-4| ... | @@ -207,7 +207,7 @@ enum Key { | ... | @@ -207,7 +207,7 @@ enum Key { |
| 207 | Tab, | 207 | Tab, |
| 208 | ShiftTab, | 208 | ShiftTab, |
| 209 | Type(&'static str), | 209 | Type(&'static str), |
| 210 | Paste(&'static str), | 210 | Paste(&'static str, u32), |
| 211 | Bullets, | 211 | Bullets, |
| 212 | } | 212 | } |
| 213 | 213 | ||
| ... | @@ -238,7 +238,7 @@ fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) { | ... | @@ -238,7 +238,7 @@ fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) { |
| 238 | Key::Tab => editor.tab(engine, false).unwrap(), | 238 | Key::Tab => editor.tab(engine, false).unwrap(), |
| 239 | Key::ShiftTab => editor.tab(engine, true).unwrap(), | 239 | Key::ShiftTab => editor.tab(engine, true).unwrap(), |
| 240 | Key::Type(text) => editor.insert(engine, text).unwrap(), | 240 | Key::Type(text) => editor.insert(engine, text).unwrap(), |
| 241 | Key::Paste(text) => editor.paste(engine, text).unwrap(), | 241 | Key::Paste(text, language) => editor.paste(engine, text, language).unwrap(), |
| 242 | Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(), | 242 | Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(), |
| 243 | } | 243 | } |
| 244 | } | 244 | } |
| ... | @@ -565,11 +565,12 @@ fn pasted_lines_are_plain_paragraphs_between_the_halves() { | ... | @@ -565,11 +565,12 @@ fn pasted_lines_are_plain_paragraphs_between_the_halves() { |
| 565 | &mut engine, | 565 | &mut engine, |
| 566 | &target(kind), | 566 | &target(kind), |
| 567 | (1, Offset::Back(4)), | 567 | (1, Offset::Back(4)), |
| 568 | &[(Key::Paste(text), &format!("c7-{kind}-1"))], | 568 | &[(Key::Paste(text, 1033), &format!("c7-{kind}-1"))], |
| 569 | ); | 569 | ); |
| 570 | let document = &editor.active_outline().document; | 570 | let document = &editor.active_outline().document; |
| 571 | let format = Format { | 571 | let format = Format { |
| 572 | bold: None, | 572 | bold: None, |
| 573 | language: Some(1033), | ||
| 573 | ..calibri(11.0, false) | 574 | ..calibri(11.0, false) |
| 574 | }; | 575 | }; |
| 575 | assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format); | 576 | assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format); |
| ... | @@ -586,10 +587,64 @@ fn pasted_lines_are_plain_paragraphs_between_the_halves() { | ... | @@ -586,10 +587,64 @@ fn pasted_lines_are_plain_paragraphs_between_the_halves() { |
| 586 | &mut engine, | 587 | &mut engine, |
| 587 | " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second", | 588 | " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second", |
| 588 | (1, Offset::Back(1)), | 589 | (1, Offset::Back(1)), |
| 589 | &[(Key::Paste("Line one\r\nLine two"), "c7-num2-1")], | 590 | &[(Key::Paste("Line one\r\nLine two", 1033), "c7-num2-1")], |
| 590 | ); | 591 | ); |
| 591 | } | 592 | } |
| 592 | 593 | ||
| 594 | /// OneNote 2010 typing and pasting into a French run: typed text stays French, pasted text, | ||
| 595 | /// one line or several, takes the clipboard's language (en-US in the captures). | ||
| 596 | #[test] | ||
| 597 | fn pasted_text_takes_the_clipboard_language_and_typed_text_the_runs() { | ||
| 598 | let mut engine = TextEngine::default(); | ||
| 599 | let (fr, en, de) = (Some(1036), Some(1033), Some(1031)); | ||
| 600 | let french = Format { | ||
| 601 | language: fr, | ||
| 602 | ..calibri(11.0, false) | ||
| 603 | }; | ||
| 604 | for (key, languages) in [ | ||
| 605 | (Key::Type("Typed"), vec![fr]), | ||
| 606 | (Key::Paste("Pasted", 1033), vec![fr, en, fr]), | ||
| 607 | ( | ||
| 608 | Key::Paste("Line one\r\nLine two", 1033), | ||
| 609 | vec![fr, en, en, fr], | ||
| 610 | ), | ||
| 611 | (Key::Paste("Pasted", 1031), vec![fr, de, fr]), | ||
| 612 | ( | ||
| 613 | Key::Paste("Line one\r\nLine two", 1031), | ||
| 614 | vec![fr, de, de, fr], | ||
| 615 | ), | ||
| 616 | ] { | ||
| 617 | let mut editor = open(&mut engine, " L1 [] [] qs0 Target text"); | ||
| 618 | editor | ||
| 619 | .select( | ||
| 620 | [0, 11] | ||
| 621 | .map(|offset| TextPosition { | ||
| 622 | paragraph: 0, | ||
| 623 | offset, | ||
| 624 | }) | ||
| 625 | .into(), | ||
| 626 | ) | ||
| 627 | .unwrap(); | ||
| 628 | editor | ||
| 629 | .replace( | ||
| 630 | &mut engine, | ||
| 631 | vec![Paragraph::new("Target text".into(), french.clone())], | ||
| 632 | ) | ||
| 633 | .unwrap(); | ||
| 634 | place(&mut editor, (0, Offset::Back(4))); | ||
| 635 | press(&mut editor, &mut engine, key); | ||
| 636 | let document = &editor.active_outline().document; | ||
| 637 | let paragraphs = (0..).map_while(|paragraph| document.paragraph(paragraph)); | ||
| 638 | assert_eq!( | ||
| 639 | paragraphs | ||
| 640 | .flat_map(|paragraph| paragraph.spans()) | ||
| 641 | .map(|span| span.format.language) | ||
| 642 | .collect::<Vec<_>>(), | ||
| 643 | languages | ||
| 644 | ); | ||
| 645 | } | ||
| 646 | } | ||
| 647 | |||
| 593 | #[test] | 648 | #[test] |
| 594 | fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() { | 649 | fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() { |
| 595 | let mut engine = TextEngine::default(); | 650 | let mut engine = TextEngine::default(); |
crates/canvas/src/editor/format.rs+111-30| ... | @@ -560,36 +560,7 @@ impl CanvasEditor { | ... | @@ -560,36 +560,7 @@ impl CanvasEditor { |
| 560 | } | 560 | } |
| 561 | }); | 561 | }); |
| 562 | } | 562 | } |
| 563 | Formatting::Check => { | 563 | Formatting::Check => self.check(&mut replacement, ranges, ends), |
| 564 | let checkable = |tag: &Tag| { | ||
| 565 | matches!( | ||
| 566 | self.tag_kind(tag), | ||
| 567 | Some(Kind::TagDefinition { | ||
| 568 | shape: Some(CHECKBOX), | ||
| 569 | .. | ||
| 570 | }) | ||
| 571 | ) | ||
| 572 | }; | ||
| 573 | let checked = covered(&replacement, ends) | ||
| 574 | .flat_map(|(node, _)| tags(node)) | ||
| 575 | .filter(|tag| checkable(tag)) | ||
| 576 | .all(|tag| tag.status & 1 != 0); | ||
| 577 | let completed = if checked { Some(0) } else { time32() }; | ||
| 578 | leaves_mut(&mut replacement, &mut |node| { | ||
| 579 | if ranges.remove(&node.id).is_none() { | ||
| 580 | return; | ||
| 581 | } | ||
| 582 | let ParagraphContent::Text(text) = &mut node.content else { | ||
| 583 | unreachable!() | ||
| 584 | }; | ||
| 585 | for tag in node.tags.iter_mut().chain(&mut text.tags) { | ||
| 586 | if checkable(tag) && (tag.status & 1 != 0) == checked { | ||
| 587 | tag.status ^= 1; | ||
| 588 | tag.completed = completed; | ||
| 589 | } | ||
| 590 | } | ||
| 591 | }); | ||
| 592 | } | ||
| 593 | Formatting::Toggle(_) | 564 | Formatting::Toggle(_) |
| 594 | | Formatting::Font(_) | 565 | | Formatting::Font(_) |
| 595 | | Formatting::FontSize(_) | 566 | | Formatting::FontSize(_) |
| ... | @@ -668,6 +639,84 @@ impl CanvasEditor { | ... | @@ -668,6 +639,84 @@ impl CanvasEditor { |
| 668 | } | 639 | } |
| 669 | } | 640 | } |
| 670 | 641 | ||
| 642 | impl CanvasEditor { | ||
| 643 | /// A click on a check box tag: checks the check boxes of paragraph `id` in outline | ||
| 644 | /// `outline`, or clears them once all are checked, as one undo step that keeps the | ||
| 645 | /// selection. | ||
| 646 | pub fn click_check( | ||
| 647 | &mut self, | ||
| 648 | engine: &mut TextEngine, | ||
| 649 | outline: ExGuid, | ||
| 650 | id: ExGuid, | ||
| 651 | ) -> Result<(), EditorError> { | ||
| 652 | self.focus_outline(outline)?; | ||
| 653 | let outline = self.active_outline(); | ||
| 654 | let document = &outline.document; | ||
| 655 | let paragraph = leaves(document.nodes(), None) | ||
| 656 | .position(|(_, _, node)| node.id == id) | ||
| 657 | .ok_or(EditError::InvalidRange)?; | ||
| 658 | let at = TextPosition { | ||
| 659 | paragraph, | ||
| 660 | offset: 0, | ||
| 661 | }; | ||
| 662 | let (container, range, ends) = selected(document, [at; 2].into())?; | ||
| 663 | let mut replacement = document.container(container)?[range.clone()].to_vec(); | ||
| 664 | let ranges = covered(&replacement, ends) | ||
| 665 | .map(|(node, range)| (node.id, range)) | ||
| 666 | .collect(); | ||
| 667 | self.check(&mut replacement, ranges, ends); | ||
| 668 | let selection = outline.selection; | ||
| 669 | self.commit( | ||
| 670 | engine, | ||
| 671 | DocumentEdit { | ||
| 672 | columns: BTreeMap::new(), | ||
| 673 | container, | ||
| 674 | range, | ||
| 675 | replacement, | ||
| 676 | }, | ||
| 677 | selection, | ||
| 678 | ) | ||
| 679 | } | ||
| 680 | |||
| 681 | /// Checks the check boxes of the `covered` paragraphs, or clears them once all are | ||
| 682 | /// checked; OneNote keeps a cleared box's completion time as zero. | ||
| 683 | fn check( | ||
| 684 | &self, | ||
| 685 | replacement: &mut [PageParagraph], | ||
| 686 | mut covered: BTreeMap<ExGuid, Range<usize>>, | ||
| 687 | ends: Ends, | ||
| 688 | ) { | ||
| 689 | let checkable = |tag: &Tag| { | ||
| 690 | matches!( | ||
| 691 | self.tag_kind(tag), | ||
| 692 | Some(Kind::TagDefinition { | ||
| 693 | shape: Some(CHECKBOX), | ||
| 694 | .. | ||
| 695 | }) | ||
| 696 | ) | ||
| 697 | }; | ||
| 698 | let checked = self::covered(replacement, ends) | ||
| 699 | .flat_map(|(node, _)| tags(node)) | ||
| 700 | .filter(|tag| checkable(tag)) | ||
| 701 | .all(|tag| tag.status & 1 != 0); | ||
| 702 | let completed = if checked { Some(0) } else { time32() }; | ||
| 703 | leaves_mut(replacement, &mut |node| { | ||
| 704 | if covered.remove(&node.id).is_none() { | ||
| 705 | return; | ||
| 706 | } | ||
| 707 | let ParagraphContent::Text(text) = &mut node.content else { | ||
| 708 | unreachable!() | ||
| 709 | }; | ||
| 710 | for tag in node.tags.iter_mut().chain(&mut text.tags) { | ||
| 711 | if checkable(tag) && (tag.status & 1 != 0) == checked { | ||
| 712 | tag.status ^= 1; | ||
| 713 | tag.completed = completed; | ||
| 714 | } | ||
| 715 | } | ||
| 716 | }); | ||
| 717 | } | ||
| 718 | } | ||
| 719 | |||
| 671 | /// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote | 720 | /// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote |
| 672 | /// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`, | 721 | /// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`, |
| 673 | /// `c8-tab-1.xml`), then to ■ as `corpus/private` nests bullets, and to i.; outdenting stops at | 722 | /// `c8-tab-1.xml`), then to ■ as `corpus/private` nests bullets, and to i.; outdenting stops at |
| ... | @@ -1210,6 +1259,38 @@ mod tests { | ... | @@ -1210,6 +1259,38 @@ mod tests { |
| 1210 | .collect() | 1259 | .collect() |
| 1211 | } | 1260 | } |
| 1212 | 1261 | ||
| 1262 | /// OneNote 2010 checks a clicked To Do box (status 1, completion time now) and clears it | ||
| 1263 | /// (status 0, completion time kept as zero) without moving the caret. | ||
| 1264 | #[test] | ||
| 1265 | fn clicking_a_check_box_checks_and_clears_it_as_one_step() { | ||
| 1266 | let mut engine = TextEngine::default(); | ||
| 1267 | let mut editor = plain(&mut engine, &["task", "other"]); | ||
| 1268 | editor.select([at(0, 0), at(1, 5)].into()).unwrap(); | ||
| 1269 | editor | ||
| 1270 | .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) | ||
| 1271 | .unwrap(); | ||
| 1272 | editor.select([at(1, 2); 2].into()).unwrap(); | ||
| 1273 | let outline = editor.active_outline().id; | ||
| 1274 | let task = editor.active_outline().document.nodes()[0].id; | ||
| 1275 | let selection = editor.selection(); | ||
| 1276 | editor.click_check(&mut engine, outline, task).unwrap(); | ||
| 1277 | assert_eq!(editor.selection(), selection); | ||
| 1278 | assert_eq!(text_tags(&editor, 0), [("To Do".into(), 1, true)]); | ||
| 1279 | assert_eq!(text_tags(&editor, 1), [("To Do".into(), 0, true)]); | ||
| 1280 | let tag = |editor: &CanvasEditor| { | ||
| 1281 | editor.active_outline().document.nodes()[0] | ||
| 1282 | .text() | ||
| 1283 | .unwrap() | ||
| 1284 | .tags[0] | ||
| 1285 | .clone() | ||
| 1286 | }; | ||
| 1287 | assert!(tag(&editor).completed.is_some_and(|time| time > 0)); | ||
| 1288 | editor.click_check(&mut engine, outline, task).unwrap(); | ||
| 1289 | assert_eq!((tag(&editor).status, tag(&editor).completed), (0, Some(0))); | ||
| 1290 | editor.undo(&mut engine).unwrap(); | ||
| 1291 | assert_eq!(tag(&editor).status, 1); | ||
| 1292 | } | ||
| 1293 | |||
| 1213 | #[test] | 1294 | #[test] |
| 1214 | fn tags_toggle_check_and_replace_tags_of_their_action_type() { | 1295 | fn tags_toggle_check_and_replace_tags_of_their_action_type() { |
| 1215 | let mut engine = TextEngine::default(); | 1296 | let mut engine = TextEngine::default(); |
crates/canvas/src/editor/table.rs+9-2| ... | @@ -311,8 +311,15 @@ impl CanvasEditor { | ... | @@ -311,8 +311,15 @@ impl CanvasEditor { |
| 311 | }; | 311 | }; |
| 312 | let (replacement, next) = if exit { | 312 | let (replacement, next) = if exit { |
| 313 | table.rows.pop(); | 313 | table.rows.pop(); |
| 314 | let first = self.blank_paragraph(source)?; | 314 | let blank = || { |
| 315 | let second = self.blank_paragraph(source)?; | 315 | Ok::<_, EditError>(PageParagraph { |
| 316 | parent: location.node.parent, | ||
| 317 | level: location.node.level, | ||
| 318 | format: location.node.format.clone(), | ||
| 319 | ..self.blank_paragraph(source)? | ||
| 320 | }) | ||
| 321 | }; | ||
| 322 | let (first, second) = (blank()?, blank()?); | ||
| 316 | ( | 323 | ( |
| 317 | vec![wrapper, first, second], | 324 | vec![wrapper, first, second], |
| 318 | TextPosition { | 325 | TextPosition { |
crates/canvas/src/gpu/mod.rs+10-3| ... | @@ -32,6 +32,7 @@ impl Viewport { | ... | @@ -32,6 +32,7 @@ impl Viewport { |
| 32 | scale: self.scale, | 32 | scale: self.scale, |
| 33 | origin: self.origin, | 33 | origin: self.origin, |
| 34 | clip: None, | 34 | clip: None, |
| 35 | backdrop: None, | ||
| 35 | primitives, | 36 | primitives, |
| 36 | } | 37 | } |
| 37 | } | 38 | } |
| ... | @@ -53,7 +54,7 @@ impl Paper { | ... | @@ -53,7 +54,7 @@ impl Paper { |
| 53 | 54 | ||
| 54 | /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its | 55 | /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its |
| 55 | /// darkness becomes ink and its tint tints the paper. | 56 | /// darkness becomes ink and its tint tints the paper. |
| 56 | fn shade(&self, light: [f32; 4]) -> [f32; 4] { | 57 | pub(crate) fn shade(&self, light: [f32; 4]) -> [f32; 4] { |
| 57 | let ink = 1.0 - (light[0] + light[1] + light[2]) / 3.0; | 58 | let ink = 1.0 - (light[0] + light[1] + light[2]) / 3.0; |
| 58 | let mut shade = light; | 59 | let mut shade = light; |
| 59 | for channel in 0..3 { | 60 | for channel in 0..3 { |
| ... | @@ -78,8 +79,14 @@ impl Glyphs for TextLayout { | ... | @@ -78,8 +79,14 @@ impl Glyphs for TextLayout { |
| 78 | bounds.baseline, | 79 | bounds.baseline, |
| 79 | run.style().brush.rise, | 80 | run.style().brush.rise, |
| 80 | [bounds.top, bounds.height], | 81 | [bounds.top, bounds.height], |
| 81 | // A highlight keeps its stored colour, so automatic text on it stays black. | 82 | // Automatic text on a highlight stays black, but on a black highlight, which |
| 82 | |brush: &TextBrush| brush.color.or(brush.highlight.map(|_| 0)).map(colorref), | 83 | // paints in the ink, it stays ink. |
| 84 | |brush: &TextBrush| { | ||
| 85 | brush | ||
| 86 | .color | ||
| 87 | .or(brush.highlight.filter(|&color| color != 0).map(|_| 0)) | ||
| 88 | .map(colorref) | ||
| 89 | }, | ||
| 83 | paint, | 90 | paint, |
| 84 | )?; | 91 | )?; |
| 85 | } | 92 | } |
crates/canvas/src/gpu/page.rs+297-62| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | mod background; | 1 | mod background; |
| 2 | mod picture; | ||
| 2 | 3 | ||
| 3 | use super::{Paper, colorref}; | 4 | use super::{Paper, colorref}; |
| 4 | use crate::editor::ReadOnlyObject; | 5 | use crate::editor::ReadOnlyObject; |
| ... | @@ -9,18 +10,68 @@ use crate::{ | ... | @@ -9,18 +10,68 @@ use crate::{ |
| 9 | layout::{LayoutError, TextEngine}, | 10 | layout::{LayoutError, TextEngine}, |
| 10 | }; | 11 | }; |
| 11 | use background::Background; | 12 | use background::Background; |
| 12 | use draw::{Primitive, RasterImage, RenderError}; | 13 | use draw::Primitive; |
| 13 | use onestore::page::Page; | 14 | use onestore::page::Page; |
| 14 | use std::fmt; | 15 | use picture::Picture; |
| 16 | use std::{ | ||
| 17 | fmt, | ||
| 18 | sync::{Arc, Mutex, OnceLock, mpsc}, | ||
| 19 | task::Waker, | ||
| 20 | time::{Duration, Instant}, | ||
| 21 | }; | ||
| 15 | 22 | ||
| 16 | /// Retained drawing data in the source page's coordinate system. | 23 | /// Retained drawing data in the source page's coordinate system. |
| 17 | pub struct PageScene { | 24 | pub struct PageScene { |
| 18 | reference: Option<Vec<Content>>, | 25 | reference: Option<Vec<Content>>, |
| 19 | images: std::collections::BTreeMap<onestore::ExGuid, RasterImage>, | 26 | /// Pictures and file icons, by the identity of the object that shows them. |
| 27 | pictures: std::collections::BTreeMap<onestore::ExGuid, Picture>, | ||
| 20 | /// Background pictures from OneNote's page templates, painted from their recreations. | 28 | /// Background pictures from OneNote's page templates, painted from their recreations. |
| 21 | backgrounds: std::collections::BTreeMap<onestore::ExGuid, Background>, | 29 | backgrounds: std::collections::BTreeMap<onestore::ExGuid, Background>, |
| 22 | } | 30 | } |
| 23 | 31 | ||
| 32 | /// How long the zoom must hold before a raster is made again for it. | ||
| 33 | const SETTLE: Duration = Duration::from_millis(150); | ||
| 34 | |||
| 35 | /// Where work done off the frame thread lands while its requester keeps it. | ||
| 36 | type Slot<T> = Arc<Mutex<Option<T>>>; | ||
| 37 | |||
| 38 | /// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot | ||
| 39 | /// returned has been dropped by then; the result lands in the slot and wakes `waker`. | ||
| 40 | fn queue<T: Send + 'static>( | ||
| 41 | start: Instant, | ||
| 42 | waker: &Waker, | ||
| 43 | work: impl FnOnce() -> T + Send + 'static, | ||
| 44 | ) -> Slot<T> { | ||
| 45 | static WORKER: OnceLock<mpsc::Sender<Box<dyn FnOnce() + Send>>> = OnceLock::new(); | ||
| 46 | let slot = Slot::default(); | ||
| 47 | let requester = Arc::downgrade(&slot); | ||
| 48 | let waker = waker.clone(); | ||
| 49 | WORKER | ||
| 50 | .get_or_init(|| { | ||
| 51 | let (sender, jobs) = mpsc::channel::<Box<dyn FnOnce() + Send>>(); | ||
| 52 | std::thread::Builder::new() | ||
| 53 | .name("page pictures".into()) | ||
| 54 | .spawn(move || jobs.into_iter().for_each(|job| job())) | ||
| 55 | .expect("The picture thread starts"); | ||
| 56 | sender | ||
| 57 | }) | ||
| 58 | .send(Box::new(move || { | ||
| 59 | std::thread::sleep(start.saturating_duration_since(Instant::now())); | ||
| 60 | if let Some(slot) = requester.upgrade() { | ||
| 61 | *slot.lock().unwrap() = Some(work()); | ||
| 62 | waker.wake(); | ||
| 63 | } | ||
| 64 | })) | ||
| 65 | .expect("The picture thread outlives its jobs"); | ||
| 66 | slot | ||
| 67 | } | ||
| 68 | |||
| 69 | /// Device pixels per point a raster is made at for `scale`: the next half power of two, | ||
| 70 | /// so zooming makes rasters again in steps. | ||
| 71 | fn density(scale: f32) -> f32 { | ||
| 72 | 2_f32.powf((scale.max(0.25).log2() * 2.0).ceil() / 2.0) | ||
| 73 | } | ||
| 74 | |||
| 24 | #[derive(Debug, PartialEq)] | 75 | #[derive(Debug, PartialEq)] |
| 25 | pub enum SceneHit<T> { | 76 | pub enum SceneHit<T> { |
| 26 | Outline(T), | 77 | Outline(T), |
| ... | @@ -106,7 +157,6 @@ pub enum SceneError { | ... | @@ -106,7 +157,6 @@ pub enum SceneError { |
| 106 | Layout(LayoutError), | 157 | Layout(LayoutError), |
| 107 | Editor(EditorError), | 158 | Editor(EditorError), |
| 108 | MissingOutline, | 159 | MissingOutline, |
| 109 | Image(RenderError), | ||
| 110 | MissingImage, | 160 | MissingImage, |
| 111 | InvalidGeometry, | 161 | InvalidGeometry, |
| 112 | } | 162 | } |
| ... | @@ -117,7 +167,6 @@ impl fmt::Display for SceneError { | ... | @@ -117,7 +167,6 @@ impl fmt::Display for SceneError { |
| 117 | Self::Layout(error) => error.fmt(f), | 167 | Self::Layout(error) => error.fmt(f), |
| 118 | Self::Editor(error) => error.fmt(f), | 168 | Self::Editor(error) => error.fmt(f), |
| 119 | Self::MissingOutline => f.write_str("An editable outline is missing from this page."), | 169 | Self::MissingOutline => f.write_str("An editable outline is missing from this page."), |
| 120 | Self::Image(error) => write!(f, "Image rendering failed: {error:?}"), | ||
| 121 | Self::MissingImage => f.write_str("An image is missing its data or dimensions."), | 170 | Self::MissingImage => f.write_str("An image is missing its data or dimensions."), |
| 122 | Self::InvalidGeometry => { | 171 | Self::InvalidGeometry => { |
| 123 | f.write_str("The page contains invalid object dimensions or positions.") | 172 | f.write_str("The page contains invalid object dimensions or positions.") |
| ... | @@ -146,7 +195,7 @@ impl PageScene { | ... | @@ -146,7 +195,7 @@ impl PageScene { |
| 146 | let mut objects = crate::editor::page::build(&mut page, engine, false) | 195 | let mut objects = crate::editor::page::build(&mut page, engine, false) |
| 147 | .map_err(SceneError::from)? | 196 | .map_err(SceneError::from)? |
| 148 | .objects; | 197 | .objects; |
| 149 | let pictures = Self::decode_images(&objects, None)?; | 198 | let pictures = Self::pictures(&objects, None)?; |
| 150 | pictures.mark_unavailable(&mut objects, engine)?; | 199 | pictures.mark_unavailable(&mut objects, engine)?; |
| 151 | Ok(Self { | 200 | Ok(Self { |
| 152 | reference: Some(objects), | 201 | reference: Some(objects), |
| ... | @@ -159,12 +208,13 @@ impl PageScene { | ... | @@ -159,12 +208,13 @@ impl PageScene { |
| 159 | engine: &mut TextEngine, | 208 | engine: &mut TextEngine, |
| 160 | ) -> Result<(Self, CanvasEditor), SceneError> { | 209 | ) -> Result<(Self, CanvasEditor), SceneError> { |
| 161 | let mut editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; | 210 | let mut editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; |
| 162 | let scene = Self::decode_images(&editor.objects, Some(&editor))?; | 211 | let scene = Self::pictures(&editor.objects, Some(&editor))?; |
| 163 | scene.mark_unavailable(&mut editor.objects, engine)?; | 212 | scene.mark_unavailable(&mut editor.objects, engine)?; |
| 164 | Ok((scene, editor)) | 213 | Ok((scene, editor)) |
| 165 | } | 214 | } |
| 166 | 215 | ||
| 167 | /// Turns page pictures that did not decode into placeholders; their stored data is kept. | 216 | /// Turns page pictures the renderer cannot decode into placeholders; their stored data |
| 217 | /// is kept. | ||
| 168 | fn mark_unavailable( | 218 | fn mark_unavailable( |
| 169 | &self, | 219 | &self, |
| 170 | objects: &mut Vec<Content>, | 220 | objects: &mut Vec<Content>, |
| ... | @@ -174,7 +224,7 @@ impl PageScene { | ... | @@ -174,7 +224,7 @@ impl PageScene { |
| 174 | .into_iter() | 224 | .into_iter() |
| 175 | .map(|object| match object { | 225 | .map(|object| match object { |
| 176 | Content::Image(source) | 226 | Content::Image(source) |
| 177 | if !self.images.contains_key(&source.id) | 227 | if !self.pictures.contains_key(&source.id) |
| 178 | && !self.backgrounds.contains_key(&source.id) => | 228 | && !self.backgrounds.contains_key(&source.id) => |
| 179 | { | 229 | { |
| 180 | Content::unavailable(onestore::page::PageObject::Image(source), engine) | 230 | Content::unavailable(onestore::page::PageObject::Image(source), engine) |
| ... | @@ -185,24 +235,21 @@ impl PageScene { | ... | @@ -185,24 +235,21 @@ impl PageScene { |
| 185 | Ok(()) | 235 | Ok(()) |
| 186 | } | 236 | } |
| 187 | 237 | ||
| 188 | /// Decodes every picture the page draws, page-level ones and those inside outlines, | 238 | /// Every picture the page draws, page-level ones and those inside outlines, except the |
| 189 | /// except the template backgrounds it recognises; one without decodable data is left out. | 239 | /// template backgrounds it recognises; one the renderer cannot decode is left out. |
| 190 | fn decode_images( | 240 | fn pictures(objects: &[Content], editor: Option<&CanvasEditor>) -> Result<Self, SceneError> { |
| 191 | objects: &[Content], | ||
| 192 | editor: Option<&CanvasEditor>, | ||
| 193 | ) -> Result<Self, SceneError> { | ||
| 194 | fn nested<'a>( | 241 | fn nested<'a>( |
| 195 | nodes: &'a [onestore::page::PageParagraph], | 242 | nodes: &'a [onestore::page::PageParagraph], |
| 196 | payloads: &mut Vec<(onestore::ExGuid, Option<&'a [u8]>)>, | 243 | payloads: &mut Vec<(onestore::ExGuid, Option<&'a Arc<[u8]>>)>, |
| 197 | ) { | 244 | ) { |
| 198 | for node in nodes { | 245 | for node in nodes { |
| 199 | match &node.content { | 246 | match &node.content { |
| 200 | onestore::page::ParagraphContent::Image(image) => { | 247 | onestore::page::ParagraphContent::Image(image) => { |
| 201 | payloads.push((image.id, image.bytes.as_deref())) | 248 | payloads.push((image.id, image.bytes.as_ref())) |
| 202 | } | 249 | } |
| 203 | // A file without the icon OneNote rendered for it keeps an empty slot. | 250 | // A file without the icon OneNote rendered for it keeps an empty slot. |
| 204 | onestore::page::ParagraphContent::Attachment(file) => { | 251 | onestore::page::ParagraphContent::Attachment(file) => { |
| 205 | if let Some(icon) = file.preview.as_deref() { | 252 | if let Some(icon) = file.preview.as_ref() { |
| 206 | payloads.push((file.id, Some(icon))) | 253 | payloads.push((file.id, Some(icon))) |
| 207 | } | 254 | } |
| 208 | } | 255 | } |
| ... | @@ -225,7 +272,7 @@ impl PageScene { | ... | @@ -225,7 +272,7 @@ impl PageScene { |
| 225 | { | 272 | { |
| 226 | backgrounds.insert(source.id, art); | 273 | backgrounds.insert(source.id, art); |
| 227 | } else { | 274 | } else { |
| 228 | payloads.push((source.id, source.bytes.as_deref())) | 275 | payloads.push((source.id, source.bytes.as_ref())) |
| 229 | } | 276 | } |
| 230 | } | 277 | } |
| 231 | Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), | 278 | Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), |
| ... | @@ -239,46 +286,112 @@ impl PageScene { | ... | @@ -239,46 +286,112 @@ impl PageScene { |
| 239 | Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {} | 286 | Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {} |
| 240 | } | 287 | } |
| 241 | } | 288 | } |
| 242 | let mut images = std::collections::BTreeMap::new(); | 289 | let mut pictures = std::collections::BTreeMap::new(); |
| 243 | let mut bytes = 0_u64; | ||
| 244 | for (id, encoded) in payloads { | 290 | for (id, encoded) in payloads { |
| 245 | let Some(image) = encoded.and_then(|encoded| RasterImage::decode(encoded).ok()) else { | 291 | let Some(picture) = encoded.and_then(Picture::new) else { |
| 246 | continue; | 292 | continue; |
| 247 | }; | 293 | }; |
| 248 | bytes += image.pixels().len() as u64; | 294 | if pictures.insert(id, picture).is_some() { |
| 249 | if bytes > draw::MAX_IMAGE_BYTES { | ||
| 250 | return Err(SceneError::Image(RenderError::ImageBudget)); | ||
| 251 | } | ||
| 252 | if images.insert(id, image).is_some() { | ||
| 253 | return Err(SceneError::InvalidGeometry); | 295 | return Err(SceneError::InvalidGeometry); |
| 254 | } | 296 | } |
| 255 | } | 297 | } |
| 256 | Ok(Self { | 298 | Ok(Self { |
| 257 | reference: None, | 299 | reference: None, |
| 258 | images, | 300 | pictures, |
| 259 | backgrounds, | 301 | backgrounds, |
| 260 | }) | 302 | }) |
| 261 | } | 303 | } |
| 262 | 304 | ||
| 263 | pub fn image(&self, id: onestore::ExGuid) -> Option<&RasterImage> { | 305 | /// Brings template backgrounds to `paper` and the pictures near `view`, a rectangle in |
| 264 | self.images.get(&id) | 306 | /// scene coordinates, to `scale` device pixels per point, rasterizing and decoding off |
| 265 | } | 307 | /// this thread; `waker` is woken when one lands and the page should be drawn again. |
| 266 | 308 | /// Call before collecting each frame's primitives. True once everything in view shows | |
| 267 | /// Brings template backgrounds to `paper` at `scale` device pixels per point, rasterizing | 309 | /// its raster for this paper and scale. |
| 268 | /// off this thread; `waker` is woken when a raster lands and the page should be drawn | 310 | pub fn update_pictures( |
| 269 | /// again. Call before collecting each frame's primitives. True once every background | ||
| 270 | /// shows its raster for this paper and scale. | ||
| 271 | pub fn update_backgrounds( | ||
| 272 | &mut self, | 311 | &mut self, |
| 273 | paper: Paper, | 312 | editor: Option<&CanvasEditor>, |
| 313 | view: [f32; 4], | ||
| 274 | scale: f32, | 314 | scale: f32, |
| 275 | waker: &std::task::Waker, | 315 | paper: Paper, |
| 316 | waker: &Waker, | ||
| 276 | ) -> bool { | 317 | ) -> bool { |
| 277 | let mut settled = true; | 318 | let mut settled = true; |
| 278 | for art in self.backgrounds.values_mut() { | 319 | for art in self.backgrounds.values_mut() { |
| 279 | settled &= art.update(paper, scale, waker); | 320 | settled &= art.update(paper, scale, waker); |
| 280 | } | 321 | } |
| 281 | settled | 322 | let rects = self.picture_rects(editor); |
| 323 | picture::update(&mut self.pictures, rects, view, scale, waker) && settled | ||
| 324 | } | ||
| 325 | |||
| 326 | /// Waits until the whole page shows its rasters for `paper` at `scale`, for drawing it | ||
| 327 | /// offscreen. | ||
| 328 | pub fn settle(&mut self, editor: Option<&CanvasEditor>, scale: f32, paper: Paper) { | ||
| 329 | let everything = [ | ||
| 330 | f32::NEG_INFINITY, | ||
| 331 | f32::NEG_INFINITY, | ||
| 332 | f32::INFINITY, | ||
| 333 | f32::INFINITY, | ||
| 334 | ]; | ||
| 335 | while !self.update_pictures(editor, everything, scale, paper, Waker::noop()) { | ||
| 336 | std::thread::sleep(Duration::from_millis(1)); | ||
| 337 | } | ||
| 338 | } | ||
| 339 | |||
| 340 | /// The raster a picture or file icon shows, once decoded. | ||
| 341 | pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> { | ||
| 342 | self.pictures.get(&id)?.image() | ||
| 343 | } | ||
| 344 | |||
| 345 | /// Where pictures and file icons sit, in scene coordinates. | ||
| 346 | fn picture_rects(&self, editor: Option<&CanvasEditor>) -> Vec<(onestore::ExGuid, [f32; 4])> { | ||
| 347 | fn outline( | ||
| 348 | layout: &crate::outline::OutlineLayout, | ||
| 349 | [x, y]: [f32; 2], | ||
| 350 | rects: &mut Vec<(onestore::ExGuid, [f32; 4])>, | ||
| 351 | ) { | ||
| 352 | rects.extend(layout.objects.iter().map(|object| { | ||
| 353 | let [x0, y0, x1, y1] = object.rect; | ||
| 354 | (object.id, [x0 + x, y0 + y, x1 + x, y1 + y]) | ||
| 355 | })); | ||
| 356 | } | ||
| 357 | let mut rects = Vec::new(); | ||
| 358 | for content in self.objects(editor).into_iter().flatten() { | ||
| 359 | match content { | ||
| 360 | Content::Image(source) => { | ||
| 361 | let [x, y] = [ | ||
| 362 | source.layout.x.unwrap_or(0.0), | ||
| 363 | source.layout.y.unwrap_or(0.0), | ||
| 364 | ]; | ||
| 365 | if let (Some(width), Some(height)) = | ||
| 366 | (source.layout.max_width, source.layout.max_height) | ||
| 367 | { | ||
| 368 | rects.push((source.id, [x, y, x + width, y + height])); | ||
| 369 | } | ||
| 370 | } | ||
| 371 | Content::Outline { | ||
| 372 | source, | ||
| 373 | layout, | ||
| 374 | below_title, | ||
| 375 | } => { | ||
| 376 | let origin = [ | ||
| 377 | source.layout.x.unwrap_or(0.0), | ||
| 378 | source.layout.y.unwrap_or(0.0), | ||
| 379 | ]; | ||
| 380 | if let Ok(origin) = outline_origin(origin, *below_title, editor) { | ||
| 381 | outline(layout, origin, &mut rects); | ||
| 382 | } | ||
| 383 | } | ||
| 384 | Content::Editable(id) => { | ||
| 385 | if let Some(editable) = | ||
| 386 | editor.and_then(|editor| editor.visible_outlines().find(|o| o.id == *id)) | ||
| 387 | { | ||
| 388 | outline(editable.shaped(), editable.origin(), &mut rects); | ||
| 389 | } | ||
| 390 | } | ||
| 391 | Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {} | ||
| 392 | } | ||
| 393 | } | ||
| 394 | rects | ||
| 282 | } | 395 | } |
| 283 | 396 | ||
| 284 | /// Pictures, files and handwriting inside an outline whose origin is `origin`. | 397 | /// Pictures, files and handwriting inside an outline whose origin is `origin`. |
| ... | @@ -302,7 +415,8 @@ impl PageScene { | ... | @@ -302,7 +415,8 @@ impl PageScene { |
| 302 | y1 + origin[1], | 415 | y1 + origin[1], |
| 303 | ], | 416 | ], |
| 304 | }; | 417 | }; |
| 305 | if let Some(image) = self.images.get(&object.id) { | 418 | let picture = self.pictures.get(&object.id); |
| 419 | if let Some(image) = picture.and_then(Picture::image) { | ||
| 306 | primitives.push(Primitive::Image { image, rect }); | 420 | primitives.push(Primitive::Image { image, rect }); |
| 307 | } | 421 | } |
| 308 | match &object.kind { | 422 | match &object.kind { |
| ... | @@ -312,7 +426,7 @@ impl PageScene { | ... | @@ -312,7 +426,7 @@ impl PageScene { |
| 312 | crate::outline::ObjectKind::Unsupported(_) => { | 426 | crate::outline::ObjectKind::Unsupported(_) => { |
| 313 | append_placeholder(rect, paper, primitives) | 427 | append_placeholder(rect, paper, primitives) |
| 314 | } | 428 | } |
| 315 | crate::outline::ObjectKind::Picture if !self.images.contains_key(&object.id) => { | 429 | crate::outline::ObjectKind::Picture if picture.is_none_or(Picture::failed) => { |
| 316 | append_placeholder(rect, paper, primitives) | 430 | append_placeholder(rect, paper, primitives) |
| 317 | } | 431 | } |
| 318 | crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} | 432 | crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} |
| ... | @@ -588,21 +702,35 @@ impl PageScene { | ... | @@ -588,21 +702,35 @@ impl PageScene { |
| 588 | let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; | 702 | let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; |
| 589 | match content { | 703 | match content { |
| 590 | Content::Image(source) => { | 704 | Content::Image(source) => { |
| 705 | // The paper shows until the worker's raster lands. | ||
| 591 | let (image, [width, height]) = match self.backgrounds.get(&source.id) { | 706 | let (image, [width, height]) = match self.backgrounds.get(&source.id) { |
| 592 | // The paper shows until the worker's raster lands. | 707 | Some(art) => (art.image(paper), art.size), |
| 593 | Some(art) => match art.image(paper) { | 708 | None => { |
| 594 | Some(image) => (image, art.size), | 709 | let picture = self |
| 595 | None => continue, | 710 | .pictures |
| 596 | }, | ||
| 597 | None => ( | ||
| 598 | self.images | ||
| 599 | .get(&source.id) | 711 | .get(&source.id) |
| 600 | .ok_or(SceneError::MissingImage)?, | 712 | .ok_or(SceneError::MissingImage)?; |
| 601 | [ | 713 | let size = [ |
| 602 | source.layout.max_width.ok_or(SceneError::MissingImage)?, | 714 | source.layout.max_width.ok_or(SceneError::MissingImage)?, |
| 603 | source.layout.max_height.ok_or(SceneError::MissingImage)?, | 715 | source.layout.max_height.ok_or(SceneError::MissingImage)?, |
| 604 | ], | 716 | ]; |
| 605 | ), | 717 | if picture.failed() { |
| 718 | append_placeholder( | ||
| 719 | [ | ||
| 720 | object_origin[0], | ||
| 721 | object_origin[1], | ||
| 722 | object_origin[0] + size[0], | ||
| 723 | object_origin[1] + size[1], | ||
| 724 | ], | ||
| 725 | paper, | ||
| 726 | primitives, | ||
| 727 | ); | ||
| 728 | } | ||
| 729 | (picture.image(), size) | ||
| 730 | } | ||
| 731 | }; | ||
| 732 | let Some(image) = image else { | ||
| 733 | continue; | ||
| 606 | }; | 734 | }; |
| 607 | primitives.push(Primitive::Image { | 735 | primitives.push(Primitive::Image { |
| 608 | image, | 736 | image, |
| ... | @@ -632,7 +760,7 @@ impl PageScene { | ... | @@ -632,7 +760,7 @@ impl PageScene { |
| 632 | }; | 760 | }; |
| 633 | outline.append_table_primitives(primitives, object_origin, paper); | 761 | outline.append_table_primitives(primitives, object_origin, paper); |
| 634 | let everything = [f32::NEG_INFINITY, f32::INFINITY]; | 762 | let everything = [f32::NEG_INFINITY, f32::INFINITY]; |
| 635 | outline.append_background_primitives(primitives, object_origin, everything); | 763 | outline.append_background_primitives(primitives, object_origin, everything, paper); |
| 636 | self.append_outline_objects(outline, object_origin, None, paper, primitives); | 764 | self.append_outline_objects(outline, object_origin, None, paper, primitives); |
| 637 | for (index, paragraph) in outline.visible(everything) { | 765 | for (index, paragraph) in outline.visible(everything) { |
| 638 | outline.append_paragraph_primitives( | 766 | outline.append_paragraph_primitives( |
| ... | @@ -671,12 +799,14 @@ impl crate::outline::OutlineLayout { | ... | @@ -671,12 +799,14 @@ impl crate::outline::OutlineLayout { |
| 671 | }) | 799 | }) |
| 672 | } | 800 | } |
| 673 | 801 | ||
| 674 | /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. | 802 | /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black |
| 803 | /// highlight paints in the paper's ink, censoring the automatic text on it in any theme. | ||
| 675 | pub fn append_background_primitives( | 804 | pub fn append_background_primitives( |
| 676 | &self, | 805 | &self, |
| 677 | primitives: &mut Vec<Primitive<'_>>, | 806 | primitives: &mut Vec<Primitive<'_>>, |
| 678 | origin: [f32; 2], | 807 | origin: [f32; 2], |
| 679 | rows: [f32; 2], | 808 | rows: [f32; 2], |
| 809 | paper: Paper, | ||
| 680 | ) { | 810 | ) { |
| 681 | for (index, paragraph) in self.visible(rows) { | 811 | for (index, paragraph) in self.visible(rows) { |
| 682 | for (mut rect, color) in paragraph.text.backgrounds() { | 812 | for (mut rect, color) in paragraph.text.backgrounds() { |
| ... | @@ -697,7 +827,11 @@ impl crate::outline::OutlineLayout { | ... | @@ -697,7 +827,11 @@ impl crate::outline::OutlineLayout { |
| 697 | rect.x1 as f32 + origin[0], | 827 | rect.x1 as f32 + origin[0], |
| 698 | rect.y1 as f32 + origin[1], | 828 | rect.y1 as f32 + origin[1], |
| 699 | ], | 829 | ], |
| 700 | color: colorref(color), | 830 | color: if color == 0 { |
| 831 | paper.ink | ||
| 832 | } else { | ||
| 833 | colorref(color) | ||
| 834 | }, | ||
| 701 | }); | 835 | }); |
| 702 | } | 836 | } |
| 703 | } | 837 | } |
| ... | @@ -786,7 +920,7 @@ impl crate::outline::OutlineLayout { | ... | @@ -786,7 +920,7 @@ impl crate::outline::OutlineLayout { |
| 786 | origin[0] + self.tag_column_offset() + tag.origin[0], | 920 | origin[0] + self.tag_column_offset() + tag.origin[0], |
| 787 | y + tag.origin[1], | 921 | y + tag.origin[1], |
| 788 | ], | 922 | ], |
| 789 | size: crate::outline::ParagraphTag::SIZE, | 923 | size: tag.size, |
| 790 | tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], | 924 | tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], |
| 791 | }); | 925 | }); |
| 792 | } | 926 | } |
| ... | @@ -1253,7 +1387,8 @@ mod tests { | ... | @@ -1253,7 +1387,8 @@ mod tests { |
| 1253 | background, | 1387 | background, |
| 1254 | })); | 1388 | })); |
| 1255 | } | 1389 | } |
| 1256 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | 1390 | let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 1391 | scene.settle(Some(&editor), 1.0, Paper::WHITE); | ||
| 1257 | let images = |editor: &CanvasEditor| { | 1392 | let images = |editor: &CanvasEditor| { |
| 1258 | let mut primitives = Vec::new(); | 1393 | let mut primitives = Vec::new(); |
| 1259 | scene | 1394 | scene |
| ... | @@ -1749,8 +1884,10 @@ mod tests { | ... | @@ -1749,8 +1884,10 @@ mod tests { |
| 1749 | ], | 1884 | ], |
| 1750 | }; | 1885 | }; |
| 1751 | let mut engine = TextEngine::default(); | 1886 | let mut engine = TextEngine::default(); |
| 1752 | let reference = PageScene::new(page(), &mut engine).unwrap(); | 1887 | let mut reference = PageScene::new(page(), &mut engine).unwrap(); |
| 1753 | let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); | 1888 | let (mut scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); |
| 1889 | reference.settle(None, 1.0, Paper::WHITE); | ||
| 1890 | scene.settle(Some(&editor), 1.0, Paper::WHITE); | ||
| 1754 | let mut reference_primitives = Vec::new(); | 1891 | let mut reference_primitives = Vec::new(); |
| 1755 | reference | 1892 | reference |
| 1756 | .append_primitives(&mut reference_primitives, [0.0; 2], Paper::WHITE) | 1893 | .append_primitives(&mut reference_primitives, [0.0; 2], Paper::WHITE) |
| ... | @@ -1975,7 +2112,8 @@ mod tests { | ... | @@ -1975,7 +2112,8 @@ mod tests { |
| 1975 | })], | 2112 | })], |
| 1976 | }; | 2113 | }; |
| 1977 | let mut engine = TextEngine::default(); | 2114 | let mut engine = TextEngine::default(); |
| 1978 | let scene = PageScene::new(page(3.0), &mut engine).unwrap(); | 2115 | let mut scene = PageScene::new(page(3.0), &mut engine).unwrap(); |
| 2116 | scene.settle(None, 1.0, Paper::WHITE); | ||
| 1979 | for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] { | 2117 | for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] { |
| 1980 | assert!(matches!( | 2118 | assert!(matches!( |
| 1981 | PageScene::new(page(invalid), &mut engine), | 2119 | PageScene::new(page(invalid), &mut engine), |
| ... | @@ -2046,7 +2184,8 @@ mod tests { | ... | @@ -2046,7 +2184,8 @@ mod tests { |
| 2046 | objects: vec![PageObject::Image(background), PageObject::Image(picture)], | 2184 | objects: vec![PageObject::Image(background), PageObject::Image(picture)], |
| 2047 | }; | 2185 | }; |
| 2048 | let mut engine = TextEngine::default(); | 2186 | let mut engine = TextEngine::default(); |
| 2049 | let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | 2187 | let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 2188 | scene.settle(Some(&editor), 1.0, Paper::WHITE); | ||
| 2050 | let hit = |x| scene.hit_test::<()>([x, 10.0], Some(&editor), |_| None); | 2189 | let hit = |x| scene.hit_test::<()>([x, 10.0], Some(&editor), |_| None); |
| 2051 | assert_eq!(hit(60.0), Some(SceneHit::Image(id))); | 2190 | assert_eq!(hit(60.0), Some(SceneHit::Image(id))); |
| 2052 | assert_eq!(hit(10.0), None); | 2191 | assert_eq!(hit(10.0), None); |
| ... | @@ -2070,4 +2209,100 @@ mod tests { | ... | @@ -2070,4 +2209,100 @@ mod tests { |
| 2070 | .collect(); | 2209 | .collect(); |
| 2071 | assert_eq!(rects, [[0.0, 0.0, 100.0, 100.0], [70.0, 5.0, 120.0, 55.0]]); | 2210 | assert_eq!(rects, [[0.0, 0.0, 100.0, 100.0], [70.0, 5.0, 120.0, 55.0]]); |
| 2072 | } | 2211 | } |
| 2212 | |||
| 2213 | #[test] | ||
| 2214 | fn pictures_past_the_budget_open_decode_in_view_and_keep_their_bytes() { | ||
| 2215 | let side = 1024; | ||
| 2216 | let mut bytes = Vec::new(); | ||
| 2217 | { | ||
| 2218 | let mut encoder = png::Encoder::new(&mut bytes, side, side); | ||
| 2219 | encoder.set_color(png::ColorType::Rgba); | ||
| 2220 | encoder.set_depth(png::BitDepth::Eight); | ||
| 2221 | encoder | ||
| 2222 | .write_header() | ||
| 2223 | .unwrap() | ||
| 2224 | .write_image_data(&[40, 90, 160, 255].repeat((side * side) as usize)) | ||
| 2225 | .unwrap(); | ||
| 2226 | } | ||
| 2227 | let bytes: Arc<[u8]> = Arc::from(bytes); | ||
| 2228 | let pictures: Vec<_> = (0..20) | ||
| 2229 | .map(|index| Image { | ||
| 2230 | size: None, | ||
| 2231 | id: onestore::page::text::new_id().unwrap(), | ||
| 2232 | layout: Layout { | ||
| 2233 | x: Some(0.0), | ||
| 2234 | y: Some(index as f32 * 600.0), | ||
| 2235 | max_width: Some(512.0), | ||
| 2236 | max_height: Some(512.0), | ||
| 2237 | ..Default::default() | ||
| 2238 | }, | ||
| 2239 | bytes: Some(Arc::clone(&bytes)), | ||
| 2240 | alt: None, | ||
| 2241 | background: false, | ||
| 2242 | }) | ||
| 2243 | .collect(); | ||
| 2244 | assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES); | ||
| 2245 | let page = Page { | ||
| 2246 | identity: None, | ||
| 2247 | created: None, | ||
| 2248 | title: String::new(), | ||
| 2249 | margin_origin: [0.0; 2], | ||
| 2250 | definitions: BTreeMap::new(), | ||
| 2251 | objects: pictures.iter().cloned().map(PageObject::Image).collect(), | ||
| 2252 | }; | ||
| 2253 | let mut engine = TextEngine::default(); | ||
| 2254 | let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 2255 | let kept = |scene: &PageScene| -> u64 { | ||
| 2256 | let shown = scene.pictures.values().filter_map(Picture::image); | ||
| 2257 | shown.map(|image| image.pixels().len() as u64).sum() | ||
| 2258 | }; | ||
| 2259 | let shown = |scene: &PageScene, index: usize| { | ||
| 2260 | scene.image(pictures[index].id).map(|image| image.size()) | ||
| 2261 | }; | ||
| 2262 | let settle = |scene: &mut PageScene, view: [f32; 4]| { | ||
| 2263 | let give_up = Instant::now() + Duration::from_secs(20); | ||
| 2264 | while !scene.update_pictures(Some(&editor), view, 2.0, Paper::WHITE, Waker::noop()) { | ||
| 2265 | assert!( | ||
| 2266 | Instant::now() < give_up, | ||
| 2267 | "the pictures in view never landed" | ||
| 2268 | ); | ||
| 2269 | std::thread::sleep(Duration::from_millis(2)); | ||
| 2270 | } | ||
| 2271 | assert!(kept(scene) <= picture::BUDGET, "{}", kept(scene)); | ||
| 2272 | }; | ||
| 2273 | for top in (0..20).map(|index| index as f32 * 600.0) { | ||
| 2274 | let view = [0.0, top, 600.0, top + 1200.0]; | ||
| 2275 | settle(&mut scene, view); | ||
| 2276 | let index = (top / 600.0) as usize; | ||
| 2277 | assert_eq!(shown(&scene, index), Some([side; 2])); | ||
| 2278 | let mut primitives = Vec::new(); | ||
| 2279 | scene | ||
| 2280 | .append_primitives_with::<SceneError>( | ||
| 2281 | &mut primitives, | ||
| 2282 | [0.0; 2], | ||
| 2283 | Some(&editor), | ||
| 2284 | None, | ||
| 2285 | Paper::WHITE, | ||
| 2286 | |_, _, _| Ok(()), | ||
| 2287 | ) | ||
| 2288 | .unwrap(); | ||
| 2289 | assert!(primitives.iter().any(|primitive| matches!( | ||
| 2290 | primitive, | ||
| 2291 | Primitive::Image { rect, .. } if rect[1] == top | ||
| 2292 | ))); | ||
| 2293 | } | ||
| 2294 | assert_eq!(shown(&scene, 0), None, "the first picture was let go"); | ||
| 2295 | // Every picture in view at once shrinks to fit rather than failing the frame. | ||
| 2296 | settle(&mut scene, [0.0, 0.0, 600.0, 12_000.0]); | ||
| 2297 | for index in 0..20 { | ||
| 2298 | let [width, height] = shown(&scene, index).unwrap(); | ||
| 2299 | assert!(width < side && width == height, "{width}×{height}"); | ||
| 2300 | } | ||
| 2301 | for object in editor.page().unwrap().objects { | ||
| 2302 | let PageObject::Image(picture) = object else { | ||
| 2303 | panic!() | ||
| 2304 | }; | ||
| 2305 | assert!(Arc::ptr_eq(picture.bytes.as_ref().unwrap(), &bytes)); | ||
| 2306 | } | ||
| 2307 | } | ||
| 2073 | } | 2308 | } |
crates/canvas/src/gpu/page/background.rs+21-94| ... | @@ -1,12 +1,12 @@ | ... | @@ -1,12 +1,12 @@ |
| 1 | //! OneNote 2010's page template art, recognised by the SHA-256 of the embedded picture and | 1 | //! OneNote 2010's page template art, recognised by the SHA-256 of the embedded picture and |
| 2 | //! painted from vector recreations that suit the paper. | 2 | //! painted from vector recreations that suit the paper. |
| 3 | 3 | ||
| 4 | use super::{SETTLE, Slot, density, queue}; | ||
| 4 | use crate::gpu::Paper; | 5 | use crate::gpu::Paper; |
| 5 | use draw::RasterImage; | 6 | use draw::RasterImage; |
| 6 | use resvg::{tiny_skia, usvg}; | 7 | use resvg::{tiny_skia, usvg}; |
| 7 | use sha2::{Digest, Sha256}; | 8 | use sha2::{Digest, Sha256}; |
| 8 | use std::{ | 9 | use std::{ |
| 9 | sync::{Arc, Mutex, OnceLock, Weak, mpsc}, | ||
| 10 | task::Waker, | 10 | task::Waker, |
| 11 | time::{Duration, Instant}, | 11 | time::{Duration, Instant}, |
| 12 | }; | 12 | }; |
| ... | @@ -311,8 +311,6 @@ const TEMPLATES: &[(&str, &str)] = &[ | ... | @@ -311,8 +311,6 @@ const TEMPLATES: &[(&str, &str)] = &[ |
| 311 | /// A raster holds at most this many bytes; page-sized art stops sharpening past about 2.9 | 311 | /// A raster holds at most this many bytes; page-sized art stops sharpening past about 2.9 |
| 312 | /// device pixels per point. | 312 | /// device pixels per point. |
| 313 | const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0; | 313 | const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0; |
| 314 | /// How long the zoom must hold before art is rasterized again for it. | ||
| 315 | const SETTLE: Duration = Duration::from_millis(150); | ||
| 316 | 314 | ||
| 317 | /// Template art standing in for a page's background picture. | 315 | /// Template art standing in for a page's background picture. |
| 318 | pub(super) struct Background { | 316 | pub(super) struct Background { |
| ... | @@ -328,18 +326,7 @@ struct Variant { | ... | @@ -328,18 +326,7 @@ struct Variant { |
| 328 | /// The raster painted and its device pixels per point. | 326 | /// The raster painted and its device pixels per point. |
| 329 | shown: Option<(RasterImage, f32)>, | 327 | shown: Option<(RasterImage, f32)>, |
| 330 | /// The one raster being made and its device pixels per point; replacing it abandons it. | 328 | /// The one raster being made and its device pixels per point; replacing it abandons it. |
| 331 | pending: Option<(Arc<Mutex<Option<RasterImage>>>, f32)>, | 329 | pending: Option<(Slot<RasterImage>, f32)>, |
| 332 | } | ||
| 333 | |||
| 334 | /// Art rasterized on the worker thread, into `slot` unless its scene has let it go. | ||
| 335 | struct Job { | ||
| 336 | svg: &'static str, | ||
| 337 | /// The dark paper to recolour for; light paper takes the art as drawn. | ||
| 338 | dark: Option<Paper>, | ||
| 339 | size: [u32; 2], | ||
| 340 | start: Instant, | ||
| 341 | slot: Weak<Mutex<Option<RasterImage>>>, | ||
| 342 | waker: Waker, | ||
| 343 | } | 330 | } |
| 344 | 331 | ||
| 345 | impl Background { | 332 | impl Background { |
| ... | @@ -375,8 +362,7 @@ impl Background { | ... | @@ -375,8 +362,7 @@ impl Background { |
| 375 | pub fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool { | 362 | pub fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool { |
| 376 | let dark = dark(paper); | 363 | let dark = dark(paper); |
| 377 | let [width, height] = self.size.map(|side| side.max(1.0)); | 364 | let [width, height] = self.size.map(|side| side.max(1.0)); |
| 378 | let density = 2_f32 | 365 | let density = density(scale) |
| 379 | .powf((scale.max(0.25).log2() * 2.0).ceil() / 2.0) | ||
| 380 | .min((MAX_RASTER_BYTES / (4.0 * width * height)).sqrt()) | 366 | .min((MAX_RASTER_BYTES / (4.0 * width * height)).sqrt()) |
| 381 | .min(4096.0 / width.max(height)); | 367 | .min(4096.0 / width.max(height)); |
| 382 | let variant = &mut self.variants[usize::from(dark)]; | 368 | let variant = &mut self.variants[usize::from(dark)]; |
| ... | @@ -394,22 +380,16 @@ impl Background { | ... | @@ -394,22 +380,16 @@ impl Background { |
| 394 | if shown != Some(density) | 380 | if shown != Some(density) |
| 395 | && variant.pending.as_ref().map(|(_, made)| *made) != Some(density) | 381 | && variant.pending.as_ref().map(|(_, made)| *made) != Some(density) |
| 396 | { | 382 | { |
| 397 | let slot = Arc::default(); | 383 | let svg = self.svg; |
| 398 | worker() | 384 | let paper = dark.then_some(paper); |
| 399 | .send(Job { | 385 | let size = [width, height].map(|side| (side * density).round().max(1.0) as u32); |
| 400 | svg: self.svg, | 386 | let start = Instant::now() |
| 401 | dark: dark.then_some(paper), | 387 | + if shown.is_some() { |
| 402 | size: [width, height].map(|side| (side * density).round().max(1.0) as u32), | 388 | SETTLE |
| 403 | start: Instant::now() | 389 | } else { |
| 404 | + if shown.is_some() { | 390 | Duration::ZERO |
| 405 | SETTLE | 391 | }; |
| 406 | } else { | 392 | let slot = queue(start, waker, move || rasterize(svg, paper, size)); |
| 407 | Duration::ZERO | ||
| 408 | }, | ||
| 409 | slot: Arc::downgrade(&slot), | ||
| 410 | waker: waker.clone(), | ||
| 411 | }) | ||
| 412 | .expect("The background worker outlives its jobs"); | ||
| 413 | variant.pending = Some((slot, density)); | 393 | variant.pending = Some((slot, density)); |
| 414 | } | 394 | } |
| 415 | shown == Some(density) | 395 | shown == Some(density) |
| ... | @@ -417,34 +397,11 @@ impl Background { | ... | @@ -417,34 +397,11 @@ impl Background { |
| 417 | } | 397 | } |
| 418 | 398 | ||
| 419 | fn dark(paper: Paper) -> bool { | 399 | fn dark(paper: Paper) -> bool { |
| 420 | let [lightness, ..] = oklab(paper.color); | 400 | let [lightness, ..] = draw::oklab(paper.color); |
| 421 | let [ink, ..] = oklab(paper.ink); | 401 | let [ink, ..] = draw::oklab(paper.ink); |
| 422 | ink > lightness | 402 | ink > lightness |
| 423 | } | 403 | } |
| 424 | 404 | ||
| 425 | /// The process's one background rasterizer, started on first use. | ||
| 426 | fn worker() -> &'static mpsc::Sender<Job> { | ||
| 427 | static WORKER: OnceLock<mpsc::Sender<Job>> = OnceLock::new(); | ||
| 428 | WORKER.get_or_init(|| { | ||
| 429 | let (sender, jobs) = mpsc::channel::<Job>(); | ||
| 430 | std::thread::Builder::new() | ||
| 431 | .name("template backgrounds".into()) | ||
| 432 | .spawn(move || { | ||
| 433 | for job in jobs { | ||
| 434 | std::thread::sleep(job.start.saturating_duration_since(Instant::now())); | ||
| 435 | let Some(slot) = job.slot.upgrade() else { | ||
| 436 | continue; | ||
| 437 | }; | ||
| 438 | let image = rasterize(job.svg, job.dark, job.size); | ||
| 439 | *slot.lock().unwrap() = Some(image); | ||
| 440 | job.waker.wake(); | ||
| 441 | } | ||
| 442 | }) | ||
| 443 | .expect("The background worker starts"); | ||
| 444 | sender | ||
| 445 | }) | ||
| 446 | } | ||
| 447 | |||
| 448 | fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> RasterImage { | 405 | fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> RasterImage { |
| 449 | let tree = match dark { | 406 | let tree = match dark { |
| 450 | Some(paper) => parse(&onto(svg, paper)), | 407 | Some(paper) => parse(&onto(svg, paper)), |
| ... | @@ -479,8 +436,8 @@ fn parse(svg: &str) -> usvg::Tree { | ... | @@ -479,8 +436,8 @@ fn parse(svg: &str) -> usvg::Tree { |
| 479 | /// the paper towards its ink, easing off so the art stays behind the text, and hue and | 436 | /// the paper towards its ink, easing off so the art stays behind the text, and hue and |
| 480 | /// chroma carry over. Keywords, such as a mask's `white`, stay as they are. | 437 | /// chroma carry over. Keywords, such as a mask's `white`, stay as they are. |
| 481 | fn onto(svg: &str, paper: Paper) -> String { | 438 | fn onto(svg: &str, paper: Paper) -> String { |
| 482 | let [lightness, a, b] = oklab(paper.color); | 439 | let [lightness, a, b] = draw::oklab(paper.color); |
| 483 | let [ink, ..] = oklab(paper.ink); | 440 | let [ink, ..] = draw::oklab(paper.ink); |
| 484 | let mut pieces = svg.split('#'); | 441 | let mut pieces = svg.split('#'); |
| 485 | let mut recoloured = pieces.next().unwrap_or_default().to_owned(); | 442 | let mut recoloured = pieces.next().unwrap_or_default().to_owned(); |
| 486 | for piece in pieces { | 443 | for piece in pieces { |
| ... | @@ -494,9 +451,9 @@ fn onto(svg: &str, paper: Paper) -> String { | ... | @@ -494,9 +451,9 @@ fn onto(svg: &str, paper: Paper) -> String { |
| 494 | continue; | 451 | continue; |
| 495 | }; | 452 | }; |
| 496 | let [_, red, green, blue] = color.to_be_bytes(); | 453 | let [_, red, green, blue] = color.to_be_bytes(); |
| 497 | let [l, ca, cb] = oklab(draw::srgb(red, green, blue)); | 454 | let [l, ca, cb] = draw::oklab(draw::srgb(red, green, blue)); |
| 498 | let turned = lightness + 0.7 * (1.0 - l).max(0.0).sqrt() * (ink - lightness); | 455 | let turned = lightness + 0.7 * (1.0 - l).max(0.0).sqrt() * (ink - lightness); |
| 499 | let [red, green, blue] = linear([turned, a + ca, b + cb]).map(|value| { | 456 | let [red, green, blue] = draw::from_oklab([turned, a + ca, b + cb]).map(|value| { |
| 500 | let encoded = if value <= 0.003_130_8 { | 457 | let encoded = if value <= 0.003_130_8 { |
| 501 | value * 12.92 | 458 | value * 12.92 |
| 502 | } else { | 459 | } else { |
| ... | @@ -510,36 +467,6 @@ fn onto(svg: &str, paper: Paper) -> String { | ... | @@ -510,36 +467,6 @@ fn onto(svg: &str, paper: Paper) -> String { |
| 510 | recoloured | 467 | recoloured |
| 511 | } | 468 | } |
| 512 | 469 | ||
| 513 | /// OKLab lightness and opponent axes of linear RGB. | ||
| 514 | fn oklab([red, green, blue, _]: [f32; 4]) -> [f32; 3] { | ||
| 515 | let [l, m, s] = [ | ||
| 516 | [0.412_221_46, 0.536_332_55, 0.051_445_995], | ||
| 517 | [0.211_903_5, 0.680_699_5, 0.107_396_96], | ||
| 518 | [0.088_302_46, 0.281_718_85, 0.629_978_7], | ||
| 519 | ] | ||
| 520 | .map(|[r, g, b]| (r * red + g * green + b * blue).cbrt()); | ||
| 521 | [ | ||
| 522 | 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, | ||
| 523 | 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, | ||
| 524 | 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, | ||
| 525 | ] | ||
| 526 | } | ||
| 527 | |||
| 528 | /// Linear RGB of OKLab, clipped to the sRGB gamut. | ||
| 529 | fn linear([lightness, a, b]: [f32; 3]) -> [f32; 3] { | ||
| 530 | let [l, m, s] = [ | ||
| 531 | lightness + 0.396_337_78 * a + 0.215_803_76 * b, | ||
| 532 | lightness - 0.105_561_346 * a - 0.063_854_17 * b, | ||
| 533 | lightness - 0.089_484_18 * a - 1.291_485_5 * b, | ||
| 534 | ] | ||
| 535 | .map(|value| value.powi(3)); | ||
| 536 | [ | ||
| 537 | [4.076_741_7, -3.307_711_6, 0.230_969_94], | ||
| 538 | [-1.268_438, 2.609_757_4, -0.341_319_38], | ||
| 539 | [-0.004_196_086_3, -0.703_418_6, 1.707_614_7], | ||
| 540 | ] | ||
| 541 | .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0)) | ||
| 542 | } | ||
| 543 | 470 | ||
| 544 | #[cfg(test)] | 471 | #[cfg(test)] |
| 545 | mod tests { | 472 | mod tests { |
| ... | @@ -550,7 +477,7 @@ mod tests { | ... | @@ -550,7 +477,7 @@ mod tests { |
| 550 | document::Layout, | 477 | document::Layout, |
| 551 | page::{Image, Page, PageObject}, | 478 | page::{Image, Page, PageObject}, |
| 552 | }; | 479 | }; |
| 553 | use std::collections::BTreeMap; | 480 | use std::{collections::BTreeMap, sync::Arc}; |
| 554 | 481 | ||
| 555 | const DARK: Paper = Paper { | 482 | const DARK: Paper = Paper { |
| 556 | color: [0.0137, 0.0144, 0.0159, 1.0], | 483 | color: [0.0137, 0.0144, 0.0159, 1.0], |
| ... | @@ -713,7 +640,7 @@ mod tests { | ... | @@ -713,7 +640,7 @@ mod tests { |
| 713 | .unwrap(); | 640 | .unwrap(); |
| 714 | assert!(before.is_empty(), "the paper shows until the raster lands"); | 641 | assert!(before.is_empty(), "the paper shows until the raster lands"); |
| 715 | drop(before); | 642 | drop(before); |
| 716 | settle(|| scene.update_backgrounds(DARK, 1.0, Waker::noop())); | 643 | scene.settle(None, 1.0, DARK); |
| 717 | let mut primitives = Vec::new(); | 644 | let mut primitives = Vec::new(); |
| 718 | scene | 645 | scene |
| 719 | .append_primitives(&mut primitives, [0.0; 2], DARK) | 646 | .append_primitives(&mut primitives, [0.0; 2], DARK) |
crates/canvas/src/gpu/page/picture.rs created+173| ... | @@ -0,0 +1,173 @@ | ||
| 1 | //! The page's own pictures, decoded off the frame thread at the size they show and let go | ||
| 2 | //! under a budget. | ||
| 3 | |||
| 4 | use super::{SETTLE, Slot, density, queue}; | ||
| 5 | use draw::RasterImage; | ||
| 6 | use onestore::ExGuid; | ||
| 7 | use std::{ | ||
| 8 | collections::BTreeMap, | ||
| 9 | sync::Arc, | ||
| 10 | task::Waker, | ||
| 11 | time::{Duration, Instant}, | ||
| 12 | }; | ||
| 13 | |||
| 14 | /// Decoded bytes a scene's pictures keep at once. Pictures in view shrink to fit it, and | ||
| 15 | /// the rest of the renderer's frame budget stays for template art and the interface. | ||
| 16 | pub(super) const BUDGET: u64 = draw::MAX_IMAGE_BYTES / 2; | ||
| 17 | |||
| 18 | pub(super) struct Picture { | ||
| 19 | /// The stored picture, which saving keeps as it is. | ||
| 20 | bytes: Arc<[u8]>, | ||
| 21 | /// The stored picture's size in pixels. | ||
| 22 | native: [u32; 2], | ||
| 23 | shown: Option<RasterImage>, | ||
| 24 | /// The one decode under way and the size it makes; replacing it abandons it. | ||
| 25 | pending: Option<(Slot<Option<RasterImage>>, [u32; 2])>, | ||
| 26 | /// The stored bytes did not decode, so the picture paints as a placeholder. | ||
| 27 | failed: bool, | ||
| 28 | /// When the picture was last near the view; the longest unseen are let go first. | ||
| 29 | seen: Instant, | ||
| 30 | } | ||
| 31 | |||
| 32 | impl Picture { | ||
| 33 | /// Reads the size from the picture's header; None when the renderer cannot decode it. | ||
| 34 | pub fn new(bytes: &Arc<[u8]>) -> Option<Self> { | ||
| 35 | Some(Self { | ||
| 36 | native: RasterImage::measure(bytes).ok()?, | ||
| 37 | bytes: Arc::clone(bytes), | ||
| 38 | shown: None, | ||
| 39 | pending: None, | ||
| 40 | failed: false, | ||
| 41 | seen: Instant::now(), | ||
| 42 | }) | ||
| 43 | } | ||
| 44 | |||
| 45 | /// The latest raster, once one has landed. | ||
| 46 | pub fn image(&self) -> Option<&RasterImage> { | ||
| 47 | self.shown.as_ref() | ||
| 48 | } | ||
| 49 | |||
| 50 | pub fn failed(&self) -> bool { | ||
| 51 | self.failed | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | fn bytes([width, height]: [u32; 2]) -> u64 { | ||
| 56 | u64::from(width) * u64::from(height) * 4 | ||
| 57 | } | ||
| 58 | |||
| 59 | /// Takes finished decodes and asks for the pictures `rects` places near `view` at `scale` | ||
| 60 | /// device pixels per point; `waker` hears when one lands. Pictures in view come first and | ||
| 61 | /// shrink together to fit the budget, the half view around it decodes ahead with what | ||
| 62 | /// room is left, and those unseen longest are let go past it. True once every picture in | ||
| 63 | /// view shows the raster it asked for. | ||
| 64 | pub(super) fn update( | ||
| 65 | pictures: &mut BTreeMap<ExGuid, Picture>, | ||
| 66 | rects: impl IntoIterator<Item = (ExGuid, [f32; 4])>, | ||
| 67 | view: [f32; 4], | ||
| 68 | scale: f32, | ||
| 69 | waker: &Waker, | ||
| 70 | ) -> bool { | ||
| 71 | let now = Instant::now(); | ||
| 72 | for picture in pictures.values_mut() { | ||
| 73 | let landed = picture | ||
| 74 | .pending | ||
| 75 | .as_ref() | ||
| 76 | .and_then(|(slot, _)| slot.lock().unwrap().take()); | ||
| 77 | if let Some(image) = landed { | ||
| 78 | picture.failed = image.is_none(); | ||
| 79 | picture.shown = image; | ||
| 80 | picture.pending = None; | ||
| 81 | } | ||
| 82 | } | ||
| 83 | let density = density(scale); | ||
| 84 | let [x0, y0, x1, y1] = view; | ||
| 85 | let [ahead_x, ahead_y] = [(x1 - x0) / 2.0, (y1 - y0) / 2.0]; | ||
| 86 | let near = [x0 - ahead_x, y0 - ahead_y, x1 + ahead_x, y1 + ahead_y]; | ||
| 87 | let overlaps = |rect: [f32; 4], area: [f32; 4]| { | ||
| 88 | rect[0] <= area[2] && rect[2] >= area[0] && rect[1] <= area[3] && rect[3] >= area[1] | ||
| 89 | }; | ||
| 90 | let mut wanted: Vec<_> = rects | ||
| 91 | .into_iter() | ||
| 92 | .filter_map(|(id, rect)| { | ||
| 93 | let picture = pictures.get(&id)?; | ||
| 94 | if picture.failed || !overlaps(rect, near) { | ||
| 95 | return None; | ||
| 96 | } | ||
| 97 | let shown = [rect[2] - rect[0], rect[3] - rect[1]]; | ||
| 98 | let size = [0, 1] | ||
| 99 | .map(|axis| ((shown[axis] * density).ceil() as u32).clamp(1, picture.native[axis])); | ||
| 100 | Some((id, size, overlaps(rect, view))) | ||
| 101 | }) | ||
| 102 | .collect(); | ||
| 103 | let visible: u64 = wanted | ||
| 104 | .iter() | ||
| 105 | .filter(|(.., visible)| *visible) | ||
| 106 | .map(|(_, size, _)| bytes(*size)) | ||
| 107 | .sum(); | ||
| 108 | let shrink = (BUDGET as f64 / visible as f64).sqrt(); | ||
| 109 | let mut room = BUDGET.saturating_sub(visible); | ||
| 110 | wanted.retain_mut(|(_, size, visible)| { | ||
| 111 | if *visible && shrink < 1.0 { | ||
| 112 | *size = size.map(|side| ((f64::from(side) * shrink) as u32).max(1)); | ||
| 113 | } else if !*visible { | ||
| 114 | if bytes(*size) > room { | ||
| 115 | return false; | ||
| 116 | } | ||
| 117 | room -= bytes(*size); | ||
| 118 | } | ||
| 119 | true | ||
| 120 | }); | ||
| 121 | let wanted: BTreeMap<_, _> = wanted | ||
| 122 | .into_iter() | ||
| 123 | .map(|(id, size, visible)| (id, (size, visible))) | ||
| 124 | .collect(); | ||
| 125 | let mut settled = true; | ||
| 126 | for (id, picture) in pictures.iter_mut() { | ||
| 127 | let Some(&(size, visible)) = wanted.get(id) else { | ||
| 128 | picture.pending = None; | ||
| 129 | continue; | ||
| 130 | }; | ||
| 131 | picture.seen = now; | ||
| 132 | let shown = picture.shown.as_ref().map(RasterImage::size); | ||
| 133 | if shown != Some(size) && picture.pending.as_ref().map(|(_, made)| *made) != Some(size) { | ||
| 134 | let encoded = Arc::clone(&picture.bytes); | ||
| 135 | let start = now | ||
| 136 | + if shown.is_some() { | ||
| 137 | SETTLE | ||
| 138 | } else { | ||
| 139 | Duration::ZERO | ||
| 140 | }; | ||
| 141 | let slot = queue(start, waker, move || { | ||
| 142 | RasterImage::decode(&encoded, size).ok() | ||
| 143 | }); | ||
| 144 | picture.pending = Some((slot, size)); | ||
| 145 | } | ||
| 146 | settled &= !visible || shown == Some(size); | ||
| 147 | } | ||
| 148 | let mut kept: u64 = pictures | ||
| 149 | .values() | ||
| 150 | .filter_map(Picture::image) | ||
| 151 | .map(|image| bytes(image.size())) | ||
| 152 | .sum(); | ||
| 153 | if kept > BUDGET { | ||
| 154 | let mut spare: Vec<_> = pictures | ||
| 155 | .iter_mut() | ||
| 156 | .filter(|(id, picture)| { | ||
| 157 | picture.shown.as_ref().is_some_and(|image| { | ||
| 158 | wanted | ||
| 159 | .get(*id) | ||
| 160 | .is_none_or(|(size, _)| image.size() != *size) | ||
| 161 | }) | ||
| 162 | }) | ||
| 163 | .collect(); | ||
| 164 | spare.sort_by_key(|(id, picture)| (wanted.contains_key(*id), picture.seen)); | ||
| 165 | for (_, picture) in spare { | ||
| 166 | if kept <= BUDGET { | ||
| 167 | break; | ||
| 168 | } | ||
| 169 | kept -= picture.shown.take().map_or(0, |image| bytes(image.size())); | ||
| 170 | } | ||
| 171 | } | ||
| 172 | settled | ||
| 173 | } | ||
crates/canvas/src/gpu/profile.rs+1| ... | @@ -323,6 +323,7 @@ fn frame_cost() { | ... | @@ -323,6 +323,7 @@ fn frame_cost() { |
| 323 | } | 323 | } |
| 324 | let input = start.elapsed(); | 324 | let input = start.elapsed(); |
| 325 | let start = Instant::now(); | 325 | let start = Instant::now(); |
| 326 | view.update_pictures(colors.paper, std::task::Waker::noop()); | ||
| 326 | let primitives = view.primitives(colors).unwrap(); | 327 | let primitives = view.primitives(colors).unwrap(); |
| 327 | let built = start.elapsed(); | 328 | let built = start.elapsed(); |
| 328 | let start = Instant::now(); | 329 | let start = Instant::now(); |
crates/canvas/src/interaction/accessibility.rs+35| ... | @@ -1711,4 +1711,39 @@ pub(super) mod tests { | ... | @@ -1711,4 +1711,39 @@ pub(super) mod tests { |
| 1711 | assert!(access.selection(second, selection).is_err()); | 1711 | assert!(access.selection(second, selection).is_err()); |
| 1712 | assert_eq!(access.outline_for_node(fields[0].0), Some(first)); | 1712 | assert_eq!(access.outline_for_node(fields[0].0), Some(first)); |
| 1713 | } | 1713 | } |
| 1714 | |||
| 1715 | #[test] | ||
| 1716 | fn the_end_of_a_wrapped_rtl_word_stays_on_its_line() { | ||
| 1717 | let mut engine = TextEngine::default(); | ||
| 1718 | let text = "Fictitious: café, 東京, مرحبا"; | ||
| 1719 | let end = text.encode_utf16().count() as u32; | ||
| 1720 | let mut wrapped = false; | ||
| 1721 | for width in [60.0, 90.0, 120.0, 480.0] { | ||
| 1722 | let document = | ||
| 1723 | TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(); | ||
| 1724 | let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap(); | ||
| 1725 | let caret = |editor: &mut CanvasEditor, offset| { | ||
| 1726 | let position = TextPosition { | ||
| 1727 | paragraph: 0, | ||
| 1728 | offset, | ||
| 1729 | }; | ||
| 1730 | editor.select([position; 2].into()).unwrap(); | ||
| 1731 | editor.caret(0.0).unwrap() | ||
| 1732 | }; | ||
| 1733 | let inside = caret(&mut editor, end - 2); | ||
| 1734 | let first = caret(&mut editor, 0); | ||
| 1735 | wrapped |= inside.y0 > first.y0; | ||
| 1736 | assert_eq!(caret(&mut editor, end).y0, inside.y0, "width {width}"); | ||
| 1737 | editor.select_all().unwrap(); | ||
| 1738 | let viewport = Viewport { | ||
| 1739 | size: [800, 600], | ||
| 1740 | origin: [0.0; 2], | ||
| 1741 | scale: 1.0, | ||
| 1742 | }; | ||
| 1743 | Accessibility::default() | ||
| 1744 | .update(&editor, None, viewport, "Test", None, None) | ||
| 1745 | .unwrap(); | ||
| 1746 | } | ||
| 1747 | assert!(wrapped); | ||
| 1748 | } | ||
| 1714 | } | 1749 | } |
crates/canvas/src/interaction/mod.rs+191-69| ... | @@ -101,11 +101,11 @@ pub struct TextColors { | ... | @@ -101,11 +101,11 @@ pub struct TextColors { |
| 101 | pub paper: Paper, | 101 | pub paper: Paper, |
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | /// How to paint an outline: whether the caret is in its blink-on phase, the view scale, | 104 | /// How to paint an outline: the caret's opacity, the view scale, document points per |
| 105 | /// document points per device pixel, and the text colours. | 105 | /// logical pixel, and the text colours. |
| 106 | #[derive(Clone, Copy)] | 106 | #[derive(Clone, Copy)] |
| 107 | struct Paint { | 107 | struct Paint { |
| 108 | show_caret: bool, | 108 | caret: f32, |
| 109 | scale: f32, | 109 | scale: f32, |
| 110 | pixel: f32, | 110 | pixel: f32, |
| 111 | colors: TextColors, | 111 | colors: TextColors, |
| ... | @@ -186,15 +186,35 @@ pub struct PageView { | ... | @@ -186,15 +186,35 @@ pub struct PageView { |
| 186 | object_focus: Option<ObjectFocus>, | 186 | object_focus: Option<ObjectFocus>, |
| 187 | modifiers: Modifiers, | 187 | modifiers: Modifiers, |
| 188 | focused: bool, | 188 | focused: bool, |
| 189 | caret: bool, | 189 | /// The caret's opacity in its blink. |
| 190 | blink_at: Instant, | 190 | caret: f32, |
| 191 | /// When the caret last moved, which restarts its blink. | ||
| 192 | blink_from: Instant, | ||
| 191 | } | 193 | } |
| 192 | 194 | ||
| 195 | /// Room, in OneNote pixels, the view leaves beyond content it scrolls to. | ||
| 196 | const PAD: f32 = 11.0; | ||
| 197 | |||
| 193 | /// Where a page's origin sits in a view that has not scrolled, in device pixels. | 198 | /// Where a page's origin sits in a view that has not scrolled, in device pixels. |
| 194 | fn home(display_scale: f32) -> [f32; 2] { | 199 | fn home(display_scale: f32) -> [f32; 2] { |
| 195 | [48.0 * display_scale; 2] | 200 | [48.0 * display_scale; 2] |
| 196 | } | 201 | } |
| 197 | 202 | ||
| 203 | /// Brings `scene`, drawn at `offset` in the view, to what `viewport` shows. | ||
| 204 | fn update_pictures( | ||
| 205 | scene: &mut PageScene, | ||
| 206 | [x, y]: [f32; 2], | ||
| 207 | editor: &CanvasEditor, | ||
| 208 | viewport: Viewport, | ||
| 209 | paper: Paper, | ||
| 210 | waker: &std::task::Waker, | ||
| 211 | ) -> bool { | ||
| 212 | let [x0, y0] = viewport.document_point([0.0; 2]); | ||
| 213 | let [x1, y1] = viewport.document_point(viewport.size.map(|side| side as f32)); | ||
| 214 | let view = [x0 - x, y0 - y, x1 - x, y1 - y]; | ||
| 215 | scene.update_pictures(Some(editor), view, viewport.scale, paper, waker) | ||
| 216 | } | ||
| 217 | |||
| 198 | impl PageView { | 218 | impl PageView { |
| 199 | /// `size` is in device pixels; `double_click` is the platform's double-click interval. | 219 | /// `size` is in device pixels; `double_click` is the platform's double-click interval. |
| 200 | pub fn new( | 220 | pub fn new( |
| ... | @@ -205,7 +225,7 @@ impl PageView { | ... | @@ -205,7 +225,7 @@ impl PageView { |
| 205 | display_scale: f32, | 225 | display_scale: f32, |
| 206 | double_click: Duration, | 226 | double_click: Duration, |
| 207 | ) -> Self { | 227 | ) -> Self { |
| 208 | Self { | 228 | let mut view = Self { |
| 209 | editor, | 229 | editor, |
| 210 | engine, | 230 | engine, |
| 211 | scene, | 231 | scene, |
| ... | @@ -222,9 +242,11 @@ impl PageView { | ... | @@ -222,9 +242,11 @@ impl PageView { |
| 222 | object_focus: None, | 242 | object_focus: None, |
| 223 | modifiers: Modifiers::default(), | 243 | modifiers: Modifiers::default(), |
| 224 | focused: true, | 244 | focused: true, |
| 225 | caret: true, | 245 | caret: 1.0, |
| 226 | blink_at: Instant::now() + Duration::from_millis(500), | 246 | blink_from: Instant::now(), |
| 227 | } | 247 | }; |
| 248 | view.place_opened(); | ||
| 249 | view | ||
| 228 | } | 250 | } |
| 229 | 251 | ||
| 230 | /// Shows a page reloaded from storage in place of the edited one. | 252 | /// Shows a page reloaded from storage in place of the edited one. |
| ... | @@ -235,11 +257,28 @@ impl PageView { | ... | @@ -235,11 +257,28 @@ impl PageView { |
| 235 | self.object_focus = None; | 257 | self.object_focus = None; |
| 236 | } | 258 | } |
| 237 | 259 | ||
| 238 | /// Shows another page from its top-left corner, keeping the zoom. | 260 | /// Shows another page as OneNote opens one, keeping the zoom. |
| 239 | pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { | 261 | pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { |
| 240 | self.replace(editor, scene); | 262 | self.replace(editor, scene); |
| 241 | self.viewport.origin = home(self.display_scale); | 263 | self.place_opened(); |
| 242 | self.scroll().clamp(&mut self.viewport); | 264 | } |
| 265 | |||
| 266 | /// OneNote 2010 opens a page scrolled fully left and up, then down just far enough to | ||
| 267 | /// show the caret's outline 9 pt and 9 px above the view's bottom. | ||
| 268 | fn place_opened(&mut self) { | ||
| 269 | let scroll = self.scroll(); | ||
| 270 | self.viewport.origin = scroll.min.map(|offset| -offset); | ||
| 271 | let outline = self.editor.active_outline(); | ||
| 272 | let offset = self | ||
| 273 | .scene | ||
| 274 | .as_ref() | ||
| 275 | .filter(|_| self.editor.has_page_outline(outline.id)) | ||
| 276 | .map_or(0.0, |(_, offset)| offset[1]); | ||
| 277 | let bottom = (outline.bounds().y1 as f32 + offset + 9.0) * self.viewport.scale | ||
| 278 | + 9.0 * self.display_scale | ||
| 279 | + self.viewport.origin[1]; | ||
| 280 | self.viewport.origin[1] -= (bottom - self.viewport.size[1] as f32).max(0.0); | ||
| 281 | scroll.clamp(&mut self.viewport); | ||
| 243 | } | 282 | } |
| 244 | 283 | ||
| 245 | pub fn modifiers(&self) -> Modifiers { | 284 | pub fn modifiers(&self) -> Modifiers { |
| ... | @@ -364,8 +403,8 @@ impl PageView { | ... | @@ -364,8 +403,8 @@ impl PageView { |
| 364 | /// Keeps the view in bounds and restarts the caret blink after a change. | 403 | /// Keeps the view in bounds and restarts the caret blink after a change. |
| 365 | fn changed(&mut self) -> Result<Response> { | 404 | fn changed(&mut self) -> Result<Response> { |
| 366 | self.scroll().clamp(&mut self.viewport); | 405 | self.scroll().clamp(&mut self.viewport); |
| 367 | self.caret = true; | 406 | self.caret = 1.0; |
| 368 | self.blink_at = Instant::now() + Duration::from_millis(500); | 407 | self.blink_from = Instant::now(); |
| 369 | Ok(Response::changed()) | 408 | Ok(Response::changed()) |
| 370 | } | 409 | } |
| 371 | 410 | ||
| ... | @@ -492,9 +531,10 @@ impl PageView { | ... | @@ -492,9 +531,10 @@ impl PageView { |
| 492 | .filter(|_| self.editor.has_page_outline(outline.id)) | 531 | .filter(|_| self.editor.has_page_outline(outline.id)) |
| 493 | .map(|(_, offset)| *offset) | 532 | .map(|(_, offset)| *offset) |
| 494 | .unwrap_or([0.0; 2]); | 533 | .unwrap_or([0.0; 2]); |
| 534 | let [left, top] = reach(outline); | ||
| 495 | [ | 535 | [ |
| 496 | rect.x0 as f32 + offset[0], | 536 | left + offset[0], |
| 497 | rect.y0 as f32 + offset[1], | 537 | top + offset[1], |
| 498 | rect.x1 as f32 + offset[0], | 538 | rect.x1 as f32 + offset[0], |
| 499 | rect.y1 as f32 + offset[1], | 539 | rect.y1 as f32 + offset[1], |
| 500 | ] | 540 | ] |
| ... | @@ -509,7 +549,11 @@ impl PageView { | ... | @@ -509,7 +549,11 @@ impl PageView { |
| 509 | ] | 549 | ] |
| 510 | }) | 550 | }) |
| 511 | }); | 551 | }); |
| 512 | scroll::Scroll::new(self.viewport, editable.chain(fixed)) | 552 | scroll::Scroll::new( |
| 553 | self.viewport, | ||
| 554 | PAD * self.display_scale, | ||
| 555 | editable.chain(fixed), | ||
| 556 | ) | ||
| 513 | } | 557 | } |
| 514 | 558 | ||
| 515 | /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`, | 559 | /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`, |
| ... | @@ -519,15 +563,31 @@ impl PageView { | ... | @@ -519,15 +563,31 @@ impl PageView { |
| 519 | self.moved() | 563 | self.moved() |
| 520 | } | 564 | } |
| 521 | 565 | ||
| 522 | /// Brings the page's template backgrounds to `paper` and the current zoom before a | 566 | /// Brings the page's template backgrounds to `paper` and its pictures to the view and |
| 523 | /// frame's primitives; `waker` is woken off the main thread when the view should be | 567 | /// zoom before a frame's primitives; `waker` is woken off the main thread when the view |
| 524 | /// drawn again because a background raster landed. | 568 | /// should be drawn again because a raster landed. |
| 525 | pub fn update_backgrounds(&mut self, paper: Paper, waker: &std::task::Waker) { | 569 | pub fn update_pictures(&mut self, paper: Paper, waker: &std::task::Waker) { |
| 526 | if let Some((scene, _)) = &mut self.scene { | 570 | if let Some((scene, offset)) = &mut self.scene { |
| 527 | scene.update_backgrounds(paper, self.viewport.scale, waker); | 571 | update_pictures(scene, *offset, &self.editor, self.viewport, paper, waker); |
| 528 | } | 572 | } |
| 529 | } | 573 | } |
| 530 | 574 | ||
| 575 | /// `update_pictures` for a page `open` is about to show at `offset`; true once the | ||
| 576 | /// pictures it would show at first are drawn. | ||
| 577 | pub fn prepare( | ||
| 578 | &self, | ||
| 579 | (scene, offset): &mut (PageScene, [f32; 2]), | ||
| 580 | editor: &CanvasEditor, | ||
| 581 | paper: Paper, | ||
| 582 | waker: &std::task::Waker, | ||
| 583 | ) -> bool { | ||
| 584 | let viewport = Viewport { | ||
| 585 | origin: home(self.display_scale), | ||
| 586 | ..self.viewport | ||
| 587 | }; | ||
| 588 | update_pictures(scene, *offset, editor, viewport, paper, waker) | ||
| 589 | } | ||
| 590 | |||
| 531 | /// Everything to draw this frame. | 591 | /// Everything to draw this frame. |
| 532 | pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> { | 592 | pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> { |
| 533 | let preview = match &self.drag { | 593 | let preview = match &self.drag { |
| ... | @@ -565,12 +625,15 @@ impl PageView { | ... | @@ -565,12 +625,15 @@ impl PageView { |
| 565 | preview, | 625 | preview, |
| 566 | self.object_focus, | 626 | self.object_focus, |
| 567 | Paint { | 627 | Paint { |
| 568 | show_caret: self.caret | 628 | caret: if self.focused |
| 569 | && self.focused | ||
| 570 | && !matches!( | 629 | && !matches!( |
| 571 | self.drag, | 630 | self.drag, |
| 572 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | 631 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) |
| 573 | ), | 632 | ) { |
| 633 | self.caret | ||
| 634 | } else { | ||
| 635 | 0.0 | ||
| 636 | }, | ||
| 574 | scale: self.viewport.scale, | 637 | scale: self.viewport.scale, |
| 575 | pixel: self.pixel(), | 638 | pixel: self.pixel(), |
| 576 | colors, | 639 | colors, |
| ... | @@ -589,7 +652,7 @@ impl PageView { | ... | @@ -589,7 +652,7 @@ impl PageView { |
| 589 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, | 652 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, |
| 590 | (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, | 653 | (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, |
| 591 | (None, Some(Hit::Date(_))) => Cursor::Pointer, | 654 | (None, Some(Hit::Date(_))) => Cursor::Pointer, |
| 592 | (None, Some(Hit::ReadOnly(_))) => Cursor::Default, | 655 | (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default, |
| 593 | _ => Cursor::Text, | 656 | _ => Cursor::Text, |
| 594 | } | 657 | } |
| 595 | } | 658 | } |
| ... | @@ -600,12 +663,10 @@ impl PageView { | ... | @@ -600,12 +663,10 @@ impl PageView { |
| 600 | if !(self.focused && self.object_focus.is_none() && anchor == focus) { | 663 | if !(self.focused && self.object_focus.is_none() && anchor == focus) { |
| 601 | return (false, None); | 664 | return (false, None); |
| 602 | } | 665 | } |
| 603 | let repaint = now >= self.blink_at; | 666 | let (caret, hold) = edit::caret_blink(now.saturating_duration_since(self.blink_from)); |
| 604 | if repaint { | 667 | let repaint = caret != self.caret; |
| 605 | self.caret = !self.caret; | 668 | self.caret = caret; |
| 606 | self.blink_at = now + Duration::from_millis(500); | 669 | (repaint, Some(now + hold)) |
| 607 | } | ||
| 608 | (repaint, Some(self.blink_at)) | ||
| 609 | } | 670 | } |
| 610 | 671 | ||
| 611 | /// `size` in device pixels. | 672 | /// `size` in device pixels. |
| ... | @@ -710,6 +771,13 @@ impl PageView { | ... | @@ -710,6 +771,13 @@ impl PageView { |
| 710 | return Ok(Response::request(Request::EditDate(field))); | 771 | return Ok(Response::request(Request::EditDate(field))); |
| 711 | } | 772 | } |
| 712 | Some(Hit::ReadOnly(index)) => self.set_object_focus(Some(ObjectFocus::ReadOnly(index))), | 773 | Some(Hit::ReadOnly(index)) => self.set_object_focus(Some(ObjectFocus::ReadOnly(index))), |
| 774 | Some(Hit::Check { outline, paragraph }) => { | ||
| 775 | self.set_object_focus(None); | ||
| 776 | self.drag = None; | ||
| 777 | self.editor | ||
| 778 | .click_check(&mut self.engine, outline, paragraph)?; | ||
| 779 | return self.changed(); | ||
| 780 | } | ||
| 713 | Some(Hit::Image { id, handle }) => { | 781 | Some(Hit::Image { id, handle }) => { |
| 714 | self.set_object_focus(Some(ObjectFocus::Image(id))); | 782 | self.set_object_focus(Some(ObjectFocus::Image(id))); |
| 715 | self.drag = Some(Drag::Image { | 783 | self.drag = Some(Drag::Image { |
| ... | @@ -828,12 +896,12 @@ impl PageView { | ... | @@ -828,12 +896,12 @@ impl PageView { |
| 828 | self.edited() | 896 | self.edited() |
| 829 | } | 897 | } |
| 830 | 898 | ||
| 831 | /// Clipboard text; see [`CanvasEditor::paste`]. | 899 | /// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`]. |
| 832 | pub fn paste(&mut self, text: &str) -> Result<Response> { | 900 | pub fn paste(&mut self, text: &str, language: u32) -> Result<Response> { |
| 833 | if !self.accepts_text() { | 901 | if !self.accepts_text() { |
| 834 | return Ok(Response::default()); | 902 | return Ok(Response::default()); |
| 835 | } | 903 | } |
| 836 | self.editor.paste(&mut self.engine, text)?; | 904 | self.editor.paste(&mut self.engine, text, language)?; |
| 837 | self.edited() | 905 | self.edited() |
| 838 | } | 906 | } |
| 839 | 907 | ||
| ... | @@ -905,6 +973,9 @@ impl PageView { | ... | @@ -905,6 +973,9 @@ impl PageView { |
| 905 | option, | 973 | option, |
| 906 | command, | 974 | command, |
| 907 | } = self.modifiers; | 975 | } = self.modifiers; |
| 976 | if key == &Key::Named(NamedKey::Modifier) { | ||
| 977 | return Ok(Response::default()); | ||
| 978 | } | ||
| 908 | if let Some(ObjectFocus::Image(id)) = self.object_focus | 979 | if let Some(ObjectFocus::Image(id)) = self.object_focus |
| 909 | && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) | 980 | && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) |
| 910 | { | 981 | { |
| ... | @@ -946,9 +1017,6 @@ impl PageView { | ... | @@ -946,9 +1017,6 @@ impl PageView { |
| 946 | self.drag, | 1017 | self.drag, |
| 947 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | 1018 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) |
| 948 | ) { | 1019 | ) { |
| 949 | if key == &Key::Named(NamedKey::Modifier) { | ||
| 950 | return Ok(Response::default()); | ||
| 951 | } | ||
| 952 | self.drag = None; | 1020 | self.drag = None; |
| 953 | if key == &Key::Named(NamedKey::Escape) { | 1021 | if key == &Key::Named(NamedKey::Escape) { |
| 954 | return self.changed(); | 1022 | return self.changed(); |
| ... | @@ -1148,6 +1216,11 @@ pub enum Hit { | ... | @@ -1148,6 +1216,11 @@ pub enum Hit { |
| 1148 | grab: [f32; 2], | 1216 | grab: [f32; 2], |
| 1149 | }, | 1217 | }, |
| 1150 | ReadOnly(usize), | 1218 | ReadOnly(usize), |
| 1219 | /// A check box tag on paragraph `paragraph`. | ||
| 1220 | Check { | ||
| 1221 | outline: onestore::ExGuid, | ||
| 1222 | paragraph: onestore::ExGuid, | ||
| 1223 | }, | ||
| 1151 | /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles. | 1224 | /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles. |
| 1152 | Image { | 1225 | Image { |
| 1153 | id: onestore::ExGuid, | 1226 | id: onestore::ExGuid, |
| ... | @@ -1211,6 +1284,23 @@ pub fn page_hit_test( | ... | @@ -1211,6 +1284,23 @@ pub fn page_hit_test( |
| 1211 | ], | 1284 | ], |
| 1212 | }); | 1285 | }); |
| 1213 | } | 1286 | } |
| 1287 | let shaped = outline.shaped(); | ||
| 1288 | let [left, top] = outline.origin(); | ||
| 1289 | let check = shaped.paragraphs.iter().find(|paragraph| { | ||
| 1290 | paragraph.tags.iter().any(|tag| { | ||
| 1291 | let tag_x = left + shaped.tag_column_offset() + tag.origin[0]; | ||
| 1292 | let tag_y = top + paragraph.origin[1] + tag.origin[1]; | ||
| 1293 | matches!(tag.icon, crate::outline::TagIcon::CheckBox { .. }) | ||
| 1294 | && (tag_x..=tag_x + tag.size).contains(&x) | ||
| 1295 | && (tag_y..=tag_y + tag.size).contains(&y) | ||
| 1296 | }) | ||
| 1297 | }); | ||
| 1298 | if let Some(paragraph) = check { | ||
| 1299 | return Some(Hit::Check { | ||
| 1300 | outline: outline.id, | ||
| 1301 | paragraph: paragraph.id, | ||
| 1302 | }); | ||
| 1303 | } | ||
| 1214 | let text = outline.bounds(); | 1304 | let text = outline.bounds(); |
| 1215 | if !(text.x0..=text.x1).contains(&f64::from(x)) | 1305 | if !(text.x0..=text.x1).contains(&f64::from(x)) |
| 1216 | || !(text.y0..=text.y1).contains(&f64::from(y)) | 1306 | || !(text.y0..=text.y1).contains(&f64::from(y)) |
| ... | @@ -1229,17 +1319,7 @@ pub fn page_hit_test( | ... | @@ -1229,17 +1319,7 @@ pub fn page_hit_test( |
| 1229 | handle: [0, 0], | 1319 | handle: [0, 0], |
| 1230 | }); | 1320 | }); |
| 1231 | } | 1321 | } |
| 1232 | if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3]) | 1322 | if x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3] { |
| 1233 | || outline.layouts().any(|(_, paragraph)| { | ||
| 1234 | paragraph.tags.iter().any(|tag| { | ||
| 1235 | let origin = outline.origin(); | ||
| 1236 | let x = origin[0] + outline.shaped().tag_column_offset() + tag.origin[0]; | ||
| 1237 | let y = origin[1] + paragraph.origin[1] + tag.origin[1]; | ||
| 1238 | let size = crate::outline::ParagraphTag::SIZE; | ||
| 1239 | (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1]) | ||
| 1240 | }) | ||
| 1241 | }) | ||
| 1242 | { | ||
| 1243 | return Some(Hit::Text { | 1323 | return Some(Hit::Text { |
| 1244 | id: outline.id, | 1324 | id: outline.id, |
| 1245 | point: [ | 1325 | point: [ |
| ... | @@ -1290,10 +1370,10 @@ fn page_primitives<'a>( | ... | @@ -1290,10 +1370,10 @@ fn page_primitives<'a>( |
| 1290 | ) -> Result<Vec<Primitive<'a>>> { | 1370 | ) -> Result<Vec<Primitive<'a>>> { |
| 1291 | let mut primitives = Vec::new(); | 1371 | let mut primitives = Vec::new(); |
| 1292 | let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| { | 1372 | let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| { |
| 1293 | let outline = editor | 1373 | // An emptied page outline leaves the editor but keeps its paint slot for undo. |
| 1294 | .visible_outlines() | 1374 | let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else { |
| 1295 | .find(|outline| outline.id == id) | 1375 | return Ok(()); |
| 1296 | .ok_or(crate::gpu::page::SceneError::MissingOutline)?; | 1376 | }; |
| 1297 | let outline = match preview { | 1377 | let outline = match preview { |
| 1298 | Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized, | 1378 | Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized, |
| 1299 | _ => outline, | 1379 | _ => outline, |
| ... | @@ -1310,6 +1390,7 @@ fn page_primitives<'a>( | ... | @@ -1310,6 +1390,7 @@ fn page_primitives<'a>( |
| 1310 | outline, | 1390 | outline, |
| 1311 | [origin[0] + offset[0], origin[1] + offset[1]], | 1391 | [origin[0] + offset[0], origin[1] + offset[1]], |
| 1312 | paint.pixel, | 1392 | paint.pixel, |
| 1393 | paint.colors.paper, | ||
| 1313 | primitives, | 1394 | primitives, |
| 1314 | ); | 1395 | ); |
| 1315 | } | 1396 | } |
| ... | @@ -1531,6 +1612,29 @@ fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec<Primitiv | ... | @@ -1531,6 +1612,29 @@ fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec<Primitiv |
| 1531 | } | 1612 | } |
| 1532 | } | 1613 | } |
| 1533 | 1614 | ||
| 1615 | /// How far left and up OneNote 2010 scrolls to show an outline: 7.5 pt past its text, to | ||
| 1616 | /// its list markers, 0.75 pt into its tag column's first slot, and 6 pt above it. | ||
| 1617 | fn reach(outline: &TextOutline) -> [f32; 2] { | ||
| 1618 | let shaped = outline.shaped(); | ||
| 1619 | let column = shaped.tag_column_offset(); | ||
| 1620 | let left = shaped | ||
| 1621 | .paragraphs | ||
| 1622 | .iter() | ||
| 1623 | .flat_map(|paragraph| { | ||
| 1624 | let markers = paragraph.markers.iter().map(|(_, [x, _])| *x); | ||
| 1625 | let tags = paragraph | ||
| 1626 | .tags | ||
| 1627 | .iter() | ||
| 1628 | .map(move |tag| tag.origin[0] + column + 0.75); | ||
| 1629 | std::iter::once(paragraph.origin[0] - 7.5) | ||
| 1630 | .chain(markers) | ||
| 1631 | .chain(tags) | ||
| 1632 | }) | ||
| 1633 | .fold(-7.5, f32::min); | ||
| 1634 | let [x, y] = outline.origin(); | ||
| 1635 | [x + left, y - 6.0] | ||
| 1636 | } | ||
| 1637 | |||
| 1534 | fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { | 1638 | fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { |
| 1535 | std::array::from_fn(|axis| { | 1639 | std::array::from_fn(|axis| { |
| 1536 | let offset = margin[axis]; | 1640 | let offset = margin[axis]; |
| ... | @@ -1563,9 +1667,23 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { | ... | @@ -1563,9 +1667,23 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { |
| 1563 | body_top, | 1667 | body_top, |
| 1564 | ); | 1668 | ); |
| 1565 | } | 1669 | } |
| 1670 | // OneNote widens the box leftward to hold the tag column and list markers; text and right | ||
| 1671 | // edge stay. | ||
| 1672 | let shaped = outline.shaped(); | ||
| 1673 | let column = shaped.tag_column_offset() + crate::outline::ParagraphTag::INSET | ||
| 1674 | - crate::outline::ParagraphTag::SIZE; | ||
| 1675 | let left = shaped | ||
| 1676 | .paragraphs | ||
| 1677 | .iter() | ||
| 1678 | .flat_map(|paragraph| { | ||
| 1679 | let tags = paragraph.tags.iter().map(|tag| tag.origin[0] + column); | ||
| 1680 | let markers = paragraph.markers.iter().map(|(_, [x, _])| x + 7.5); | ||
| 1681 | tags.chain(markers) | ||
| 1682 | }) | ||
| 1683 | .fold(0.0, f32::min); | ||
| 1566 | ( | 1684 | ( |
| 1567 | [ | 1685 | [ |
| 1568 | bounds.x0 as f32 - 7.5 - inset, | 1686 | bounds.x0 as f32 + left - 7.5 - inset, |
| 1569 | body_top - HANDLE_HEIGHT, | 1687 | body_top - HANDLE_HEIGHT, |
| 1570 | bounds.x1 as f32 + inset, | 1688 | bounds.x1 as f32 + inset, |
| 1571 | bounds.y1 as f32 + HANDLE_HEIGHT + inset, | 1689 | bounds.y1 as f32 + HANDLE_HEIGHT + inset, |
| ... | @@ -1578,6 +1696,7 @@ fn append_outline_chrome( | ... | @@ -1578,6 +1696,7 @@ fn append_outline_chrome( |
| 1578 | outline: &TextOutline, | 1696 | outline: &TextOutline, |
| 1579 | origin: [f32; 2], | 1697 | origin: [f32; 2], |
| 1580 | pixel: f32, | 1698 | pixel: f32, |
| 1699 | paper: crate::gpu::Paper, | ||
| 1581 | primitives: &mut Vec<Primitive<'_>>, | 1700 | primitives: &mut Vec<Primitive<'_>>, |
| 1582 | ) { | 1701 | ) { |
| 1583 | let [x, y] = origin; | 1702 | let [x, y] = origin; |
| ... | @@ -1595,7 +1714,7 @@ fn append_outline_chrome( | ... | @@ -1595,7 +1714,7 @@ fn append_outline_chrome( |
| 1595 | rect: [left, top, right, bottom], | 1714 | rect: [left, top, right, bottom], |
| 1596 | radius: [6.0 * pixel, (bottom - top) * 0.5], | 1715 | radius: [6.0 * pixel, (bottom - top) * 0.5], |
| 1597 | stroke: Some(Stroke::Dashed(pixel)), | 1716 | stroke: Some(Stroke::Dashed(pixel)), |
| 1598 | color: crate::gpu::colorref(0x007f7f7f), | 1717 | color: paper.shade(crate::gpu::colorref(0x007f7f7f)), |
| 1599 | }); | 1718 | }); |
| 1600 | return; | 1719 | return; |
| 1601 | } | 1720 | } |
| ... | @@ -1603,19 +1722,19 @@ fn append_outline_chrome( | ... | @@ -1603,19 +1722,19 @@ fn append_outline_chrome( |
| 1603 | rect: [left, top, right, body_top], | 1722 | rect: [left, top, right, body_top], |
| 1604 | radius: [3.0 * pixel; 2], | 1723 | radius: [3.0 * pixel; 2], |
| 1605 | stroke: None, | 1724 | stroke: None, |
| 1606 | color: crate::gpu::colorref(0x00e8ebed), | 1725 | color: paper.shade(crate::gpu::colorref(0x00e8ebed)), |
| 1607 | }); | 1726 | }); |
| 1608 | primitives.push(Primitive::RoundedRect { | 1727 | primitives.push(Primitive::RoundedRect { |
| 1609 | rect: [right - 9.0 * pixel, top, right, body_top], | 1728 | rect: [right - 9.0 * pixel, top, right, body_top], |
| 1610 | radius: [3.0 * pixel; 2], | 1729 | radius: [3.0 * pixel; 2], |
| 1611 | stroke: None, | 1730 | stroke: None, |
| 1612 | color: crate::gpu::colorref(0x00e5dee7), | 1731 | color: paper.shade(crate::gpu::colorref(0x00e5dee7)), |
| 1613 | }); | 1732 | }); |
| 1614 | primitives.push(Primitive::RoundedRect { | 1733 | primitives.push(Primitive::RoundedRect { |
| 1615 | rect: [left, top, right, bottom], | 1734 | rect: [left, top, right, bottom], |
| 1616 | radius: [3.0 * pixel; 2], | 1735 | radius: [3.0 * pixel; 2], |
| 1617 | stroke: Some(Stroke::Solid(pixel)), | 1736 | stroke: Some(Stroke::Solid(pixel)), |
| 1618 | color: crate::gpu::colorref(0x00d9cfd8), | 1737 | color: paper.shade(crate::gpu::colorref(0x00d9cfd8)), |
| 1619 | }); | 1738 | }); |
| 1620 | let middle = (top + body_top) * 0.5; | 1739 | let middle = (top + body_top) * 0.5; |
| 1621 | for offset in [-3.0, 0.0, 3.0] { | 1740 | for offset in [-3.0, 0.0, 3.0] { |
| ... | @@ -1629,7 +1748,7 @@ fn append_outline_chrome( | ... | @@ -1629,7 +1748,7 @@ fn append_outline_chrome( |
| 1629 | ], | 1748 | ], |
| 1630 | radius: [pixel * 0.5; 2], | 1749 | radius: [pixel * 0.5; 2], |
| 1631 | stroke: None, | 1750 | stroke: None, |
| 1632 | color: crate::gpu::colorref(0x00b4a5b4), | 1751 | color: paper.shade(crate::gpu::colorref(0x00b4a5b4)), |
| 1633 | }); | 1752 | }); |
| 1634 | } | 1753 | } |
| 1635 | for column in [0.0, 1.0, 2.0] { | 1754 | for column in [0.0, 1.0, 2.0] { |
| ... | @@ -1640,7 +1759,7 @@ fn append_outline_chrome( | ... | @@ -1640,7 +1759,7 @@ fn append_outline_chrome( |
| 1640 | ] { | 1759 | ] { |
| 1641 | primitives.push(Primitive::Rect { | 1760 | primitives.push(Primitive::Rect { |
| 1642 | rect: [x, middle - half, x + pixel, middle + half], | 1761 | rect: [x, middle - half, x + pixel, middle + half], |
| 1643 | color: crate::gpu::colorref(0x00b4a5b4), | 1762 | color: paper.shade(crate::gpu::colorref(0x00b4a5b4)), |
| 1644 | }); | 1763 | }); |
| 1645 | } | 1764 | } |
| 1646 | } | 1765 | } |
| ... | @@ -1655,7 +1774,7 @@ fn append_outline<'a>( | ... | @@ -1655,7 +1774,7 @@ fn append_outline<'a>( |
| 1655 | ) -> Result<()> { | 1774 | ) -> Result<()> { |
| 1656 | let [x, y] = origin; | 1775 | let [x, y] = origin; |
| 1657 | let Paint { | 1776 | let Paint { |
| 1658 | show_caret, | 1777 | caret, |
| 1659 | scale, | 1778 | scale, |
| 1660 | pixel, | 1779 | pixel, |
| 1661 | colors, | 1780 | colors, |
| ... | @@ -1667,7 +1786,7 @@ fn append_outline<'a>( | ... | @@ -1667,7 +1786,7 @@ fn append_outline<'a>( |
| 1667 | .append_table_primitives(primitives, origin, colors.paper); | 1786 | .append_table_primitives(primitives, origin, colors.paper); |
| 1668 | outline | 1787 | outline |
| 1669 | .shaped() | 1788 | .shaped() |
| 1670 | .append_background_primitives(primitives, origin, rows); | 1789 | .append_background_primitives(primitives, origin, rows, colors.paper); |
| 1671 | if let Some(editor) = editor { | 1790 | if let Some(editor) = editor { |
| 1672 | for rect in editor.selection_rects()? { | 1791 | for rect in editor.selection_rects()? { |
| 1673 | primitives.push(Primitive::Rect { | 1792 | primitives.push(Primitive::Rect { |
| ... | @@ -1703,18 +1822,21 @@ fn append_outline<'a>( | ... | @@ -1703,18 +1822,21 @@ fn append_outline<'a>( |
| 1703 | }); | 1822 | }); |
| 1704 | } | 1823 | } |
| 1705 | let [anchor, focus] = editor.selection().positions; | 1824 | let [anchor, focus] = editor.selection().positions; |
| 1706 | if show_caret && anchor == focus { | 1825 | if caret > 0.0 && anchor == focus { |
| 1707 | let rect = editor.caret(2.0 * pixel)?; | 1826 | // AppKit centres the caret on the insertion point. |
| 1827 | let rect = editor.caret(0.0)?; | ||
| 1828 | let half = edit::CARET_WIDTH / 2.0 * pixel; | ||
| 1829 | let middle = rect.x0 as f32 + x; | ||
| 1708 | primitives.push(Primitive::RoundedRect { | 1830 | primitives.push(Primitive::RoundedRect { |
| 1709 | rect: [ | 1831 | rect: [ |
| 1710 | rect.x0 as f32 + x, | 1832 | middle - half, |
| 1711 | rect.y0 as f32 + y, | 1833 | rect.y0 as f32 + y, |
| 1712 | rect.x1 as f32 + x, | 1834 | middle + half, |
| 1713 | rect.y1 as f32 + y, | 1835 | rect.y1 as f32 + y, |
| 1714 | ], | 1836 | ], |
| 1715 | radius: [pixel; 2], | 1837 | radius: [half; 2], |
| 1716 | stroke: None, | 1838 | stroke: None, |
| 1717 | color: colors.caret, | 1839 | color: edit::caret_color(colors.caret, colors.paper.color, caret), |
| 1718 | }); | 1840 | }); |
| 1719 | } | 1841 | } |
| 1720 | } | 1842 | } |
crates/canvas/src/interaction/scroll.rs+7-6| ... | @@ -9,8 +9,9 @@ pub struct Scroll { | ... | @@ -9,8 +9,9 @@ pub struct Scroll { |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | impl Scroll { | 11 | impl Scroll { |
| 12 | pub fn new(viewport: Viewport, bounds: impl Iterator<Item = [f32; 4]>) -> Self { | 12 | /// `pad` is the room, in device pixels, OneNote leaves beyond content it scrolls to. |
| 13 | let mut min = [0.0_f32; 2]; | 13 | pub fn new(viewport: Viewport, pad: f32, bounds: impl Iterator<Item = [f32; 4]>) -> Self { |
| 14 | let mut min = [f32::INFINITY; 2]; | ||
| 14 | let mut max = [0.0_f32; 2]; | 15 | let mut max = [0.0_f32; 2]; |
| 15 | for rect in bounds { | 16 | for rect in bounds { |
| 16 | for axis in 0..2 { | 17 | for axis in 0..2 { |
| ... | @@ -18,9 +19,9 @@ impl Scroll { | ... | @@ -18,9 +19,9 @@ impl Scroll { |
| 18 | max[axis] = max[axis].max(rect[axis + 2]); | 19 | max[axis] = max[axis].max(rect[axis + 2]); |
| 19 | } | 20 | } |
| 20 | } | 21 | } |
| 21 | // OneNote stops at the page origin or the object furthest above or left of it. | 22 | // OneNote stops at the page origin, or `pad` beyond content that reaches past it. |
| 22 | for axis in 0..2 { | 23 | for axis in 0..2 { |
| 23 | min[axis] *= viewport.scale; | 24 | min[axis] = (min[axis] * viewport.scale - pad).min(0.0); |
| 24 | max[axis] = | 25 | max[axis] = |
| 25 | ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); | 26 | ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); |
| 26 | } | 27 | } |
| ... | @@ -45,9 +46,9 @@ mod tests { | ... | @@ -45,9 +46,9 @@ mod tests { |
| 45 | scale: 2.0, | 46 | scale: 2.0, |
| 46 | origin: [9999.0; 2], | 47 | origin: [9999.0; 2], |
| 47 | }; | 48 | }; |
| 48 | let scroll = Scroll::new(viewport, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter()); | 49 | let scroll = Scroll::new(viewport, 11.0, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter()); |
| 49 | scroll.clamp(&mut viewport); | 50 | scroll.clamp(&mut viewport); |
| 50 | assert_eq!(viewport.origin, [160.0, 40.0]); | 51 | assert_eq!(viewport.origin, [171.0, 51.0]); |
| 51 | viewport.origin = [-9999.0; 2]; | 52 | viewport.origin = [-9999.0; 2]; |
| 52 | scroll.clamp(&mut viewport); | 53 | scroll.clamp(&mut viewport); |
| 53 | assert_eq!(viewport.origin, [-1272.0, -1872.0]); | 54 | assert_eq!(viewport.origin, [-1272.0, -1872.0]); |
crates/canvas/src/interaction/tests.rs+234-3| ... | @@ -15,7 +15,7 @@ const COLORS: TextColors = TextColors { | ... | @@ -15,7 +15,7 @@ const COLORS: TextColors = TextColors { |
| 15 | /// Paint for a view at `scale` device pixels per point on a display of `display_scale`. | 15 | /// Paint for a view at `scale` device pixels per point on a display of `display_scale`. |
| 16 | pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { | 16 | pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { |
| 17 | Paint { | 17 | Paint { |
| 18 | show_caret, | 18 | caret: f32::from(u8::from(show_caret)), |
| 19 | scale, | 19 | scale, |
| 20 | pixel: display_scale / scale, | 20 | pixel: display_scale / scale, |
| 21 | colors: COLORS, | 21 | colors: COLORS, |
| ... | @@ -823,7 +823,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { | ... | @@ -823,7 +823,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { |
| 823 | editor.active_outline(), | 823 | editor.active_outline(), |
| 824 | [24.0, 48.0], | 824 | [24.0, 48.0], |
| 825 | Paint { | 825 | Paint { |
| 826 | show_caret: false, | 826 | caret: 0.0, |
| 827 | scale: 1.0, | 827 | scale: 1.0, |
| 828 | pixel: 1.0, | 828 | pixel: 1.0, |
| 829 | colors: COLORS, | 829 | colors: COLORS, |
| ... | @@ -894,7 +894,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { | ... | @@ -894,7 +894,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { |
| 894 | editor.active_outline(), | 894 | editor.active_outline(), |
| 895 | [24.0, 48.0], | 895 | [24.0, 48.0], |
| 896 | Paint { | 896 | Paint { |
| 897 | show_caret: false, | 897 | caret: 0.0, |
| 898 | scale: 1.0, | 898 | scale: 1.0, |
| 899 | pixel: 1.0, | 899 | pixel: 1.0, |
| 900 | colors: COLORS, | 900 | colors: COLORS, |
| ... | @@ -1522,6 +1522,49 @@ fn picture_view() -> (PageView, onestore::ExGuid) { | ... | @@ -1522,6 +1522,49 @@ fn picture_view() -> (PageView, onestore::ExGuid) { |
| 1522 | (view, id) | 1522 | (view, id) |
| 1523 | } | 1523 | } |
| 1524 | 1524 | ||
| 1525 | #[test] | ||
| 1526 | fn a_page_outline_emptied_by_backspace_still_draws() { | ||
| 1527 | let mut engine = TextEngine::default(); | ||
| 1528 | let objects = [[36.0, 36.0], [36.0, 200.0]] | ||
| 1529 | .map(|origin| { | ||
| 1530 | let document = | ||
| 1531 | TextDocument::new(vec![Paragraph::new("Delete me".into(), Default::default())]) | ||
| 1532 | .unwrap(); | ||
| 1533 | let outline = TextOutline::new(&mut engine, document, 240.0, origin).unwrap(); | ||
| 1534 | onestore::page::PageObject::Outline(outline.snapshot()) | ||
| 1535 | }) | ||
| 1536 | .into(); | ||
| 1537 | let page = Page { | ||
| 1538 | title: String::new(), | ||
| 1539 | identity: None, | ||
| 1540 | created: None, | ||
| 1541 | margin_origin: [36.0, 14.4], | ||
| 1542 | definitions: Default::default(), | ||
| 1543 | objects, | ||
| 1544 | }; | ||
| 1545 | let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 1546 | let other = editor.outlines()[1].id; | ||
| 1547 | let mut view = PageView::new( | ||
| 1548 | editor, | ||
| 1549 | engine, | ||
| 1550 | Some((scene, [0.0; 2])), | ||
| 1551 | [800, 600], | ||
| 1552 | 1.0, | ||
| 1553 | Duration::from_millis(500), | ||
| 1554 | ); | ||
| 1555 | view.editor.select_all().unwrap(); | ||
| 1556 | assert!( | ||
| 1557 | view.key(&Key::Named(NamedKey::Backspace), None) | ||
| 1558 | .unwrap() | ||
| 1559 | .changed | ||
| 1560 | ); | ||
| 1561 | assert!(view.editor.caret_outline().is_some()); | ||
| 1562 | view.primitives(COLORS).unwrap(); | ||
| 1563 | view.editor.focus_outline(other).unwrap(); | ||
| 1564 | assert_eq!(view.editor.outlines().len(), 1); | ||
| 1565 | view.primitives(COLORS).unwrap(); | ||
| 1566 | } | ||
| 1567 | |||
| 1525 | fn click(view: &mut PageView, point: [f32; 2], now: Instant) { | 1568 | fn click(view: &mut PageView, point: [f32; 2], now: Instant) { |
| 1526 | let _ = view.pointer_moved(point).unwrap(); | 1569 | let _ = view.pointer_moved(point).unwrap(); |
| 1527 | assert!(view.pointer_pressed(now).unwrap().changed); | 1570 | assert!(view.pointer_pressed(now).unwrap().changed); |
| ... | @@ -1653,3 +1696,191 @@ fn toolbar_commands_wait_for_composition_and_object_focus() { | ... | @@ -1653,3 +1696,191 @@ fn toolbar_commands_wait_for_composition_and_object_focus() { |
| 1653 | assert!(view.format(bold).unwrap().changed); | 1696 | assert!(view.format(bold).unwrap().changed); |
| 1654 | assert_eq!(view.editor.format_state().unwrap().toggles, [Toggle::Bold]); | 1697 | assert_eq!(view.editor.format_state().unwrap().toggles, [Toggle::Bold]); |
| 1655 | } | 1698 | } |
| 1699 | |||
| 1700 | #[test] | ||
| 1701 | fn outline_chrome_grows_left_to_contain_its_tag_column() { | ||
| 1702 | use crate::editor::{Formatting, NoteTag}; | ||
| 1703 | let mut engine = TextEngine::default(); | ||
| 1704 | let outline = TextOutline::new( | ||
| 1705 | &mut engine, | ||
| 1706 | TextDocument::new(vec![ | ||
| 1707 | Paragraph::new("Plain one".into(), Format::default()), | ||
| 1708 | Paragraph::new("Plain two".into(), Format::default()), | ||
| 1709 | ]) | ||
| 1710 | .unwrap(), | ||
| 1711 | 468.0, | ||
| 1712 | [288.0, 36.0], | ||
| 1713 | ) | ||
| 1714 | .unwrap(); | ||
| 1715 | let mut editor = | ||
| 1716 | CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); | ||
| 1717 | // OneNote 2010 at 100%: the border sits 15 px left of the text, plus 16 px per tag slot, | ||
| 1718 | // and the text and right border stay put. | ||
| 1719 | let pixel = 0.75; | ||
| 1720 | let (plain, _) = outline_chrome(editor.active_outline(), pixel); | ||
| 1721 | let check = |editor: &CanvasEditor, slots: f32| { | ||
| 1722 | let outline = editor.active_outline(); | ||
| 1723 | let (frame, _) = outline_chrome(outline, pixel); | ||
| 1724 | assert_eq!(outline.origin()[0], 288.0); | ||
| 1725 | assert!(((288.0 - frame[0]) / pixel - (15.0 + 16.0 * slots)).abs() < 0.01); | ||
| 1726 | assert_eq!(frame[2], plain[2]); | ||
| 1727 | let shaped = outline.shaped(); | ||
| 1728 | for (_, paragraph) in outline.layouts() { | ||
| 1729 | for tag in &paragraph.tags { | ||
| 1730 | let x = outline.origin()[0] + shaped.tag_column_offset() + tag.origin[0]; | ||
| 1731 | let y = outline.origin()[1] + paragraph.origin[1] + tag.origin[1]; | ||
| 1732 | let size = crate::outline::ParagraphTag::SIZE; | ||
| 1733 | assert!(frame[0] < x && x + size < frame[2], "{frame:?} {x}"); | ||
| 1734 | assert!(frame[1] < y && y + size < frame[3], "{frame:?} {y}"); | ||
| 1735 | // OneNote 2010 shows an arrow over a check box, which a click toggles. | ||
| 1736 | let hit = page_hit_test(editor, None, [x + 1.0, y + 1.0], pixel); | ||
| 1737 | if matches!(tag.icon, crate::outline::TagIcon::CheckBox { .. }) { | ||
| 1738 | assert_eq!( | ||
| 1739 | hit, | ||
| 1740 | Some(Hit::Check { | ||
| 1741 | outline: outline.id, | ||
| 1742 | paragraph: paragraph.id, | ||
| 1743 | }) | ||
| 1744 | ); | ||
| 1745 | } else { | ||
| 1746 | assert!(matches!(hit, Some(Hit::Text { .. }))); | ||
| 1747 | } | ||
| 1748 | } | ||
| 1749 | } | ||
| 1750 | }; | ||
| 1751 | check(&editor, 0.0); | ||
| 1752 | editor | ||
| 1753 | .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) | ||
| 1754 | .unwrap(); | ||
| 1755 | check(&editor, 1.0); | ||
| 1756 | editor | ||
| 1757 | .format(&mut engine, Formatting::Tag(NoteTag::Question)) | ||
| 1758 | .unwrap(); | ||
| 1759 | check(&editor, 2.0); | ||
| 1760 | editor | ||
| 1761 | .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) | ||
| 1762 | .unwrap(); | ||
| 1763 | editor | ||
| 1764 | .format(&mut engine, Formatting::Tag(NoteTag::Question)) | ||
| 1765 | .unwrap(); | ||
| 1766 | check(&editor, 0.0); | ||
| 1767 | } | ||
| 1768 | |||
| 1769 | #[test] | ||
| 1770 | fn a_click_on_a_check_box_toggles_it_under_an_arrow() { | ||
| 1771 | use crate::editor::{Formatting, NoteTag}; | ||
| 1772 | let mut engine = TextEngine::default(); | ||
| 1773 | let outline = TextOutline::new( | ||
| 1774 | &mut engine, | ||
| 1775 | TextDocument::new(vec![Paragraph::new("Task".into(), Format::default())]).unwrap(), | ||
| 1776 | 468.0, | ||
| 1777 | [288.0, 36.0], | ||
| 1778 | ) | ||
| 1779 | .unwrap(); | ||
| 1780 | let mut editor = | ||
| 1781 | CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); | ||
| 1782 | editor | ||
| 1783 | .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) | ||
| 1784 | .unwrap(); | ||
| 1785 | let outline = editor.active_outline(); | ||
| 1786 | let (_, paragraph) = outline.layouts().next().unwrap(); | ||
| 1787 | let tag = &paragraph.tags[0]; | ||
| 1788 | let centre = [ | ||
| 1789 | outline.origin()[0] + outline.shaped().tag_column_offset() + tag.origin[0] + tag.size / 2.0, | ||
| 1790 | outline.origin()[1] + paragraph.origin[1] + tag.origin[1] + tag.size / 2.0, | ||
| 1791 | ]; | ||
| 1792 | let mut view = PageView::new( | ||
| 1793 | editor, | ||
| 1794 | engine, | ||
| 1795 | None, | ||
| 1796 | [800, 600], | ||
| 1797 | 1.0, | ||
| 1798 | Duration::from_millis(500), | ||
| 1799 | ); | ||
| 1800 | let device = [0, 1].map(|axis| centre[axis] * view.viewport.scale + view.viewport.origin[axis]); | ||
| 1801 | let _ = view.pointer_moved(device).unwrap(); | ||
| 1802 | assert_eq!(view.cursor(), Cursor::Default); | ||
| 1803 | let status = |view: &PageView| { | ||
| 1804 | view.editor.active_outline().document().nodes()[0] | ||
| 1805 | .text() | ||
| 1806 | .unwrap() | ||
| 1807 | .tags[0] | ||
| 1808 | .status | ||
| 1809 | }; | ||
| 1810 | assert!(view.pointer_pressed(Instant::now()).unwrap().changed); | ||
| 1811 | let _ = view.pointer_released().unwrap(); | ||
| 1812 | assert_eq!(status(&view), 1); | ||
| 1813 | let _ = view.pointer_moved([device[0] + 40.0, device[1]]).unwrap(); | ||
| 1814 | assert_eq!(view.cursor(), Cursor::Text); | ||
| 1815 | } | ||
| 1816 | |||
| 1817 | /// OneNote 2010 moves an outline by the drag from its stored position and snaps it to the | ||
| 1818 | /// nearest 18 pt step from the page's margin origin, (36, 14.4) until one is stored. | ||
| 1819 | #[test] | ||
| 1820 | fn a_dragged_outline_snaps_to_the_margin_grid_as_onenote_stores_it() { | ||
| 1821 | let margin = [36.0, 14.4]; | ||
| 1822 | for (stored, drag, expected) in [ | ||
| 1823 | ([72.0, 72.0], [15.0, 0.0], [90.0, 68.4]), | ||
| 1824 | ([90.0, 68.4], [5.25, 15.0], [90.0, 86.4]), | ||
| 1825 | ([0.0, 8.0], [30.0, 0.0], [36.0, 14.4]), | ||
| 1826 | ] { | ||
| 1827 | let point = [stored[0] + drag[0], stored[1] + drag[1]]; | ||
| 1828 | let snapped = snap_to_grid(point, margin); | ||
| 1829 | assert!( | ||
| 1830 | (0..2).all(|axis| (snapped[axis] - expected[axis]).abs() < 1e-4), | ||
| 1831 | "{snapped:?} {expected:?}" | ||
| 1832 | ); | ||
| 1833 | } | ||
| 1834 | } | ||
| 1835 | |||
| 1836 | /// OneNote 2010 at 75 to 150% opens a page scrolled fully left and up: 11 px beyond 7.5 pt | ||
| 1837 | /// left of text, a list marker, 0.75 pt into a tag's slot, or 6 pt above an outline, never | ||
| 1838 | /// right of or below the page origin; then down until the caret's outline clears the bottom. | ||
| 1839 | #[test] | ||
| 1840 | fn a_page_opens_where_onenote_places_the_view() { | ||
| 1841 | use crate::editor::{Formatting, NoteTag}; | ||
| 1842 | let open = |origin: [f32; 2], tag: bool| { | ||
| 1843 | let mut engine = TextEngine::default(); | ||
| 1844 | let outline = TextOutline::new( | ||
| 1845 | &mut engine, | ||
| 1846 | TextDocument::new(vec![Paragraph::new("Plain".into(), Format::default())]).unwrap(), | ||
| 1847 | 468.0, | ||
| 1848 | origin, | ||
| 1849 | ) | ||
| 1850 | .unwrap(); | ||
| 1851 | let mut editor = | ||
| 1852 | CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); | ||
| 1853 | if tag { | ||
| 1854 | editor | ||
| 1855 | .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) | ||
| 1856 | .unwrap(); | ||
| 1857 | } | ||
| 1858 | let reached = reach(editor.active_outline()); | ||
| 1859 | let view = PageView::new( | ||
| 1860 | editor, | ||
| 1861 | engine, | ||
| 1862 | None, | ||
| 1863 | [800, 600], | ||
| 1864 | 1.0, | ||
| 1865 | Duration::from_millis(500), | ||
| 1866 | ); | ||
| 1867 | (reached, view.viewport.origin) | ||
| 1868 | }; | ||
| 1869 | // Native at 100%: page origin 21 px right and 20 px down, 74 and 60 px, 37 px and 0. | ||
| 1870 | for (origin, tag, reached, placed) in [ | ||
| 1871 | ([0.0, 0.0], false, [-7.5, -6.0], [21.0, 19.0]), | ||
| 1872 | ([-40.0, -30.0], false, [-47.5, -36.0], [74.33, 59.0]), | ||
| 1873 | ([0.0, 40.0], true, [-19.5, 34.0], [37.0, 0.0]), | ||
| 1874 | ([72.0, 72.0], false, [64.5, 66.0], [0.0, 0.0]), | ||
| 1875 | ] { | ||
| 1876 | let (actual, view) = open(origin, tag); | ||
| 1877 | assert_eq!(actual, reached); | ||
| 1878 | assert!( | ||
| 1879 | (0..2).all(|axis| (view[axis] - placed[axis]).abs() < 0.01), | ||
| 1880 | "{origin:?} {view:?}" | ||
| 1881 | ); | ||
| 1882 | } | ||
| 1883 | // Content far down scrolls into view, 9 pt and 9 px above the bottom. | ||
| 1884 | let (_, view) = open([50.0, 900.0], false); | ||
| 1885 | assert!(view[1] < -600.0); | ||
| 1886 | } |
crates/canvas/src/language.rs created+162| ... | @@ -0,0 +1,162 @@ | ||
| 1 | /// Windows LCIDs keyed by BCP-47 language, `language-Script` or `language-REGION`; a bare | ||
| 2 | /// language maps to the locale Windows picks for it. | ||
| 3 | const LCIDS: &[(&str, u32)] = &[ | ||
| 4 | ("af", 1078), | ||
| 5 | ("am", 1118), | ||
| 6 | ("ar", 1025), | ||
| 7 | ("ar-EG", 3073), | ||
| 8 | ("az", 1068), | ||
| 9 | ("be", 1059), | ||
| 10 | ("bg", 1026), | ||
| 11 | ("bn", 1093), | ||
| 12 | ("bn-BD", 2117), | ||
| 13 | ("ca", 1027), | ||
| 14 | ("cs", 1029), | ||
| 15 | ("cy", 1106), | ||
| 16 | ("da", 1030), | ||
| 17 | ("de", 1031), | ||
| 18 | ("de-AT", 3079), | ||
| 19 | ("de-CH", 2055), | ||
| 20 | ("de-LI", 5127), | ||
| 21 | ("de-LU", 4103), | ||
| 22 | ("el", 1032), | ||
| 23 | ("en", 1033), | ||
| 24 | ("en-AU", 3081), | ||
| 25 | ("en-CA", 4105), | ||
| 26 | ("en-GB", 2057), | ||
| 27 | ("en-IE", 6153), | ||
| 28 | ("en-IN", 16393), | ||
| 29 | ("en-NZ", 5129), | ||
| 30 | ("en-SG", 18441), | ||
| 31 | ("en-ZA", 7177), | ||
| 32 | ("es", 3082), | ||
| 33 | ("es-MX", 2058), | ||
| 34 | ("es-US", 21514), | ||
| 35 | ("et", 1061), | ||
| 36 | ("eu", 1069), | ||
| 37 | ("fa", 1065), | ||
| 38 | ("fi", 1035), | ||
| 39 | ("fil", 1124), | ||
| 40 | ("fr", 1036), | ||
| 41 | ("fr-BE", 2060), | ||
| 42 | ("fr-CA", 3084), | ||
| 43 | ("fr-CH", 4108), | ||
| 44 | ("gl", 1110), | ||
| 45 | ("gu", 1095), | ||
| 46 | ("he", 1037), | ||
| 47 | ("hi", 1081), | ||
| 48 | ("hr", 1050), | ||
| 49 | ("hu", 1038), | ||
| 50 | ("hy", 1067), | ||
| 51 | ("id", 1057), | ||
| 52 | ("is", 1039), | ||
| 53 | ("it", 1040), | ||
| 54 | ("it-CH", 2064), | ||
| 55 | ("ja", 1041), | ||
| 56 | ("ka", 1079), | ||
| 57 | ("kk", 1087), | ||
| 58 | ("km", 1107), | ||
| 59 | ("kn", 1099), | ||
| 60 | ("ko", 1042), | ||
| 61 | ("lo", 1108), | ||
| 62 | ("lt", 1063), | ||
| 63 | ("lv", 1062), | ||
| 64 | ("mk", 1071), | ||
| 65 | ("ml", 1100), | ||
| 66 | ("mn", 1104), | ||
| 67 | ("mr", 1102), | ||
| 68 | ("ms", 1086), | ||
| 69 | ("my", 1109), | ||
| 70 | ("nb", 1044), | ||
| 71 | ("ne", 1121), | ||
| 72 | ("nl", 1043), | ||
| 73 | ("nl-BE", 2067), | ||
| 74 | ("nn", 2068), | ||
| 75 | ("no", 1044), | ||
| 76 | ("pa", 1094), | ||
| 77 | ("pl", 1045), | ||
| 78 | ("pt", 1046), | ||
| 79 | ("pt-PT", 2070), | ||
| 80 | ("ro", 1048), | ||
| 81 | ("ru", 1049), | ||
| 82 | ("si", 1115), | ||
| 83 | ("sk", 1051), | ||
| 84 | ("sl", 1060), | ||
| 85 | ("sq", 1052), | ||
| 86 | ("sr", 10266), | ||
| 87 | ("sr-Latn", 9242), | ||
| 88 | ("sv", 1053), | ||
| 89 | ("sv-FI", 2077), | ||
| 90 | ("sw", 1089), | ||
| 91 | ("ta", 1097), | ||
| 92 | ("te", 1098), | ||
| 93 | ("th", 1054), | ||
| 94 | ("tr", 1055), | ||
| 95 | ("uk", 1058), | ||
| 96 | ("ur", 1056), | ||
| 97 | ("vi", 1066), | ||
| 98 | ("zh", 2052), | ||
| 99 | ("zh-HK", 3076), | ||
| 100 | ("zh-Hant", 1028), | ||
| 101 | ("zh-MO", 5124), | ||
| 102 | ("zh-SG", 4100), | ||
| 103 | ("zh-TW", 1028), | ||
| 104 | ]; | ||
| 105 | |||
| 106 | /// en-US, what OneNote records when nothing better is known. | ||
| 107 | const EN_US: u32 = 1033; | ||
| 108 | |||
| 109 | /// The Windows LCID of a BCP-47 tag such as an input source's language (`de`, `en-GB`, | ||
| 110 | /// `zh-Hans`), matched by region, then script, then language, else en-US. | ||
| 111 | pub fn lcid(tag: &str) -> u32 { | ||
| 112 | let mut subtags = tag.split(['-', '_']); | ||
| 113 | let language = subtags.next().unwrap_or_default(); | ||
| 114 | let (mut script, mut region) = (None, None); | ||
| 115 | for subtag in subtags { | ||
| 116 | match subtag.len() { | ||
| 117 | 4 => script = Some(subtag), | ||
| 118 | 2 | 3 => region = Some(subtag), | ||
| 119 | _ => {} | ||
| 120 | } | ||
| 121 | } | ||
| 122 | [region, script] | ||
| 123 | .into_iter() | ||
| 124 | .flatten() | ||
| 125 | .map(|subtag| format!("{language}-{subtag}")) | ||
| 126 | .chain([language.to_owned()]) | ||
| 127 | .find_map(|key| { | ||
| 128 | LCIDS | ||
| 129 | .iter() | ||
| 130 | .find(|(tag, _)| tag.eq_ignore_ascii_case(&key)) | ||
| 131 | .map(|(_, lcid)| *lcid) | ||
| 132 | }) | ||
| 133 | .unwrap_or(EN_US) | ||
| 134 | } | ||
| 135 | |||
| 136 | #[cfg(test)] | ||
| 137 | mod tests { | ||
| 138 | use super::*; | ||
| 139 | |||
| 140 | #[test] | ||
| 141 | fn tags_map_to_windows_lcids() { | ||
| 142 | for (tag, lcid) in [ | ||
| 143 | ("en", 1033), | ||
| 144 | ("en-US", 1033), | ||
| 145 | ("en_GB", 2057), | ||
| 146 | ("de", 1031), | ||
| 147 | ("DE-at", 3079), | ||
| 148 | ("fr-FR", 1036), | ||
| 149 | ("es-419", 3082), | ||
| 150 | ("ja-JP", 1041), | ||
| 151 | ("zh-Hans", 2052), | ||
| 152 | ("zh-Hant", 1028), | ||
| 153 | ("zh-Hant-HK", 3076), | ||
| 154 | ("zh-Hans-SG", 4100), | ||
| 155 | ("sr-Latn-RS", 9242), | ||
| 156 | ("emoji", EN_US), | ||
| 157 | ("", EN_US), | ||
| 158 | ] { | ||
| 159 | assert_eq!(super::lcid(tag), lcid, "{tag}"); | ||
| 160 | } | ||
| 161 | } | ||
| 162 | } | ||
crates/canvas/src/layout.rs+21| ... | @@ -414,6 +414,27 @@ impl TextLayout { | ... | @@ -414,6 +414,27 @@ impl TextLayout { |
| 414 | 414 | ||
| 415 | pub fn caret(&self, cursor: Cursor, width: f32) -> BoundingBox { | 415 | pub fn caret(&self, cursor: Cursor, width: f32) -> BoundingBox { |
| 416 | let mut rect = cursor.geometry(&self.shaped, width); | 416 | let mut rect = cursor.geometry(&self.shaped, width); |
| 417 | // parley pairs the logical end of an RTL cluster leading its line with the previous | ||
| 418 | // line's last cluster, placing the caret at that line's end. | ||
| 419 | if cursor.affinity() == Affinity::Upstream | ||
| 420 | && let Some(cluster) = cursor | ||
| 421 | .index() | ||
| 422 | .checked_sub(1) | ||
| 423 | .and_then(|index| parley::Cluster::from_byte_index(&self.shaped, index)) | ||
| 424 | && cluster.is_rtl() | ||
| 425 | && cluster | ||
| 426 | .previous_visual() | ||
| 427 | .is_some_and(|previous| previous.path().line_index() != cluster.path().line_index()) | ||
| 428 | { | ||
| 429 | let x = f64::from(cluster.visual_offset().unwrap_or_default()); | ||
| 430 | let metrics = *cluster.line().metrics(); | ||
| 431 | rect = BoundingBox::new( | ||
| 432 | x, | ||
| 433 | f64::from(metrics.block_min_coord), | ||
| 434 | x + f64::from(width), | ||
| 435 | f64::from(metrics.block_max_coord), | ||
| 436 | ); | ||
| 437 | } | ||
| 417 | let index = self | 438 | let index = self |
| 418 | .shaped | 439 | .shaped |
| 419 | .lines() | 440 | .lines() |
crates/canvas/src/lib.rs+1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | pub mod document; | 1 | pub mod document; |
| 2 | pub mod editor; | 2 | pub mod editor; |
| 3 | pub mod language; | ||
| 3 | pub mod layout; | 4 | pub mod layout; |
| 4 | pub mod math; | 5 | pub mod math; |
| 5 | pub mod outline; | 6 | pub mod outline; |
crates/canvas/src/outline.rs+350-34| ... | @@ -198,6 +198,9 @@ pub struct ParagraphLayout { | ... | @@ -198,6 +198,9 @@ pub struct ParagraphLayout { |
| 198 | /// continuing from the previous sibling's. | 198 | /// continuing from the previous sibling's. |
| 199 | pub(crate) number: Option<(u32, bool)>, | 199 | pub(crate) number: Option<(u32, bool)>, |
| 200 | pub tags: Vec<ParagraphTag>, | 200 | pub tags: Vec<ParagraphTag>, |
| 201 | /// Its siblings' group, whose list markers its tags clear: the parent paragraph, or the | ||
| 202 | /// table cell holding a cell's top-level paragraph. | ||
| 203 | pub(crate) parent: Option<ExGuid>, | ||
| 201 | /// An equation draws in two dimensions in place of its linear text. | 204 | /// An equation draws in two dimensions in place of its linear text. |
| 202 | pub math: Option<crate::math::MathLayout>, | 205 | pub math: Option<crate::math::MathLayout>, |
| 203 | } | 206 | } |
| ... | @@ -223,14 +226,48 @@ pub struct ParagraphTag { | ... | @@ -223,14 +226,48 @@ pub struct ParagraphTag { |
| 223 | pub icon: TagIcon, | 226 | pub icon: TagIcon, |
| 224 | /// Coordinates are outline-local in x and paragraph-local in y. | 227 | /// Coordinates are outline-local in x and paragraph-local in y. |
| 225 | pub origin: [f32; 2], | 228 | pub origin: [f32; 2], |
| 229 | /// The icon's side, which follows its paragraph's first run. | ||
| 230 | pub size: f32, | ||
| 226 | pub label: String, | 231 | pub label: String, |
| 227 | pub disabled: bool, | 232 | pub disabled: bool, |
| 228 | } | 233 | } |
| 229 | 234 | ||
| 230 | impl ParagraphTag { | 235 | impl ParagraphTag { |
| 236 | /// The icon's side beside 10 to 17.5 pt text. | ||
| 231 | pub const SIZE: f32 = 12.0; | 237 | pub const SIZE: f32 = 12.0; |
| 238 | /// How far a 12 pt icon starts left of its text when no sibling has a list marker. | ||
| 239 | pub const INSET: f32 = 20.25; | ||
| 240 | /// Space between a tag and the leftmost list marker among its paragraph's siblings. | ||
| 241 | const MARKER_GAP: f32 = 0.9; | ||
| 242 | |||
| 243 | /// Where an icon of `side` starts below its paragraph's top, for a first line whose | ||
| 244 | /// baseline is `baseline` below it and a first run of `size` points: OneNote 2010 centres | ||
| 245 | /// it 0.357 of the run's size, less 0.2 pt, above the baseline (within 0.75 pt, 8 to 60 pt). | ||
| 246 | fn top(baseline: f32, size: f32, side: f32) -> f32 { | ||
| 247 | baseline - 0.357 * size + 0.2 - side / 2.0 | ||
| 248 | } | ||
| 249 | |||
| 250 | /// OneNote 2010's icon side for text of `size` points. | ||
| 251 | fn side(size: f32) -> f32 { | ||
| 252 | match size { | ||
| 253 | 24.0.. => 24.0, | ||
| 254 | 18.0.. => 18.0, | ||
| 255 | 10.0.. => Self::SIZE, | ||
| 256 | _ => 9.0, | ||
| 257 | } | ||
| 258 | } | ||
| 259 | |||
| 260 | /// Where a one-tag column of `side` starts for text at `x` among siblings whose leftmost | ||
| 261 | /// list marker starts at `marker`; its right edge stays put as the icon grows. | ||
| 262 | fn column(x: f32, marker: Option<f32>, side: f32) -> f32 { | ||
| 263 | let right = x - Self::INSET + Self::SIZE; | ||
| 264 | marker.map_or(right, |marker| right.min(marker - Self::MARKER_GAP)) - side | ||
| 265 | } | ||
| 232 | } | 266 | } |
| 233 | 267 | ||
| 268 | /// How much further than its list spacing OneNote 2010 sets a marker's advance from its text. | ||
| 269 | const MARKER_OFFSET: f32 = 3.9; | ||
| 270 | |||
| 234 | /// A one-run paragraph standing in for an object's caption, so it lays out through the same | 271 | /// A one-run paragraph standing in for an object's caption, so it lays out through the same |
| 235 | /// shaping (and caching) as the outline's text. | 272 | /// shaping (and caching) as the outline's text. |
| 236 | fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParagraph { | 273 | fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParagraph { |
| ... | @@ -264,25 +301,23 @@ pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> { | ... | @@ -264,25 +301,23 @@ pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> { |
| 264 | .then_some(size) | 301 | .then_some(size) |
| 265 | } | 302 | } |
| 266 | 303 | ||
| 267 | /// The table OneNote writes on every outline it creates, standing in for a missing one. | 304 | /// The step OneNote gives each level past the end of an indentation table. |
| 268 | const DEFAULT_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0]; | 305 | const MISSING_INDENT: f64 = 27.0; |
| 269 | 306 | ||
| 307 | /// Text offset of `level` from its outline: entry `n` of `indents` steps level `n` in from | ||
| 308 | /// level `n - 1`; entry 0 moves nothing OneNote 2010 draws, text nor markers. | ||
| 270 | pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> { | 309 | pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> { |
| 271 | let indents = if indents.is_empty() { | ||
| 272 | &DEFAULT_INDENTS | ||
| 273 | } else { | ||
| 274 | indents | ||
| 275 | }; | ||
| 276 | if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { | 310 | if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { |
| 277 | return Err(LayoutError::InvalidIndentation); | 311 | return Err(LayoutError::InvalidIndentation); |
| 278 | } | 312 | } |
| 279 | let known = (level as usize).min(indents.len() - 1); | 313 | let known = (level as usize).min(indents.len().saturating_sub(1)); |
| 280 | let indent = (indents[1..=known] | 314 | let indent = (indents |
| 315 | .get(1..=known) | ||
| 316 | .unwrap_or_default() | ||
| 281 | .iter() | 317 | .iter() |
| 282 | .map(|v| f64::from(*v)) | 318 | .map(|v| f64::from(*v)) |
| 283 | .sum::<f64>() | 319 | .sum::<f64>() |
| 284 | + f64::from(level - known as u32) * f64::from(*indents.last().unwrap())) | 320 | + f64::from(level - known as u32) * MISSING_INDENT) as f32; |
| 285 | as f32; | ||
| 286 | if !indent.is_finite() || indent >= width { | 321 | if !indent.is_finite() || indent >= width { |
| 287 | return Err(LayoutError::InvalidIndentation); | 322 | return Err(LayoutError::InvalidIndentation); |
| 288 | } | 323 | } |
| ... | @@ -486,7 +521,7 @@ impl ParagraphLayout { | ... | @@ -486,7 +521,7 @@ impl ParagraphLayout { |
| 486 | )? | 521 | )? |
| 487 | }; | 522 | }; |
| 488 | let mut markers = Vec::new(); | 523 | let mut markers = Vec::new(); |
| 489 | let mut marker_x = indent; | 524 | let mut marker_x = indent - MARKER_OFFSET; |
| 490 | let mut number = None; | 525 | let mut number = None; |
| 491 | for id in paragraph.lists.iter().rev() { | 526 | for id in paragraph.lists.iter().rev() { |
| 492 | let definition = definitions.get(id).ok_or(LayoutError::InvalidList)?; | 527 | let definition = definitions.get(id).ok_or(LayoutError::InvalidList)?; |
| ... | @@ -523,7 +558,11 @@ impl ParagraphLayout { | ... | @@ -523,7 +558,11 @@ impl ParagraphLayout { |
| 523 | let marker = Paragraph::new( | 558 | let marker = Paragraph::new( |
| 524 | value, | 559 | value, |
| 525 | Format { | 560 | Format { |
| 526 | font: font.clone().or_else(|| definition.format.font.clone()), | 561 | // Without a font of its own a marker takes its text's, as a number does. |
| 562 | font: font | ||
| 563 | .clone() | ||
| 564 | .or_else(|| definition.format.font.clone()) | ||
| 565 | .or_else(|| format.font.clone()), | ||
| 527 | font_size: definition.format.font_size.or(format.font_size), | 566 | font_size: definition.format.font_size.or(format.font_size), |
| 528 | color, | 567 | color, |
| 529 | ..Format::default() | 568 | ..Format::default() |
| ... | @@ -542,6 +581,7 @@ impl ParagraphLayout { | ... | @@ -542,6 +581,7 @@ impl ParagraphLayout { |
| 542 | markers.push((layout, [marker_x, y])); | 581 | markers.push((layout, [marker_x, y])); |
| 543 | } | 582 | } |
| 544 | text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?; | 583 | text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?; |
| 584 | let baseline = text.lines().next().unwrap().1.baseline; | ||
| 545 | let mut tags = Vec::new(); | 585 | let mut tags = Vec::new(); |
| 546 | for tag in &tag_definitions { | 586 | for tag in &tag_definitions { |
| 547 | let (tag, shape, label) = (tag.0, tag.1, tag.2); | 587 | let (tag, shape, label) = (tag.0, tag.1, tag.2); |
| ... | @@ -563,11 +603,12 @@ impl ParagraphLayout { | ... | @@ -563,11 +603,12 @@ impl ParagraphLayout { |
| 563 | Some(121) => TagIcon::Music, | 603 | Some(121) => TagIcon::Music, |
| 564 | _ => return Err(LayoutError::UnsupportedContent), | 604 | _ => return Err(LayoutError::UnsupportedContent), |
| 565 | }; | 605 | }; |
| 566 | // Later tags follow the first to its right, toward the text. | 606 | let size = format.font_size.unwrap_or(11.0); |
| 567 | let x = marker_x - 20.25 + 12.0 * tags.len() as f32; | 607 | let side = ParagraphTag::side(size); |
| 568 | tags.push(ParagraphTag { | 608 | tags.push(ParagraphTag { |
| 569 | icon, | 609 | icon, |
| 570 | origin: [x, 0.0], | 610 | origin: [0.0, ParagraphTag::top(baseline, size, side)], |
| 611 | size: side, | ||
| 571 | label: label.clone().unwrap_or_default(), | 612 | label: label.clone().unwrap_or_default(), |
| 572 | disabled: tag.status & 2 != 0, | 613 | disabled: tag.status & 2 != 0, |
| 573 | }); | 614 | }); |
| ... | @@ -575,7 +616,7 @@ impl ParagraphLayout { | ... | @@ -575,7 +616,7 @@ impl ParagraphLayout { |
| 575 | let math = equation | 616 | let math = equation |
| 576 | .then(|| crate::math::layout(engine, &source.text)) | 617 | .then(|| crate::math::layout(engine, &source.text)) |
| 577 | .transpose()?; | 618 | .transpose()?; |
| 578 | Ok(Self { | 619 | let mut result = Self { |
| 579 | id: paragraph.id, | 620 | id: paragraph.id, |
| 580 | origin: [indent, 0.0], | 621 | origin: [indent, 0.0], |
| 581 | projection, | 622 | projection, |
| ... | @@ -584,7 +625,23 @@ impl ParagraphLayout { | ... | @@ -584,7 +625,23 @@ impl ParagraphLayout { |
| 584 | number, | 625 | number, |
| 585 | tags, | 626 | tags, |
| 586 | math, | 627 | math, |
| 587 | }) | 628 | parent: paragraph.parent, |
| 629 | }; | ||
| 630 | result.place_tags(result.marker_left()); | ||
| 631 | Ok(result) | ||
| 632 | } | ||
| 633 | |||
| 634 | fn marker_left(&self) -> Option<f32> { | ||
| 635 | self.markers.iter().map(|(_, [x, _])| *x).reduce(f32::min) | ||
| 636 | } | ||
| 637 | |||
| 638 | /// Places the tags for siblings whose leftmost marker starts at `marker`; later tags | ||
| 639 | /// follow the first to its right, toward the text. | ||
| 640 | fn place_tags(&mut self, marker: Option<f32>) { | ||
| 641 | for (index, tag) in self.tags.iter_mut().enumerate() { | ||
| 642 | tag.origin[0] = | ||
| 643 | ParagraphTag::column(self.origin[0], marker, tag.size) + tag.size * index as f32; | ||
| 644 | } | ||
| 588 | } | 645 | } |
| 589 | } | 646 | } |
| 590 | 647 | ||
| ... | @@ -607,13 +664,11 @@ impl OutlineLayout { | ... | @@ -607,13 +664,11 @@ impl OutlineLayout { |
| 607 | /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each | 664 | /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each |
| 608 | /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column. | 665 | /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column. |
| 609 | pub fn tag_column_offset(&self) -> f32 { | 666 | pub fn tag_column_offset(&self) -> f32 { |
| 610 | let widest = self | 667 | -self |
| 611 | .paragraphs | 668 | .paragraphs |
| 612 | .iter() | 669 | .iter() |
| 613 | .map(|p| p.tags.len()) | 670 | .map(|p| p.tags.iter().skip(1).map(|tag| tag.size).sum::<f32>()) |
| 614 | .max() | 671 | .fold(0.0, f32::max) |
| 615 | .unwrap_or(0); | ||
| 616 | -12.0 * widest.saturating_sub(1) as f32 | ||
| 617 | } | 672 | } |
| 618 | 673 | ||
| 619 | /// Innermost table cell containing a visible paragraph index. | 674 | /// Innermost table cell containing a visible paragraph index. |
| ... | @@ -690,9 +745,25 @@ impl OutlineLayout { | ... | @@ -690,9 +745,25 @@ impl OutlineLayout { |
| 690 | shape, | 745 | shape, |
| 691 | )?; | 746 | )?; |
| 692 | result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width()); | 747 | result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width()); |
| 748 | result.place_tags(); | ||
| 693 | Ok(result) | 749 | Ok(result) |
| 694 | } | 750 | } |
| 695 | 751 | ||
| 752 | /// Moves each paragraph's tags clear of the widest list marker among its siblings. | ||
| 753 | fn place_tags(&mut self) { | ||
| 754 | let mut markers = BTreeMap::<Option<ExGuid>, f32>::new(); | ||
| 755 | for paragraph in &self.paragraphs { | ||
| 756 | if let Some(x) = paragraph.marker_left() { | ||
| 757 | let left = markers.entry(paragraph.parent).or_insert(x); | ||
| 758 | *left = left.min(x); | ||
| 759 | } | ||
| 760 | } | ||
| 761 | for paragraph in &mut self.paragraphs { | ||
| 762 | let marker = markers.get(&paragraph.parent).copied(); | ||
| 763 | paragraph.place_tags(marker); | ||
| 764 | } | ||
| 765 | } | ||
| 766 | |||
| 696 | /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of | 767 | /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of |
| 697 | /// `hiding`; at the root, keeps a block for each node. | 768 | /// `hiding`; at the root, keeps a block for each node. |
| 698 | #[allow(clippy::too_many_arguments)] | 769 | #[allow(clippy::too_many_arguments)] |
| ... | @@ -804,7 +875,7 @@ impl OutlineLayout { | ... | @@ -804,7 +875,7 @@ impl OutlineLayout { |
| 804 | &caption(node.id, file.id, name, format), | 875 | &caption(node.id, file.id, name, format), |
| 805 | None, | 876 | None, |
| 806 | ATTACHMENT_WIDTH, | 877 | ATTACHMENT_WIDTH, |
| 807 | &[0.0], | 878 | &[0.0, 0.0], |
| 808 | )?; | 879 | )?; |
| 809 | label.reset_origin(x); | 880 | label.reset_origin(x); |
| 810 | let object = ObjectLayout { | 881 | let object = ObjectLayout { |
| ... | @@ -841,7 +912,7 @@ impl OutlineLayout { | ... | @@ -841,7 +912,7 @@ impl OutlineLayout { |
| 841 | ), | 912 | ), |
| 842 | None, | 913 | None, |
| 843 | w - 16.0, | 914 | w - 16.0, |
| 844 | &[0.0], | 915 | &[0.0, 0.0], |
| 845 | )?; | 916 | )?; |
| 846 | label.reset_origin(x + 8.0); | 917 | label.reset_origin(x + 8.0); |
| 847 | let h = unsupported | 918 | let h = unsupported |
| ... | @@ -1187,6 +1258,7 @@ impl OutlineLayout { | ... | @@ -1187,6 +1258,7 @@ impl OutlineLayout { |
| 1187 | } | 1258 | } |
| 1188 | self.size = size; | 1259 | self.size = size; |
| 1189 | self.widths = widths; | 1260 | self.widths = widths; |
| 1261 | self.place_tags(); | ||
| 1190 | } | 1262 | } |
| 1191 | 1263 | ||
| 1192 | pub(crate) fn table_width(&self) -> f32 { | 1264 | pub(crate) fn table_width(&self) -> f32 { |
| ... | @@ -1251,7 +1323,7 @@ impl OutlineLayout { | ... | @@ -1251,7 +1323,7 @@ impl OutlineLayout { |
| 1251 | if cell.paragraphs.is_empty() || !cell.unsupported.is_empty() { | 1323 | if cell.paragraphs.is_empty() || !cell.unsupported.is_empty() { |
| 1252 | return Err(LayoutError::UnsupportedContent); | 1324 | return Err(LayoutError::UnsupportedContent); |
| 1253 | } | 1325 | } |
| 1254 | let child = Self::flow( | 1326 | let mut child = Self::flow( |
| 1255 | edited_nodes(&cell.paragraphs, Some(cell.id), edit), | 1327 | edited_nodes(&cell.paragraphs, Some(cell.id), edit), |
| 1256 | &cell.indents, | 1328 | &cell.indents, |
| 1257 | width, | 1329 | width, |
| ... | @@ -1261,6 +1333,9 @@ impl OutlineLayout { | ... | @@ -1261,6 +1333,9 @@ impl OutlineLayout { |
| 1261 | shape, | 1333 | shape, |
| 1262 | )?; | 1334 | )?; |
| 1263 | height = height.max(child.size[1]); | 1335 | height = height.max(child.size[1]); |
| 1336 | for paragraph in &mut child.paragraphs { | ||
| 1337 | paragraph.parent.get_or_insert(cell.id); | ||
| 1338 | } | ||
| 1264 | let paragraph_start = result.paragraphs.len(); | 1339 | let paragraph_start = result.paragraphs.len(); |
| 1265 | result.append(child, [x, y + 3.54]); | 1340 | result.append(child, [x, y + 3.54]); |
| 1266 | result.tables[0].cells.push(CellLayout { | 1341 | result.tables[0].cells.push(CellLayout { |
| ... | @@ -1754,7 +1829,11 @@ mod tests { | ... | @@ -1754,7 +1829,11 @@ mod tests { |
| 1754 | ) | 1829 | ) |
| 1755 | .unwrap(); | 1830 | .unwrap(); |
| 1756 | assert_eq!(tagged.tags[0].icon, expected); | 1831 | assert_eq!(tagged.tags[0].icon, expected); |
| 1757 | assert_eq!(tagged.tags[0].origin, [6.75, 0.0]); | 1832 | let baseline = tagged.text.lines().next().unwrap().1.baseline; |
| 1833 | assert_eq!( | ||
| 1834 | tagged.tags[0].origin, | ||
| 1835 | [6.75, baseline - 0.357 * 11.0 + 0.2 - 6.0] | ||
| 1836 | ); | ||
| 1758 | assert_eq!(tagged.tags[0].label, "Label"); | 1837 | assert_eq!(tagged.tags[0].label, "Label"); |
| 1759 | assert!(tagged.tags[0].disabled); | 1838 | assert!(tagged.tags[0].disabled); |
| 1760 | assert_eq!(tagged.text.height(), plain.text.height()); | 1839 | assert_eq!(tagged.text.height(), plain.text.height()); |
| ... | @@ -1797,8 +1876,12 @@ mod tests { | ... | @@ -1797,8 +1876,12 @@ mod tests { |
| 1797 | &definitions, | 1876 | &definitions, |
| 1798 | ) | 1877 | ) |
| 1799 | .unwrap(); | 1878 | .unwrap(); |
| 1800 | assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 20.25); | 1879 | assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 12.9); |
| 1801 | assert_eq!(tagged.tags[0].origin[1], 0.0); | 1880 | let baseline = tagged.text.lines().next().unwrap().1.baseline; |
| 1881 | assert_eq!( | ||
| 1882 | tagged.tags[0].origin[1], | ||
| 1883 | baseline - 0.357 * 11.0 + 0.2 - 6.0 | ||
| 1884 | ); | ||
| 1802 | let Kind::TagDefinition { shape, .. } = &mut definitions.get_mut(&id).unwrap().kind else { | 1885 | let Kind::TagDefinition { shape, .. } = &mut definitions.get_mut(&id).unwrap().kind else { |
| 1803 | unreachable!() | 1886 | unreachable!() |
| 1804 | }; | 1887 | }; |
| ... | @@ -1816,6 +1899,210 @@ mod tests { | ... | @@ -1816,6 +1899,210 @@ mod tests { |
| 1816 | )); | 1899 | )); |
| 1817 | } | 1900 | } |
| 1818 | 1901 | ||
| 1902 | /// OneNote 2010 at 400% (Calibri bullets and numbers from 8 to 24 pt, default list | ||
| 1903 | /// spacing): a marker's advance ends 11.1 pt before its text, and a tag's 12 pt slot ends | ||
| 1904 | /// 0.9 pt before the leftmost marker among its paragraph's siblings, or 8.25 pt before its | ||
| 1905 | /// text when none has one. | ||
| 1906 | #[test] | ||
| 1907 | fn markers_and_tags_sit_where_onenote_draws_them() { | ||
| 1908 | use onestore::document::Tag; | ||
| 1909 | let id = |n| ExGuid { | ||
| 1910 | n, | ||
| 1911 | ..ExGuid::default() | ||
| 1912 | }; | ||
| 1913 | let list = |format: &str| Definition { | ||
| 1914 | kind: Kind::List { | ||
| 1915 | font: None, | ||
| 1916 | format: Some(format.into()), | ||
| 1917 | bullet: None, | ||
| 1918 | restart: None, | ||
| 1919 | }, | ||
| 1920 | format: Format::default(), | ||
| 1921 | }; | ||
| 1922 | let definitions = BTreeMap::from([ | ||
| 1923 | (id(900), list("\u{2022}")), | ||
| 1924 | (id(901), list("\u{fffd}\u{0}.")), | ||
| 1925 | ( | ||
| 1926 | id(902), | ||
| 1927 | Definition { | ||
| 1928 | kind: Kind::TagDefinition { | ||
| 1929 | shape: Some(3), | ||
| 1930 | label: None, | ||
| 1931 | action_type: None, | ||
| 1932 | color: None, | ||
| 1933 | highlight: None, | ||
| 1934 | }, | ||
| 1935 | format: Format::default(), | ||
| 1936 | }, | ||
| 1937 | ), | ||
| 1938 | ]); | ||
| 1939 | let tag = Tag { | ||
| 1940 | definition: Some(id(902)), | ||
| 1941 | action_type: None, | ||
| 1942 | status: 0, | ||
| 1943 | created: None, | ||
| 1944 | completed: None, | ||
| 1945 | start: None, | ||
| 1946 | due: None, | ||
| 1947 | task_id: None, | ||
| 1948 | extra_set: 0, | ||
| 1949 | }; | ||
| 1950 | let tagged = |n, level, parent| { | ||
| 1951 | let mut node = paragraph(n, "Tag", level, parent); | ||
| 1952 | node.text_mut().unwrap().tags.push(tag.clone()); | ||
| 1953 | node | ||
| 1954 | }; | ||
| 1955 | let mut number = paragraph(1, "Number", 1, None); | ||
| 1956 | number.lists.push(id(901)); | ||
| 1957 | let mut bullet = paragraph(5, "Bullet", 2, Some(3)); | ||
| 1958 | bullet.lists.push(id(900)); | ||
| 1959 | let nodes = [ | ||
| 1960 | number, | ||
| 1961 | tagged(2, 1, None), | ||
| 1962 | paragraph(3, "Plain", 1, None), | ||
| 1963 | tagged(4, 2, Some(3)), | ||
| 1964 | bullet, | ||
| 1965 | paragraph(6, "Plain", 2, Some(3)), | ||
| 1966 | ]; | ||
| 1967 | let mut engine = TextEngine::default(); | ||
| 1968 | let layout = OutlineLayout::flow( | ||
| 1969 | nodes.iter(), | ||
| 1970 | &[18.0, 0.0, 27.0, 27.0], | ||
| 1971 | 400.0, | ||
| 1972 | false, | ||
| 1973 | 0, | ||
| 1974 | None, | ||
| 1975 | &mut |node, previous, width, indents| { | ||
| 1976 | ParagraphLayout::shape(&mut engine, node, previous, width, indents, &definitions) | ||
| 1977 | }, | ||
| 1978 | ) | ||
| 1979 | .unwrap(); | ||
| 1980 | let [number, first, _, second, bullet, _] = &layout.paragraphs[..] else { | ||
| 1981 | panic!() | ||
| 1982 | }; | ||
| 1983 | for paragraph in [number, bullet] { | ||
| 1984 | let (marker, [x, _]) = &paragraph.markers[0]; | ||
| 1985 | let advance = marker.lines().next().unwrap().0.metrics().advance; | ||
| 1986 | assert!((x + advance - (paragraph.origin[0] - 11.1)).abs() < 1e-4); | ||
| 1987 | } | ||
| 1988 | // A numbered sibling pushes the level-1 tag left; the level-2 tag clears the bullet | ||
| 1989 | // that follows it rather than its own text. | ||
| 1990 | assert_eq!(first.tags[0].origin[0], number.markers[0].1[0] - 0.9 - 12.0); | ||
| 1991 | assert_eq!( | ||
| 1992 | second.tags[0].origin[0], | ||
| 1993 | bullet.markers[0].1[0] - 0.9 - 12.0 | ||
| 1994 | ); | ||
| 1995 | assert!(first.tags[0].origin[0] < first.origin[0] - 20.25); | ||
| 1996 | let alone = OutlineLayout::flow( | ||
| 1997 | [tagged(7, 1, None)].iter(), | ||
| 1998 | &[18.0, 0.0, 27.0, 27.0], | ||
| 1999 | 400.0, | ||
| 2000 | false, | ||
| 2001 | 0, | ||
| 2002 | None, | ||
| 2003 | &mut |node, previous, width, indents| { | ||
| 2004 | ParagraphLayout::shape(&mut engine, node, previous, width, indents, &definitions) | ||
| 2005 | }, | ||
| 2006 | ) | ||
| 2007 | .unwrap(); | ||
| 2008 | assert_eq!(alone.paragraphs[0].tags[0].origin[0], -20.25); | ||
| 2009 | } | ||
| 2010 | |||
| 2011 | /// OneNote 2010 at 400%: the first run's size picks the tag icon (9 pt below 10 pt text, | ||
| 2012 | /// 12 pt to 17.5, 18 pt to 23.5, then 24 pt up to at least 60), whose right edge stays | ||
| 2013 | /// 8.25 pt before the text and whose centre sits 0.357 of the run's size less 0.2 pt above | ||
| 2014 | /// the first baseline. | ||
| 2015 | #[test] | ||
| 2016 | fn tag_icons_follow_the_first_run() { | ||
| 2017 | use onestore::document::Tag; | ||
| 2018 | let id = |n| ExGuid { | ||
| 2019 | n, | ||
| 2020 | ..ExGuid::default() | ||
| 2021 | }; | ||
| 2022 | let definitions = BTreeMap::from([( | ||
| 2023 | id(902), | ||
| 2024 | Definition { | ||
| 2025 | kind: Kind::TagDefinition { | ||
| 2026 | shape: Some(3), | ||
| 2027 | label: None, | ||
| 2028 | action_type: None, | ||
| 2029 | color: None, | ||
| 2030 | highlight: None, | ||
| 2031 | }, | ||
| 2032 | format: Format::default(), | ||
| 2033 | }, | ||
| 2034 | )]); | ||
| 2035 | let tag = Tag { | ||
| 2036 | definition: Some(id(902)), | ||
| 2037 | action_type: None, | ||
| 2038 | status: 0, | ||
| 2039 | created: None, | ||
| 2040 | completed: None, | ||
| 2041 | start: None, | ||
| 2042 | due: None, | ||
| 2043 | task_id: None, | ||
| 2044 | extra_set: 0, | ||
| 2045 | }; | ||
| 2046 | let tagged = |sizes: &[f32], tags: usize| { | ||
| 2047 | let mut node = paragraph(1, "", 1, None); | ||
| 2048 | let text = node.text_mut().unwrap(); | ||
| 2049 | text.text = Paragraph::from_runs(sizes.iter().map(|size| { | ||
| 2050 | ( | ||
| 2051 | "Tag ".to_string(), | ||
| 2052 | Format { | ||
| 2053 | font_size: Some(*size), | ||
| 2054 | ..Format::default() | ||
| 2055 | }, | ||
| 2056 | ) | ||
| 2057 | })); | ||
| 2058 | text.tags = vec![tag.clone(); tags]; | ||
| 2059 | node | ||
| 2060 | }; | ||
| 2061 | let mut engine = TextEngine::default(); | ||
| 2062 | let mut lay = |nodes: &[PageParagraph]| { | ||
| 2063 | OutlineLayout::flow( | ||
| 2064 | nodes.iter(), | ||
| 2065 | &[18.0, 0.0, 27.0, 27.0], | ||
| 2066 | 400.0, | ||
| 2067 | false, | ||
| 2068 | 0, | ||
| 2069 | None, | ||
| 2070 | &mut |node, previous, width, indents| { | ||
| 2071 | ParagraphLayout::shape( | ||
| 2072 | &mut engine, | ||
| 2073 | node, | ||
| 2074 | previous, | ||
| 2075 | width, | ||
| 2076 | indents, | ||
| 2077 | &definitions, | ||
| 2078 | ) | ||
| 2079 | }, | ||
| 2080 | ) | ||
| 2081 | .unwrap() | ||
| 2082 | }; | ||
| 2083 | for (sizes, side) in [ | ||
| 2084 | (&[9.5][..], 9.0), | ||
| 2085 | (&[10.0], 12.0), | ||
| 2086 | (&[17.5], 12.0), | ||
| 2087 | (&[18.0], 18.0), | ||
| 2088 | (&[23.5], 18.0), | ||
| 2089 | (&[24.0], 24.0), | ||
| 2090 | (&[60.0], 24.0), | ||
| 2091 | (&[11.0, 24.0], 12.0), | ||
| 2092 | (&[24.0, 11.0], 24.0), | ||
| 2093 | ] { | ||
| 2094 | let layout = lay(&[tagged(sizes, 1)]); | ||
| 2095 | let icon = &layout.paragraphs[0].tags[0]; | ||
| 2096 | assert_eq!((icon.size, icon.origin[0] + icon.size), (side, -8.25)); | ||
| 2097 | let baseline = layout.paragraphs[0].text.lines().next().unwrap().1.baseline; | ||
| 2098 | let centre = icon.origin[1] + side / 2.0; | ||
| 2099 | assert!((centre - (baseline - 0.357 * sizes[0] + 0.2)).abs() < 1e-4); | ||
| 2100 | } | ||
| 2101 | // A second icon extends the column left by its own side. | ||
| 2102 | let layout = lay(&[tagged(&[20.0], 2)]); | ||
| 2103 | assert_eq!(layout.tag_column_offset(), -18.0); | ||
| 2104 | } | ||
| 2105 | |||
| 1819 | #[test] | 2106 | #[test] |
| 1820 | fn files_and_ink_take_their_own_paragraph_in_the_flow() { | 2107 | fn files_and_ink_take_their_own_paragraph_in_the_flow() { |
| 1821 | use onestore::page::{Attachment, Ink, InkStroke}; | 2108 | use onestore::page::{Attachment, Ink, InkStroke}; |
| ... | @@ -1855,7 +2142,7 @@ mod tests { | ... | @@ -1855,7 +2142,7 @@ mod tests { |
| 1855 | let mut engine = TextEngine::default(); | 2142 | let mut engine = TextEngine::default(); |
| 1856 | let layout = OutlineLayout::flow( | 2143 | let layout = OutlineLayout::flow( |
| 1857 | [&before, &file, &ink, &after].into_iter(), | 2144 | [&before, &file, &ink, &after].into_iter(), |
| 1858 | &[0.0], | 2145 | &[0.0, 0.0], |
| 1859 | 468.0, | 2146 | 468.0, |
| 1860 | false, | 2147 | false, |
| 1861 | 0, | 2148 | 0, |
| ... | @@ -2082,11 +2369,40 @@ mod tests { | ... | @@ -2082,11 +2369,40 @@ mod tests { |
| 2082 | outline.layout(&mut engine, &BTreeMap::new()), | 2369 | outline.layout(&mut engine, &BTreeMap::new()), |
| 2083 | Err(LayoutError::InvalidIndentation) | 2370 | Err(LayoutError::InvalidIndentation) |
| 2084 | )); | 2371 | )); |
| 2085 | // OneNote stores only the level-zero indent for an outline of top-level paragraphs. | ||
| 2086 | outline.indents = vec![0.0]; | ||
| 2087 | outline.paragraphs.truncate(1); | 2372 | outline.paragraphs.truncate(1); |
| 2088 | let single = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); | 2373 | for (indents, x) in [(vec![18.0, 0.0, 27.0, 27.0], 0.0), (vec![0.0], 27.0)] { |
| 2089 | assert_eq!(single.paragraphs[0].origin[0], 0.0); | 2374 | outline.indents = indents; |
| 2375 | let single = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); | ||
| 2376 | assert_eq!(single.paragraphs[0].origin[0], x); | ||
| 2377 | } | ||
| 2378 | } | ||
| 2379 | |||
| 2380 | /// Text offsets of levels 1 through 5 as OneNote 2010 draws each table (cold reads of | ||
| 2381 | /// Rust-written outlines, measured to the pixel at 96 dpi). | ||
| 2382 | #[test] | ||
| 2383 | fn indentation_follows_onenote_for_short_and_unusual_tables() { | ||
| 2384 | for (indents, expected) in [ | ||
| 2385 | (&[18.0, 0.0, 27.0, 27.0][..], [0.0, 27.0, 54.0, 81.0, 108.0]), | ||
| 2386 | (&[18.0, 0.0], [0.0, 27.0, 54.0, 81.0, 108.0]), | ||
| 2387 | (&[0.0, 0.0], [0.0, 27.0, 54.0, 81.0, 108.0]), | ||
| 2388 | (&[0.0], [27.0, 54.0, 81.0, 108.0, 135.0]), | ||
| 2389 | (&[18.0], [27.0, 54.0, 81.0, 108.0, 135.0]), | ||
| 2390 | (&[], [27.0, 54.0, 81.0, 108.0, 135.0]), | ||
| 2391 | (&[0.0, 5.0], [5.0, 32.0, 59.0, 86.0, 113.0]), | ||
| 2392 | (&[5.0, 10.0, 20.0, 40.0], [10.0, 30.0, 70.0, 97.0, 124.0]), | ||
| 2393 | ( | ||
| 2394 | &[40.0, 0.0, 10.0, 20.0, 30.0, 50.0], | ||
| 2395 | [0.0, 10.0, 30.0, 60.0, 110.0], | ||
| 2396 | ), | ||
| 2397 | ] { | ||
| 2398 | for (level, x) in (1..).zip(expected) { | ||
| 2399 | assert_eq!( | ||
| 2400 | indentation(level, indents, 500.0).unwrap(), | ||
| 2401 | x, | ||
| 2402 | "{indents:?}" | ||
| 2403 | ); | ||
| 2404 | } | ||
| 2405 | } | ||
| 2090 | } | 2406 | } |
| 2091 | 2407 | ||
| 2092 | #[test] | 2408 | #[test] |
crates/canvas/tests/reopen.rs+8-3| ... | @@ -1,6 +1,9 @@ | ... | @@ -1,6 +1,9 @@ |
| 1 | #![cfg(feature = "gpu")] | 1 | #![cfg(feature = "gpu")] |
| 2 | 2 | ||
| 3 | use canvas::{gpu::page::PageScene, layout::TextEngine}; | 3 | use canvas::{ |
| 4 | gpu::{Paper, page::PageScene}, | ||
| 5 | layout::TextEngine, | ||
| 6 | }; | ||
| 4 | use onestore::{ | 7 | use onestore::{ |
| 5 | ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, | 8 | ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, |
| 6 | document::Document, | 9 | document::Document, |
| ... | @@ -149,7 +152,8 @@ fn undecodable_pictures_show_placeholders_and_keep_their_data() { | ... | @@ -149,7 +152,8 @@ fn undecodable_pictures_show_placeholders_and_keep_their_data() { |
| 149 | _ => None, | 152 | _ => None, |
| 150 | }) | 153 | }) |
| 151 | .unwrap(); | 154 | .unwrap(); |
| 152 | let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); | 155 | let (mut scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); |
| 156 | scene.settle(Some(&editor), 1.0, Paper::WHITE); | ||
| 153 | assert!(scene.image(picture).is_none()); | 157 | assert!(scene.image(picture).is_none()); |
| 154 | assert_eq!(editor.page().unwrap(), page); | 158 | assert_eq!(editor.page().unwrap(), page); |
| 155 | 159 | ||
| ... | @@ -158,7 +162,8 @@ fn undecodable_pictures_show_placeholders_and_keep_their_data() { | ... | @@ -158,7 +162,8 @@ fn undecodable_pictures_show_placeholders_and_keep_their_data() { |
| 158 | .into_iter() | 162 | .into_iter() |
| 159 | .find(|page| page.title == "Image tiff") | 163 | .find(|page| page.title == "Image tiff") |
| 160 | .unwrap(); | 164 | .unwrap(); |
| 161 | let (scene, _) = PageScene::from_page(page.clone(), &mut engine).unwrap(); | 165 | let (mut scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); |
| 166 | scene.settle(Some(&editor), 1.0, Paper::WHITE); | ||
| 162 | let PageObject::Image(tiff) = page | 167 | let PageObject::Image(tiff) = page |
| 163 | .objects | 168 | .objects |
| 164 | .iter_mut() | 169 | .iter_mut() |
crates/canvas/tests/structural_roundtrip.rs created+577| ... | @@ -0,0 +1,577 @@ | ||
| 1 | //! Every structural edit the editor makes saves through the page writer and rereads as the | ||
| 2 | //! model it saved: random Enter, Backspace, Delete, Tab, Shift+Tab, list and paste edits, alone | ||
| 3 | //! and several to a save, on every outline of the probe section and of any sections named in | ||
| 4 | //! `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). | ||
| 5 | |||
| 6 | use canvas::{ | ||
| 7 | document::TextPosition, | ||
| 8 | editor::{CanvasEditor, Formatting}, | ||
| 9 | layout::TextEngine, | ||
| 10 | }; | ||
| 11 | use onestore::{ | ||
| 12 | ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 13 | document::Document, | ||
| 14 | page::{Outline, Page, PageObject, PageParagraph, ParagraphContent}, | ||
| 15 | }; | ||
| 16 | use std::{collections::BTreeMap, path::Path}; | ||
| 17 | |||
| 18 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] | ||
| 19 | enum Kind { | ||
| 20 | EnterStart, | ||
| 21 | EnterMiddle, | ||
| 22 | EnterEnd, | ||
| 23 | Backspace, | ||
| 24 | Delete, | ||
| 25 | Tab, | ||
| 26 | ShiftTab, | ||
| 27 | TabMiddle, | ||
| 28 | Paste, | ||
| 29 | Bullets, | ||
| 30 | Numbering, | ||
| 31 | } | ||
| 32 | |||
| 33 | const KINDS: [Kind; 11] = [ | ||
| 34 | Kind::EnterStart, | ||
| 35 | Kind::EnterMiddle, | ||
| 36 | Kind::EnterEnd, | ||
| 37 | Kind::Backspace, | ||
| 38 | Kind::Delete, | ||
| 39 | Kind::Tab, | ||
| 40 | Kind::ShiftTab, | ||
| 41 | Kind::TabMiddle, | ||
| 42 | Kind::Paste, | ||
| 43 | Kind::Bullets, | ||
| 44 | Kind::Numbering, | ||
| 45 | ]; | ||
| 46 | |||
| 47 | struct Random(u64); | ||
| 48 | |||
| 49 | impl Random { | ||
| 50 | fn below(&mut self, bound: usize) -> usize { | ||
| 51 | self.0 ^= self.0 << 13; | ||
| 52 | self.0 ^= self.0 >> 7; | ||
| 53 | self.0 ^= self.0 << 17; | ||
| 54 | (self.0 % bound as u64) as usize | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | fn pages(section: &[u8]) -> Vec<(ExGuid, Page)> { | ||
| 59 | let store = Store::parse(section).unwrap(); | ||
| 60 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 61 | let document = Document::parse(&index).unwrap(); | ||
| 62 | let mut spaces: Vec<_> = document | ||
| 63 | .pages() | ||
| 64 | .unwrap() | ||
| 65 | .into_iter() | ||
| 66 | .map(|(space, _)| space) | ||
| 67 | .collect(); | ||
| 68 | spaces.dedup(); | ||
| 69 | spaces | ||
| 70 | .into_iter() | ||
| 71 | .filter_map(|space| Some((space, Page::from_space(&document, space).ok()?))) | ||
| 72 | .collect() | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Applies `kind` at a random paragraph of the focused outline; false when the editor | ||
| 76 | /// declines it. | ||
| 77 | fn apply( | ||
| 78 | editor: &mut CanvasEditor, | ||
| 79 | engine: &mut TextEngine, | ||
| 80 | kind: Kind, | ||
| 81 | random: &mut Random, | ||
| 82 | ) -> bool { | ||
| 83 | let count = editor.active_outline().document().paragraphs().count(); | ||
| 84 | let paragraph = random.below(count); | ||
| 85 | let text = editor | ||
| 86 | .active_outline() | ||
| 87 | .document() | ||
| 88 | .paragraphs() | ||
| 89 | .nth(paragraph) | ||
| 90 | .unwrap(); | ||
| 91 | let length = text.text().encode_utf16().count() as u32; | ||
| 92 | let middle = || { | ||
| 93 | let chars: Vec<_> = text.text().char_indices().map(|(at, _)| at).collect(); | ||
| 94 | let at = chars.get(chars.len() / 2).copied().unwrap_or(0); | ||
| 95 | text.utf16_offset(at).unwrap() | ||
| 96 | }; | ||
| 97 | let offset = match kind { | ||
| 98 | Kind::EnterStart | Kind::Backspace | Kind::Tab | Kind::ShiftTab => 0, | ||
| 99 | Kind::EnterEnd | Kind::Delete => length, | ||
| 100 | Kind::EnterMiddle | Kind::TabMiddle | Kind::Paste | Kind::Bullets | Kind::Numbering => { | ||
| 101 | middle() | ||
| 102 | } | ||
| 103 | }; | ||
| 104 | let position = TextPosition { paragraph, offset }; | ||
| 105 | if editor.select([position; 2].into()).is_err() { | ||
| 106 | return false; | ||
| 107 | } | ||
| 108 | let result = match kind { | ||
| 109 | Kind::EnterStart | Kind::EnterMiddle | Kind::EnterEnd => editor.enter(engine, false), | ||
| 110 | Kind::Backspace => editor.delete(engine, true).map(|_| ()), | ||
| 111 | Kind::Delete => editor.delete(engine, false).map(|_| ()), | ||
| 112 | Kind::Tab | Kind::TabMiddle => editor.tab(engine, false), | ||
| 113 | Kind::ShiftTab => editor.tab(engine, true), | ||
| 114 | Kind::Paste => editor.paste(engine, "Line one\r\nLine two", 1033), | ||
| 115 | Kind::Bullets => editor.format(engine, Formatting::Bullets), | ||
| 116 | Kind::Numbering => editor.format(engine, Formatting::Numbering), | ||
| 117 | }; | ||
| 118 | result.is_ok() | ||
| 119 | } | ||
| 120 | |||
| 121 | /// Every paragraph on the page, cells' included, by identity. | ||
| 122 | fn paragraphs(page: &Page) -> BTreeMap<ExGuid, PageParagraph> { | ||
| 123 | fn walk(list: &[PageParagraph], out: &mut BTreeMap<ExGuid, PageParagraph>) { | ||
| 124 | for paragraph in list { | ||
| 125 | out.insert(paragraph.id, paragraph.clone()); | ||
| 126 | if let ParagraphContent::Table(table) = &paragraph.content { | ||
| 127 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | ||
| 128 | walk(&cell.paragraphs, out); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | let mut out = BTreeMap::new(); | ||
| 134 | for object in &page.objects { | ||
| 135 | match object { | ||
| 136 | PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), | ||
| 137 | PageObject::Title(title) => { | ||
| 138 | for outline in &title.outlines { | ||
| 139 | walk(&outline.paragraphs, &mut out); | ||
| 140 | } | ||
| 141 | } | ||
| 142 | _ => {} | ||
| 143 | } | ||
| 144 | } | ||
| 145 | out | ||
| 146 | } | ||
| 147 | |||
| 148 | /// The fields two values disagree on, as `name: saved -> reread`. | ||
| 149 | fn fields<T: serde::Serialize>(saved: &T, reread: &T) -> String { | ||
| 150 | let (serde_json::Value::Object(a), serde_json::Value::Object(b)) = ( | ||
| 151 | serde_json::to_value(saved).unwrap(), | ||
| 152 | serde_json::to_value(reread).unwrap(), | ||
| 153 | ) else { | ||
| 154 | return "differs".into(); | ||
| 155 | }; | ||
| 156 | a.iter() | ||
| 157 | .filter(|(key, value)| b.get(*key) != Some(value)) | ||
| 158 | .map(|(key, value)| format!("{key}: {value} -> {}", b[key])) | ||
| 159 | .collect::<Vec<_>>() | ||
| 160 | .join(", ") | ||
| 161 | } | ||
| 162 | |||
| 163 | /// The first field a reread paragraph lost, as a signature without identities. | ||
| 164 | fn difference(saved: &Page, reread: &Page) -> String { | ||
| 165 | let (a, b) = (paragraphs(saved), paragraphs(reread)); | ||
| 166 | for (id, x) in &a { | ||
| 167 | let Some(y) = b.get(id) else { | ||
| 168 | return "a paragraph identity is missing".into(); | ||
| 169 | }; | ||
| 170 | macro_rules! field { | ||
| 171 | ($($name:ident),*) => {$( | ||
| 172 | if x.$name != y.$name { | ||
| 173 | return format!( | ||
| 174 | "{} {:?} reread as {:?}", | ||
| 175 | stringify!($name), | ||
| 176 | x.$name, | ||
| 177 | y.$name | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | )*}; | ||
| 181 | } | ||
| 182 | if x.lists.len() == y.lists.len() && x.lists != y.lists { | ||
| 183 | let shared = a | ||
| 184 | .values() | ||
| 185 | .any(|other| other.id != x.id && other.lists.iter().any(|l| x.lists.contains(l))); | ||
| 186 | return format!("list identities (shared {shared})"); | ||
| 187 | } | ||
| 188 | if x.format != y.format { | ||
| 189 | return format!("paragraph format {}", fields(&x.format, &y.format)); | ||
| 190 | } | ||
| 191 | field!(parent, level, style, lists, tags, collapsed); | ||
| 192 | if let (Some(p), Some(q)) = (x.text(), y.text()) { | ||
| 193 | if p.id != q.id { | ||
| 194 | return "text identity".into(); | ||
| 195 | } | ||
| 196 | if p.text.text() != q.text.text() { | ||
| 197 | return "text".into(); | ||
| 198 | } | ||
| 199 | if p.text.spans().len() != q.text.spans().len() { | ||
| 200 | return format!( | ||
| 201 | "{} spans reread as {}", | ||
| 202 | p.text.spans().len(), | ||
| 203 | q.text.spans().len() | ||
| 204 | ); | ||
| 205 | } | ||
| 206 | for (s, t) in p.text.spans().iter().zip(q.text.spans()) { | ||
| 207 | if s.format != t.format { | ||
| 208 | return format!("span {}", fields(&s.format, &t.format)); | ||
| 209 | } | ||
| 210 | } | ||
| 211 | if p != q { | ||
| 212 | return "text object".into(); | ||
| 213 | } | ||
| 214 | } | ||
| 215 | if x != y { | ||
| 216 | return "content".into(); | ||
| 217 | } | ||
| 218 | } | ||
| 219 | if a.len() != b.len() { | ||
| 220 | return "extra paragraphs".into(); | ||
| 221 | } | ||
| 222 | let kind = |definition: &onestore::page::Definition| match &definition.kind { | ||
| 223 | onestore::document::Kind::List { .. } => "list", | ||
| 224 | onestore::document::Kind::Style { .. } => "style", | ||
| 225 | _ => "tag", | ||
| 226 | }; | ||
| 227 | for (id, definition) in &saved.definitions { | ||
| 228 | match reread.definitions.get(id) { | ||
| 229 | None => return format!("{} definition missing", kind(definition)), | ||
| 230 | Some(other) if other != definition => { | ||
| 231 | return format!( | ||
| 232 | "{} definition {}", | ||
| 233 | kind(definition), | ||
| 234 | fields(&definition.format, &other.format) | ||
| 235 | ); | ||
| 236 | } | ||
| 237 | _ => {} | ||
| 238 | } | ||
| 239 | } | ||
| 240 | if let Some(extra) = reread | ||
| 241 | .definitions | ||
| 242 | .iter() | ||
| 243 | .find(|(id, _)| !saved.definitions.contains_key(id)) | ||
| 244 | { | ||
| 245 | return format!("extra {} definition", kind(extra.1)); | ||
| 246 | } | ||
| 247 | for (x, y) in saved.objects.iter().zip(&reread.objects) { | ||
| 248 | if x != y { | ||
| 249 | return match (x, y) { | ||
| 250 | (PageObject::Outline(x), PageObject::Outline(y)) => format!( | ||
| 251 | "outline {}", | ||
| 252 | fields( | ||
| 253 | &Outline { | ||
| 254 | paragraphs: Vec::new(), | ||
| 255 | ..x.clone() | ||
| 256 | }, | ||
| 257 | &Outline { | ||
| 258 | paragraphs: Vec::new(), | ||
| 259 | ..y.clone() | ||
| 260 | } | ||
| 261 | ) | ||
| 262 | ), | ||
| 263 | _ => format!("page object {x:?}\n reread as {y:?}"), | ||
| 264 | }; | ||
| 265 | } | ||
| 266 | } | ||
| 267 | format!( | ||
| 268 | "page {} ({} objects reread as {})", | ||
| 269 | fields( | ||
| 270 | &Page { | ||
| 271 | objects: Vec::new(), | ||
| 272 | definitions: Default::default(), | ||
| 273 | ..saved.clone() | ||
| 274 | }, | ||
| 275 | &Page { | ||
| 276 | objects: Vec::new(), | ||
| 277 | definitions: Default::default(), | ||
| 278 | ..reread.clone() | ||
| 279 | } | ||
| 280 | ), | ||
| 281 | saved.objects.len(), | ||
| 282 | reread.objects.len() | ||
| 283 | ) | ||
| 284 | } | ||
| 285 | |||
| 286 | #[derive(Default)] | ||
| 287 | struct Tally { | ||
| 288 | samples: usize, | ||
| 289 | failures: BTreeMap<String, Vec<String>>, | ||
| 290 | } | ||
| 291 | |||
| 292 | fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut Tally) { | ||
| 293 | let section = std::fs::read(path).unwrap(); | ||
| 294 | for (space, page) in pages(&section) { | ||
| 295 | let Ok(editor) = CanvasEditor::from_page(page.clone(), engine) else { | ||
| 296 | continue; | ||
| 297 | }; | ||
| 298 | let Ok(before) = editor.page() else { | ||
| 299 | continue; | ||
| 300 | }; | ||
| 301 | let outlines: Vec<ExGuid> = editor | ||
| 302 | .outlines() | ||
| 303 | .iter() | ||
| 304 | .filter(|outline| !outline.title) | ||
| 305 | .map(|outline| outline.id) | ||
| 306 | .collect(); | ||
| 307 | for outline in outlines { | ||
| 308 | let plans: Vec<Vec<Kind>> = KINDS | ||
| 309 | .iter() | ||
| 310 | .map(|kind| vec![*kind]) | ||
| 311 | .chain((0..3).map(|_| { | ||
| 312 | (0..2 + random.below(3)) | ||
| 313 | .map(|_| KINDS[random.below(KINDS.len())]) | ||
| 314 | .collect() | ||
| 315 | })) | ||
| 316 | .collect(); | ||
| 317 | for plan in plans { | ||
| 318 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); | ||
| 319 | editor.focus_outline(outline).unwrap(); | ||
| 320 | let mut applied = 0; | ||
| 321 | for kind in &plan { | ||
| 322 | applied += usize::from(apply(&mut editor, engine, *kind, random)); | ||
| 323 | } | ||
| 324 | let Ok(after) = editor.page() else { continue }; | ||
| 325 | if applied == 0 || after == before { | ||
| 326 | continue; | ||
| 327 | } | ||
| 328 | tally.samples += 1; | ||
| 329 | if let Some(dir) = std::env::var_os("CANVAS_SWEEP_DUMP") { | ||
| 330 | let value = serde_json::json!({"section": path.canonicalize().unwrap(), "space": space, "page": after}); | ||
| 331 | std::fs::write( | ||
| 332 | Path::new(&dir).join(format!("{}.json", tally.samples)), | ||
| 333 | serde_json::to_vec(&value).unwrap(), | ||
| 334 | ) | ||
| 335 | .unwrap(); | ||
| 336 | } | ||
| 337 | let failure = match PreparedEdit::page(&section, space, &after, "Sweep") { | ||
| 338 | Err(error) => Some(error.message.to_owned()), | ||
| 339 | Ok(saved) => match pages(saved.as_bytes()) | ||
| 340 | .into_iter() | ||
| 341 | .find(|(id, _)| *id == space) | ||
| 342 | { | ||
| 343 | // An untitled page's title follows its first line as the writer stores it. | ||
| 344 | Some((_, reread)) | ||
| 345 | if Page { | ||
| 346 | title: after.title.clone(), | ||
| 347 | ..reread.clone() | ||
| 348 | } == after => | ||
| 349 | { | ||
| 350 | None | ||
| 351 | } | ||
| 352 | Some((_, reread)) => Some(difference(&after, &reread)), | ||
| 353 | None => Some("the page does not reread".into()), | ||
| 354 | }, | ||
| 355 | }; | ||
| 356 | if let Some(failure) = failure { | ||
| 357 | tally.failures.entry(failure).or_default().push(format!( | ||
| 358 | "{plan:?} on {:?} in {}", | ||
| 359 | page.title, | ||
| 360 | path.display() | ||
| 361 | )); | ||
| 362 | } | ||
| 363 | } | ||
| 364 | } | ||
| 365 | } | ||
| 366 | } | ||
| 367 | |||
| 368 | #[test] | ||
| 369 | fn structural_edits_reread_as_saved() { | ||
| 370 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); | ||
| 371 | let mut paths = vec![root.join("evidence/structural-edits/probe-section/probe.one")]; | ||
| 372 | if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") { | ||
| 373 | paths.extend(std::env::split_paths(&extra)); | ||
| 374 | } | ||
| 375 | let seed = std::env::var("CANVAS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap()); | ||
| 376 | let mut random = Random(seed); | ||
| 377 | let mut engine = TextEngine::default(); | ||
| 378 | let mut tally = Tally::default(); | ||
| 379 | for path in &paths { | ||
| 380 | sweep(path, &mut random, &mut engine, &mut tally); | ||
| 381 | } | ||
| 382 | let failed: usize = tally.failures.values().map(Vec::len).sum(); | ||
| 383 | let mut report = String::new(); | ||
| 384 | for (failure, samples) in &tally.failures { | ||
| 385 | report += &format!("{} x {failure}\n e.g. {}\n", samples.len(), samples[0]); | ||
| 386 | if std::env::var_os("CANVAS_SWEEP_ALL").is_some() { | ||
| 387 | for sample in &samples[1..] { | ||
| 388 | report += &format!(" e.g. {sample}\n"); | ||
| 389 | } | ||
| 390 | } | ||
| 391 | } | ||
| 392 | println!( | ||
| 393 | "{} of {} edits reread as saved\n{report}", | ||
| 394 | tally.samples - failed, | ||
| 395 | tally.samples | ||
| 396 | ); | ||
| 397 | assert_eq!(failed, 0, "{report}"); | ||
| 398 | } | ||
| 399 | |||
| 400 | /// Where a scripted edit puts the caret: in the first paragraph containing the text, before | ||
| 401 | /// or after it. | ||
| 402 | enum Caret { | ||
| 403 | Before(&'static str), | ||
| 404 | After(&'static str), | ||
| 405 | } | ||
| 406 | |||
| 407 | /// A key or command the scripted edits press. | ||
| 408 | enum Press { | ||
| 409 | Enter, | ||
| 410 | Backspace, | ||
| 411 | Delete, | ||
| 412 | Tab, | ||
| 413 | ShiftTab, | ||
| 414 | Paste(&'static str), | ||
| 415 | Numbering, | ||
| 416 | } | ||
| 417 | |||
| 418 | /// Opens the first page titled `title`, presses each `(caret, key)` in one editing session and | ||
| 419 | /// saves the page once, checking that it rereads as saved. | ||
| 420 | fn edit(section: &mut Vec<u8>, title: &str, keys: &[(Caret, Press)], engine: &mut TextEngine) { | ||
| 421 | let (space, page) = pages(section) | ||
| 422 | .into_iter() | ||
| 423 | .find(|(_, page)| page.title == title) | ||
| 424 | .unwrap(); | ||
| 425 | let mut editor = CanvasEditor::from_page(page, engine).unwrap(); | ||
| 426 | for (caret, key) in keys { | ||
| 427 | let needle = match caret { | ||
| 428 | Caret::Before(needle) | Caret::After(needle) => *needle, | ||
| 429 | }; | ||
| 430 | let outline = editor | ||
| 431 | .outlines() | ||
| 432 | .iter() | ||
| 433 | .find(|outline| { | ||
| 434 | outline | ||
| 435 | .document() | ||
| 436 | .paragraphs() | ||
| 437 | .any(|p| p.text().contains(needle)) | ||
| 438 | }) | ||
| 439 | .unwrap() | ||
| 440 | .id; | ||
| 441 | editor.focus_outline(outline).unwrap(); | ||
| 442 | let (paragraph, text) = editor | ||
| 443 | .active_outline() | ||
| 444 | .document() | ||
| 445 | .paragraphs() | ||
| 446 | .enumerate() | ||
| 447 | .find(|(_, p)| p.text().contains(needle)) | ||
| 448 | .unwrap(); | ||
| 449 | let at = text.text().find(needle).unwrap() | ||
| 450 | + match caret { | ||
| 451 | Caret::Before(_) => 0, | ||
| 452 | Caret::After(_) => needle.len(), | ||
| 453 | }; | ||
| 454 | let offset = text.utf16_offset(at).unwrap(); | ||
| 455 | editor | ||
| 456 | .select([TextPosition { paragraph, offset }; 2].into()) | ||
| 457 | .unwrap(); | ||
| 458 | match key { | ||
| 459 | Press::Enter => editor.enter(engine, false).unwrap(), | ||
| 460 | Press::Backspace => assert!(editor.delete(engine, true).unwrap()), | ||
| 461 | Press::Delete => assert!(editor.delete(engine, false).unwrap()), | ||
| 462 | Press::Tab => editor.tab(engine, false).unwrap(), | ||
| 463 | Press::ShiftTab => editor.tab(engine, true).unwrap(), | ||
| 464 | Press::Paste(text) => editor.paste(engine, text, 1033).unwrap(), | ||
| 465 | Press::Numbering => editor.format(engine, Formatting::Numbering).unwrap(), | ||
| 466 | } | ||
| 467 | } | ||
| 468 | let after = editor.page().unwrap(); | ||
| 469 | let saved = PreparedEdit::page(section, space, &after, "Snowbound") | ||
| 470 | .unwrap() | ||
| 471 | .as_bytes() | ||
| 472 | .to_vec(); | ||
| 473 | let (_, reread) = pages(&saved) | ||
| 474 | .into_iter() | ||
| 475 | .find(|(id, _)| *id == space) | ||
| 476 | .unwrap(); | ||
| 477 | assert_eq!( | ||
| 478 | Page { | ||
| 479 | title: after.title.clone(), | ||
| 480 | ..reread.clone() | ||
| 481 | }, | ||
| 482 | after, | ||
| 483 | "{title}" | ||
| 484 | ); | ||
| 485 | *section = saved; | ||
| 486 | } | ||
| 487 | |||
| 488 | /// Group indents, pasted lines, splits and joins around note tags, headings, hyperlinks and | ||
| 489 | /// a recording link, and several of them in one save, written into the probe section and two | ||
| 490 | /// native feature sections. `CANVAS_STRUCTURAL_EXPORT` names a new directory receiving them | ||
| 491 | /// for a cold OneNote reopen (`corpus/structural-edit`). | ||
| 492 | #[test] | ||
| 493 | fn structural_edits_write_what_onenote_reads() { | ||
| 494 | use Caret::{After, Before}; | ||
| 495 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); | ||
| 496 | let read = |path: &str| std::fs::read(root.join(path)).unwrap(); | ||
| 497 | let mut engine = TextEngine::default(); | ||
| 498 | let mut probe = read("evidence/structural-edits/probe-section/probe.one"); | ||
| 499 | for (title, keys) in [ | ||
| 500 | ("c6-plain", vec![(Before("Above"), Press::Tab)]), | ||
| 501 | ("c6-first", vec![(Before("Target text"), Press::ShiftTab)]), | ||
| 502 | ( | ||
| 503 | "c10-tab-parent", | ||
| 504 | vec![(Before("Omega"), Press::Tab), (Before("Child"), Press::Tab)], | ||
| 505 | ), | ||
| 506 | ("c1-todo", vec![(Before("Target text"), Press::Enter)]), | ||
| 507 | ( | ||
| 508 | "c1-bullet", | ||
| 509 | vec![(After("Target "), Press::Paste("Line one\r\nLine two"))], | ||
| 510 | ), | ||
| 511 | ("c9-h1", vec![(After("ing"), Press::Enter)]), | ||
| 512 | ( | ||
| 513 | "c2-q", | ||
| 514 | vec![ | ||
| 515 | (Before("Target "), Press::Enter), | ||
| 516 | (Before("Target "), Press::Backspace), | ||
| 517 | (Before("Xtext"), Press::Backspace), | ||
| 518 | ], | ||
| 519 | ), | ||
| 520 | ( | ||
| 521 | "c3-q", | ||
| 522 | vec![ | ||
| 523 | (After("Above"), Press::Enter), | ||
| 524 | (After("Above"), Press::Delete), | ||
| 525 | ], | ||
| 526 | ), | ||
| 527 | ( | ||
| 528 | "c4-imp", | ||
| 529 | vec![ | ||
| 530 | (After("First li"), Press::Paste("Line one\r\nLine two")), | ||
| 531 | (Before("Line one"), Press::Backspace), | ||
| 532 | ], | ||
| 533 | ), | ||
| 534 | ("c1-q", vec![(After("Target "), Press::Tab)]), | ||
| 535 | ( | ||
| 536 | "c1-rem", | ||
| 537 | vec![ | ||
| 538 | (Before("Target text"), Press::Numbering), | ||
| 539 | (Before("Target text"), Press::Tab), | ||
| 540 | (Before("Target text"), Press::ShiftTab), | ||
| 541 | ], | ||
| 542 | ), | ||
| 543 | ] { | ||
| 544 | edit(&mut probe, title, &keys, &mut engine); | ||
| 545 | } | ||
| 546 | let mut features = read("corpus/m6/native-features-01/notebook/Features.one"); | ||
| 547 | edit( | ||
| 548 | &mut features, | ||
| 549 | "Files and recording", | ||
| 550 | &[(After("Recording "), Press::Enter)], | ||
| 551 | &mut engine, | ||
| 552 | ); | ||
| 553 | let mut links = read("corpus/m6/native-link-controls-01/notebook/Links.one"); | ||
| 554 | edit( | ||
| 555 | &mut links, | ||
| 556 | "Hyperlink boundary controls", | ||
| 557 | &[ | ||
| 558 | ( | ||
| 559 | Before("\u{fddf}HYPERLINK \"https://example.invalid/label/0"), | ||
| 560 | Press::Enter, | ||
| 561 | ), | ||
| 562 | (After("label/4\"label"), Press::Delete), | ||
| 563 | ], | ||
| 564 | &mut engine, | ||
| 565 | ); | ||
| 566 | if let Some(directory) = std::env::var_os("CANVAS_STRUCTURAL_EXPORT") { | ||
| 567 | let directory = Path::new(&directory); | ||
| 568 | std::fs::create_dir(directory).unwrap(); | ||
| 569 | for (name, bytes) in [ | ||
| 570 | ("probe.one", &probe), | ||
| 571 | ("Features.one", &features), | ||
| 572 | ("Links.one", &links), | ||
| 573 | ] { | ||
| 574 | std::fs::write(directory.join(name), bytes).unwrap(); | ||
| 575 | } | ||
| 576 | } | ||
| 577 | } | ||
crates/draw/Cargo.toml+2-1| ... | @@ -7,13 +7,14 @@ publish = false | ... | @@ -7,13 +7,14 @@ publish = false |
| 7 | [features] | 7 | [features] |
| 8 | default = ["render"] | 8 | default = ["render"] |
| 9 | # The wgpu renderer; `edit` needs only parley. | 9 | # The wgpu renderer; `edit` needs only parley. |
| 10 | render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"] | 10 | render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash", "dep:wgpu"] |
| 11 | 11 | ||
| 12 | [dependencies] | 12 | [dependencies] |
| 13 | parley.workspace = true | 13 | parley.workspace = true |
| 14 | 14 | ||
| 15 | bytemuck = { version = "1.25", features = ["derive"], optional = true } | 15 | bytemuck = { version = "1.25", features = ["derive"], optional = true } |
| 16 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } | 16 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } |
| 17 | linebender_resource_handle = { version = "0.1.1", optional = true } | ||
| 17 | roxmltree = { version = "0.21", optional = true } | 18 | roxmltree = { version = "0.21", optional = true } |
| 18 | swash = { version = "0.2.10", optional = true } | 19 | swash = { version = "0.2.10", optional = true } |
| 19 | wgpu = { workspace = true, optional = true } | 20 | wgpu = { workspace = true, optional = true } |
crates/draw/src/edit.rs+104| ... | @@ -183,6 +183,69 @@ impl Clicks { | ... | @@ -183,6 +183,69 @@ impl Clicks { |
| 183 | } | 183 | } |
| 184 | } | 184 | } |
| 185 | 185 | ||
| 186 | /// The caret's width in logical pixels, as AppKit's insertion indicator draws it. | ||
| 187 | pub const CARET_WIDTH: f32 = 2.0; | ||
| 188 | |||
| 189 | /// The caret's opacity `since` it last moved, and how long that opacity holds. As AppKit's | ||
| 190 | /// insertion indicator: solid for 650 ms, then 350 ms off and 650 ms on, each phase | ||
| 191 | /// opening with a fade in four 37.5 ms steps. | ||
| 192 | pub fn caret_blink(since: Duration) -> (f32, Duration) { | ||
| 193 | const STEP: u64 = 37_500; | ||
| 194 | const ON: u64 = 650_000; | ||
| 195 | const OFF: u64 = 350_000; | ||
| 196 | let micros = u64::try_from(since.as_micros()).unwrap_or(u64::MAX); | ||
| 197 | let Some(cycle) = micros.checked_sub(ON) else { | ||
| 198 | return (1.0, Duration::from_micros(ON + STEP - micros)); | ||
| 199 | }; | ||
| 200 | let (into, length, fading_in) = match cycle % (ON + OFF) { | ||
| 201 | phase if phase < OFF => (phase, OFF, false), | ||
| 202 | phase => (phase - OFF, ON, true), | ||
| 203 | }; | ||
| 204 | let step = into / STEP; | ||
| 205 | // A settled phase holds into the next one's first step, which shows the same. | ||
| 206 | let (faded, hold) = if step < 4 { | ||
| 207 | (step as f32 / 4.0, STEP * (step + 1) - into) | ||
| 208 | } else { | ||
| 209 | (1.0, length - into + STEP) | ||
| 210 | }; | ||
| 211 | ( | ||
| 212 | if fading_in { faded } else { 1.0 - faded }, | ||
| 213 | Duration::from_micros(hold), | ||
| 214 | ) | ||
| 215 | } | ||
| 216 | |||
| 217 | /// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright | ||
| 218 | /// as AppKit's layers do, which blend in sRGB rather than linear light. | ||
| 219 | pub fn caret_color(caret: [f32; 4], backdrop: [f32; 4], opacity: f32) -> [f32; 4] { | ||
| 220 | let encode = |value: f32| { | ||
| 221 | if value <= 0.003_130_8 { | ||
| 222 | value * 12.92 | ||
| 223 | } else { | ||
| 224 | 1.055 * value.powf(1.0 / 2.4) - 0.055 | ||
| 225 | } | ||
| 226 | }; | ||
| 227 | let decode = |value: f32| { | ||
| 228 | if value <= 0.040_45 { | ||
| 229 | value / 12.92 | ||
| 230 | } else { | ||
| 231 | ((value + 0.055) / 1.055).powf(2.4) | ||
| 232 | } | ||
| 233 | }; | ||
| 234 | let luminance = |[red, green, blue]: [f32; 3]| 0.2126 * red + 0.7152 * green + 0.0722 * blue; | ||
| 235 | let [under, over] = [backdrop, caret].map(|color| [color[0], color[1], color[2]]); | ||
| 236 | let shown: [f32; 3] = std::array::from_fn(|channel| { | ||
| 237 | let [under, over] = [under[channel], over[channel]].map(encode); | ||
| 238 | decode(under + opacity * (over - under)) | ||
| 239 | }); | ||
| 240 | let span = luminance(over) - luminance(under); | ||
| 241 | let alpha = if span.abs() < 1e-4 { | ||
| 242 | opacity | ||
| 243 | } else { | ||
| 244 | ((luminance(shown) - luminance(under)) / span).clamp(0.0, 1.0) | ||
| 245 | }; | ||
| 246 | [caret[0], caret[1], caret[2], caret[3] * alpha] | ||
| 247 | } | ||
| 248 | |||
| 186 | /// The selection a drag makes from the selection its press made to the one under the | 249 | /// The selection a drag makes from the selection its press made to the one under the |
| 187 | /// pointer, both by `unit`, ordered by `position`. Words and paragraphs stay whole, so | 250 | /// pointer, both by `unit`, ordered by `position`. Words and paragraphs stay whole, so |
| 188 | /// the press's selection stays covered when the drag reverses past it. | 251 | /// the press's selection stays covered when the drag reverses past it. |
| ... | @@ -454,4 +517,45 @@ mod tests { | ... | @@ -454,4 +517,45 @@ mod tests { |
| 454 | "extending keeps the anchor" | 517 | "extending keeps the anchor" |
| 455 | ); | 518 | ); |
| 456 | } | 519 | } |
| 520 | |||
| 521 | #[test] | ||
| 522 | fn caret_fades_as_bright_as_appkit_s_srgb_blend() { | ||
| 523 | let linear = |value: f32| ((value / 255.0 + 0.055) / 1.055).powf(2.4); | ||
| 524 | let pink = [248.0, 79.0, 158.0].map(linear); | ||
| 525 | let pink = [pink[0], pink[1], pink[2], 1.0]; | ||
| 526 | let white = [1.0; 4]; | ||
| 527 | assert_eq!(caret_color(pink, white, 1.0)[3], 1.0); | ||
| 528 | assert_eq!(caret_color(pink, white, 0.0)[3], 0.0); | ||
| 529 | // Half the caret over white blends to sRGB green 167; TextEdit measures 170. | ||
| 530 | let alpha = caret_color(pink, white, 0.5)[3]; | ||
| 531 | let green = 1.0 + alpha * (pink[1] - 1.0); | ||
| 532 | assert!((green - linear(167.5)).abs() < 0.02, "{alpha}"); | ||
| 533 | } | ||
| 534 | |||
| 535 | #[test] | ||
| 536 | fn caret_blinks_on_appkit_s_timing() { | ||
| 537 | let at = |millis: f64| { | ||
| 538 | let (opacity, hold) = caret_blink(Duration::from_secs_f64(millis / 1000.0)); | ||
| 539 | (opacity, hold.as_secs_f64() * 1000.0) | ||
| 540 | }; | ||
| 541 | let close = |(opacity, hold): (f32, f64), expected: (f32, f64)| { | ||
| 542 | assert_eq!(opacity, expected.0); | ||
| 543 | assert!((hold - expected.1).abs() < 0.01, "{hold} ms, not {}", expected.1); | ||
| 544 | }; | ||
| 545 | close(at(0.0), (1.0, 687.5)); | ||
| 546 | close(at(687.5), (0.75, 37.5)); | ||
| 547 | close(at(760.0), (0.5, 2.5)); | ||
| 548 | close(at(800.0), (0.0, 237.5)); | ||
| 549 | close(at(1037.5), (0.25, 37.5)); | ||
| 550 | close(at(1150.0), (1.0, 537.5)); | ||
| 551 | close(at(1687.5), (0.75, 37.5)); | ||
| 552 | let mut changes = 0; | ||
| 553 | let mut previous = at(1000.0).0; | ||
| 554 | for millis in 1001..2001 { | ||
| 555 | let opacity = at(f64::from(millis)).0; | ||
| 556 | changes += usize::from(opacity != previous); | ||
| 557 | previous = opacity; | ||
| 558 | } | ||
| 559 | assert_eq!(changes, 8, "four fade steps each way per second"); | ||
| 560 | } | ||
| 457 | } | 561 | } |
crates/draw/src/render.rs+198-48| ... | @@ -5,6 +5,7 @@ pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; | ... | @@ -5,6 +5,7 @@ pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; |
| 5 | 5 | ||
| 6 | use bytemuck::{Pod, Zeroable}; | 6 | use bytemuck::{Pod, Zeroable}; |
| 7 | use image::ImageDecoder; | 7 | use image::ImageDecoder; |
| 8 | use linebender_resource_handle::WeakBlob; | ||
| 8 | use parley::fontique::Blob; | 9 | use parley::fontique::Blob; |
| 9 | use std::{ | 10 | use std::{ |
| 10 | collections::{HashMap, HashSet}, | 11 | collections::{HashMap, HashSet}, |
| ... | @@ -22,6 +23,8 @@ const MAX_VERTICES: usize = 65_536; | ... | @@ -22,6 +23,8 @@ const MAX_VERTICES: usize = 65_536; |
| 22 | /// Decoded bytes of all images one frame may paint. | 23 | /// Decoded bytes of all images one frame may paint. |
| 23 | pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; | 24 | pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; |
| 24 | const MAX_IMAGES: usize = 256; | 25 | const MAX_IMAGES: usize = 256; |
| 26 | /// Decoded bytes one picture may take before `RasterImage::decode` shrinks it. | ||
| 27 | const MAX_DECODE_BYTES: u64 = 256 * 1024 * 1024; | ||
| 25 | 28 | ||
| 26 | #[derive(Clone)] | 29 | #[derive(Clone)] |
| 27 | pub struct RasterImage { | 30 | pub struct RasterImage { |
| ... | @@ -29,39 +32,84 @@ pub struct RasterImage { | ... | @@ -29,39 +32,84 @@ pub struct RasterImage { |
| 29 | pixels: Blob<u8>, | 32 | pixels: Blob<u8>, |
| 30 | } | 33 | } |
| 31 | 34 | ||
| 32 | impl RasterImage { | 35 | /// A PNG, JPEG, GIF or TIFF decoder for `encoded`, refusing pictures past the decode limits. |
| 33 | pub fn decode(encoded: &[u8]) -> Result<Self, RenderError> { | 36 | fn decoder(encoded: &[u8]) -> Result<impl ImageDecoder + '_, RenderError> { |
| 34 | if encoded.len() as u64 > MAX_IMAGE_BYTES { | 37 | if encoded.len() as u64 > MAX_DECODE_BYTES { |
| 35 | return Err(RenderError::ImageBudget); | 38 | return Err(RenderError::ImageBudget); |
| 39 | } | ||
| 40 | let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; | ||
| 41 | let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); | ||
| 42 | let mut limits = image::Limits::default(); | ||
| 43 | limits.max_image_width = Some(16_384); | ||
| 44 | limits.max_image_height = Some(16_384); | ||
| 45 | limits.max_alloc = Some(MAX_DECODE_BYTES); | ||
| 46 | reader.limits(limits.clone()); | ||
| 47 | let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; | ||
| 48 | let (width, height) = decoder.dimensions(); | ||
| 49 | if u64::from(width) * u64::from(height) * 4 > MAX_DECODE_BYTES | ||
| 50 | || decoder.total_bytes() > MAX_DECODE_BYTES | ||
| 51 | { | ||
| 52 | return Err(RenderError::ImageBudget); | ||
| 53 | } | ||
| 54 | limits | ||
| 55 | .reserve(decoder.total_bytes()) | ||
| 56 | .map_err(RenderError::ImageDecode)?; | ||
| 57 | decoder | ||
| 58 | .set_limits(limits) | ||
| 59 | .map_err(RenderError::ImageDecode)?; | ||
| 60 | Ok(decoder) | ||
| 61 | } | ||
| 62 | |||
| 63 | /// Premultiplies straight-alpha sRGB RGBA in linear light, so filtering never bleeds the | ||
| 64 | /// colour of transparent pixels. | ||
| 65 | fn premultiply(pixels: &mut [u8]) { | ||
| 66 | for pixel in pixels.chunks_exact_mut(4) { | ||
| 67 | let alpha = pixel[3]; | ||
| 68 | if alpha == 255 { | ||
| 69 | continue; | ||
| 36 | } | 70 | } |
| 37 | let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; | 71 | let linear = srgb(pixel[0], pixel[1], pixel[2]); |
| 38 | let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); | 72 | for (byte, value) in pixel[..3].iter_mut().zip(linear) { |
| 39 | let mut limits = image::Limits::default(); | 73 | *byte = srgb_byte(value * f32::from(alpha) / 255.0); |
| 40 | limits.max_image_width = Some(16_384); | ||
| 41 | limits.max_image_height = Some(16_384); | ||
| 42 | limits.max_alloc = Some(MAX_IMAGE_BYTES); | ||
| 43 | reader.limits(limits.clone()); | ||
| 44 | let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; | ||
| 45 | let (width, height) = decoder.dimensions(); | ||
| 46 | if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES | ||
| 47 | || decoder.total_bytes() > MAX_IMAGE_BYTES | ||
| 48 | { | ||
| 49 | return Err(RenderError::ImageBudget); | ||
| 50 | } | 74 | } |
| 51 | limits | 75 | } |
| 52 | .reserve(decoder.total_bytes()) | 76 | } |
| 53 | .map_err(RenderError::ImageDecode)?; | 77 | |
| 54 | decoder | 78 | impl RasterImage { |
| 55 | .set_limits(limits) | 79 | /// The pixel size of an encoded picture, read from its header, or why `decode` would |
| 56 | .map_err(RenderError::ImageDecode)?; | 80 | /// refuse it. |
| 57 | let pixels = image::DynamicImage::from_decoder(decoder) | 81 | pub fn measure(encoded: &[u8]) -> Result<[u32; 2], RenderError> { |
| 82 | let (width, height) = decoder(encoded)?.dimensions(); | ||
| 83 | Ok([width, height]) | ||
| 84 | } | ||
| 85 | |||
| 86 | /// Decodes a picture, shrinking it to at most `within` pixels along each axis. | ||
| 87 | pub fn decode(encoded: &[u8], within: [u32; 2]) -> Result<Self, RenderError> { | ||
| 88 | let decoder = decoder(encoded)?; | ||
| 89 | let mut pixels = image::DynamicImage::from_decoder(decoder) | ||
| 58 | .map_err(RenderError::ImageDecode)? | 90 | .map_err(RenderError::ImageDecode)? |
| 59 | .into_rgba8(); | 91 | .into_rgba8(); |
| 60 | Self::new([width, height], pixels.into_raw()) | 92 | premultiply(&mut pixels); |
| 93 | let size = [pixels.width(), pixels.height()]; | ||
| 94 | let shown = [0, 1].map(|axis| size[axis].min(within[axis]).max(1)); | ||
| 95 | if shown != size { | ||
| 96 | pixels = image::imageops::resize( | ||
| 97 | &pixels, | ||
| 98 | shown[0], | ||
| 99 | shown[1], | ||
| 100 | image::imageops::FilterType::Triangle, | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | Self::premultiplied(shown, pixels.into_raw()) | ||
| 61 | } | 104 | } |
| 62 | 105 | ||
| 63 | /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. | 106 | /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. |
| 64 | pub fn new(size: [u32; 2], mut pixels: Vec<u8>) -> Result<Self, RenderError> { | 107 | pub fn new(size: [u32; 2], mut pixels: Vec<u8>) -> Result<Self, RenderError> { |
| 108 | premultiply(&mut pixels); | ||
| 109 | Self::premultiplied(size, pixels) | ||
| 110 | } | ||
| 111 | |||
| 112 | fn premultiplied(size: [u32; 2], pixels: Vec<u8>) -> Result<Self, RenderError> { | ||
| 65 | let bytes = u64::from(size[0]) | 113 | let bytes = u64::from(size[0]) |
| 66 | .checked_mul(u64::from(size[1])) | 114 | .checked_mul(u64::from(size[1])) |
| 67 | .and_then(|v| v.checked_mul(4)) | 115 | .and_then(|v| v.checked_mul(4)) |
| ... | @@ -72,24 +120,16 @@ impl RasterImage { | ... | @@ -72,24 +120,16 @@ impl RasterImage { |
| 72 | if bytes > MAX_IMAGE_BYTES { | 120 | if bytes > MAX_IMAGE_BYTES { |
| 73 | return Err(RenderError::ImageBudget); | 121 | return Err(RenderError::ImageBudget); |
| 74 | } | 122 | } |
| 75 | // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. | ||
| 76 | for pixel in pixels.chunks_exact_mut(4) { | ||
| 77 | let alpha = pixel[3]; | ||
| 78 | if alpha == 255 { | ||
| 79 | continue; | ||
| 80 | } | ||
| 81 | let linear = srgb(pixel[0], pixel[1], pixel[2]); | ||
| 82 | for (byte, value) in pixel[..3].iter_mut().zip(linear) { | ||
| 83 | let value = value * f32::from(alpha) / 255.0; | ||
| 84 | *byte = srgb_byte(value); | ||
| 85 | } | ||
| 86 | } | ||
| 87 | Ok(Self { | 123 | Ok(Self { |
| 88 | size, | 124 | size, |
| 89 | pixels: pixels.into(), | 125 | pixels: pixels.into(), |
| 90 | }) | 126 | }) |
| 91 | } | 127 | } |
| 92 | 128 | ||
| 129 | pub fn size(&self) -> [u32; 2] { | ||
| 130 | self.size | ||
| 131 | } | ||
| 132 | |||
| 93 | /// The identity caches and uploads share across clones. | 133 | /// The identity caches and uploads share across clones. |
| 94 | pub fn id(&self) -> u64 { | 134 | pub fn id(&self) -> u64 { |
| 95 | self.pixels.id() | 135 | self.pixels.id() |
| ... | @@ -104,6 +144,8 @@ impl RasterImage { | ... | @@ -104,6 +144,8 @@ impl RasterImage { |
| 104 | struct CachedImage { | 144 | struct CachedImage { |
| 105 | binding: wgpu::BindGroup, | 145 | binding: wgpu::BindGroup, |
| 106 | bytes: u64, | 146 | bytes: u64, |
| 147 | /// The texture goes when every copy of its image has. | ||
| 148 | pixels: WeakBlob<u8>, | ||
| 107 | } | 149 | } |
| 108 | 150 | ||
| 109 | struct Batch { | 151 | struct Batch { |
| ... | @@ -168,6 +210,9 @@ pub struct Layer<'a> { | ... | @@ -168,6 +210,9 @@ pub struct Layer<'a> { |
| 168 | pub origin: [f32; 2], | 210 | pub origin: [f32; 2], |
| 169 | /// Device bounds `[left, top, right, bottom]` the layer paints within. | 211 | /// Device bounds `[left, top, right, bottom]` the layer paints within. |
| 170 | pub clip: Option<[f32; 4]>, | 212 | pub clip: Option<[f32; 4]>, |
| 213 | /// The colour behind the layer: on a dark one, text in dark colours of its own is | ||
| 214 | /// lifted to stay legible, as OneNote's dark page does. | ||
| 215 | pub backdrop: Option<[f32; 4]>, | ||
| 171 | pub primitives: &'a [Primitive<'a>], | 216 | pub primitives: &'a [Primitive<'a>], |
| 172 | } | 217 | } |
| 173 | 218 | ||
| ... | @@ -177,6 +222,7 @@ struct Space { | ... | @@ -177,6 +222,7 @@ struct Space { |
| 177 | size: [u32; 2], | 222 | size: [u32; 2], |
| 178 | scale: f32, | 223 | scale: f32, |
| 179 | origin: [f32; 2], | 224 | origin: [f32; 2], |
| 225 | backdrop: Option<[f32; 4]>, | ||
| 180 | } | 226 | } |
| 181 | 227 | ||
| 182 | impl Space { | 228 | impl Space { |
| ... | @@ -545,6 +591,8 @@ impl Renderer { | ... | @@ -545,6 +591,8 @@ impl Renderer { |
| 545 | { | 591 | { |
| 546 | return Err(RenderError::InvalidLayer); | 592 | return Err(RenderError::InvalidLayer); |
| 547 | } | 593 | } |
| 594 | self.images | ||
| 595 | .retain(|_, image| image.pixels.upgrade().is_some()); | ||
| 548 | let mut active_images = HashSet::new(); | 596 | let mut active_images = HashSet::new(); |
| 549 | let mut image_bytes = 0; | 597 | let mut image_bytes = 0; |
| 550 | for layer in layers { | 598 | for layer in layers { |
| ... | @@ -552,6 +600,7 @@ impl Renderer { | ... | @@ -552,6 +600,7 @@ impl Renderer { |
| 552 | size, | 600 | size, |
| 553 | scale: layer.scale, | 601 | scale: layer.scale, |
| 554 | origin: layer.origin, | 602 | origin: layer.origin, |
| 603 | backdrop: layer.backdrop, | ||
| 555 | }; | 604 | }; |
| 556 | for primitive in layer.primitives { | 605 | for primitive in layer.primitives { |
| 557 | if let Primitive::Image { image, rect } = primitive | 606 | if let Primitive::Image { image, rect } = primitive |
| ... | @@ -626,6 +675,7 @@ impl Renderer { | ... | @@ -626,6 +675,7 @@ impl Renderer { |
| 626 | size, | 675 | size, |
| 627 | scale: layer.scale, | 676 | scale: layer.scale, |
| 628 | origin: layer.origin, | 677 | origin: layer.origin, |
| 678 | backdrop: layer.backdrop, | ||
| 629 | }; | 679 | }; |
| 630 | let bounds = match layer.clip { | 680 | let bounds = match layer.clip { |
| 631 | Some(clip) => space.scissor(clip), | 681 | Some(clip) => space.scissor(clip), |
| ... | @@ -816,8 +866,14 @@ impl Renderer { | ... | @@ -816,8 +866,14 @@ impl Renderer { |
| 816 | }, | 866 | }, |
| 817 | ], | 867 | ], |
| 818 | }); | 868 | }); |
| 819 | self.images | 869 | self.images.insert( |
| 820 | .insert(image.id(), CachedImage { binding, bytes }); | 870 | image.id(), |
| 871 | CachedImage { | ||
| 872 | binding, | ||
| 873 | bytes, | ||
| 874 | pixels: image.pixels.downgrade(), | ||
| 875 | }, | ||
| 876 | ); | ||
| 821 | Ok(()) | 877 | Ok(()) |
| 822 | } | 878 | } |
| 823 | 879 | ||
| ... | @@ -929,7 +985,7 @@ impl Renderer { | ... | @@ -929,7 +985,7 @@ impl Renderer { |
| 929 | if glyph.color { | 985 | if glyph.color { |
| 930 | [1.0; 4] | 986 | [1.0; 4] |
| 931 | } else { | 987 | } else { |
| 932 | run.color.unwrap_or(ink) | 988 | run.color.map_or(ink, |color| legible(color, space.backdrop)) |
| 933 | }, | 989 | }, |
| 934 | )?; | 990 | )?; |
| 935 | } | 991 | } |
| ... | @@ -946,7 +1002,9 @@ impl Renderer { | ... | @@ -946,7 +1002,9 @@ impl Renderer { |
| 946 | y + (decoration.thickness * scale).max(1.0), | 1002 | y + (decoration.thickness * scale).max(1.0), |
| 947 | ], | 1003 | ], |
| 948 | [0.5 / ATLAS_SIZE as f32; 4], | 1004 | [0.5 / ATLAS_SIZE as f32; 4], |
| 949 | decoration.color.unwrap_or(ink), | 1005 | decoration |
| 1006 | .color | ||
| 1007 | .map_or(ink, |color| legible(color, space.backdrop)), | ||
| 950 | )?; | 1008 | )?; |
| 951 | } | 1009 | } |
| 952 | Ok(()) | 1010 | Ok(()) |
| ... | @@ -1410,6 +1468,51 @@ fn srgb_byte(value: f32) -> u8 { | ... | @@ -1410,6 +1468,51 @@ fn srgb_byte(value: f32) -> u8 { |
| 1410 | } | 1468 | } |
| 1411 | 1469 | ||
| 1412 | /// Linear RGBA of an opaque sRGB colour. | 1470 | /// Linear RGBA of an opaque sRGB colour. |
| 1471 | /// Least OKLab lightness difference between text and the backdrop it stays legible on. | ||
| 1472 | const LEGIBLE: f32 = 0.4; | ||
| 1473 | |||
| 1474 | /// `color` lifted clear of a dark `backdrop`'s lightness, keeping hue and chroma. | ||
| 1475 | fn legible(color: [f32; 4], backdrop: Option<[f32; 4]>) -> [f32; 4] { | ||
| 1476 | let [under, ..] = backdrop.map_or([1.0; 3], oklab); | ||
| 1477 | let [lightness, a, b] = oklab(color); | ||
| 1478 | if under >= 0.5 || lightness >= under + LEGIBLE { | ||
| 1479 | return color; | ||
| 1480 | } | ||
| 1481 | let [red, green, blue] = from_oklab([under + LEGIBLE, a, b]); | ||
| 1482 | [red, green, blue, color[3]] | ||
| 1483 | } | ||
| 1484 | |||
| 1485 | /// OKLab lightness and opponent axes of a linear RGB colour. | ||
| 1486 | pub fn oklab([red, green, blue, _]: [f32; 4]) -> [f32; 3] { | ||
| 1487 | let [l, m, s] = [ | ||
| 1488 | [0.412_221_46, 0.536_332_55, 0.051_445_995], | ||
| 1489 | [0.211_903_5, 0.680_699_5, 0.107_396_96], | ||
| 1490 | [0.088_302_46, 0.281_718_85, 0.629_978_7], | ||
| 1491 | ] | ||
| 1492 | .map(|[r, g, b]| (r * red + g * green + b * blue).cbrt()); | ||
| 1493 | [ | ||
| 1494 | 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, | ||
| 1495 | 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, | ||
| 1496 | 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, | ||
| 1497 | ] | ||
| 1498 | } | ||
| 1499 | |||
| 1500 | /// Linear RGB of an OKLab colour, clipped to the sRGB gamut. | ||
| 1501 | pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] { | ||
| 1502 | let [l, m, s] = [ | ||
| 1503 | lightness + 0.396_337_78 * a + 0.215_803_76 * b, | ||
| 1504 | lightness - 0.105_561_346 * a - 0.063_854_17 * b, | ||
| 1505 | lightness - 0.089_484_18 * a - 1.291_485_5 * b, | ||
| 1506 | ] | ||
| 1507 | .map(|value| value.powi(3)); | ||
| 1508 | [ | ||
| 1509 | [4.076_741_7, -3.307_711_6, 0.230_969_94], | ||
| 1510 | [-1.268_438, 2.609_757_4, -0.341_319_38], | ||
| 1511 | [-0.004_196_086_3, -0.703_418_6, 1.707_614_7], | ||
| 1512 | ] | ||
| 1513 | .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0)) | ||
| 1514 | } | ||
| 1515 | |||
| 1413 | pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { | 1516 | pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { |
| 1414 | let linear = |byte: u8| { | 1517 | let linear = |byte: u8| { |
| 1415 | let value = f32::from(byte) / 255.0; | 1518 | let value = f32::from(byte) / 255.0; |
| ... | @@ -1491,18 +1594,21 @@ mod tests { | ... | @@ -1491,18 +1594,21 @@ mod tests { |
| 1491 | .unwrap() | 1594 | .unwrap() |
| 1492 | .write_image_data(&rgba) | 1595 | .write_image_data(&rgba) |
| 1493 | .unwrap(); | 1596 | .unwrap(); |
| 1494 | let decoded = RasterImage::decode(&encoded).unwrap(); | 1597 | let decoded = RasterImage::decode(&encoded, [2, 1]).unwrap(); |
| 1495 | assert_eq!(decoded.size, [2, 1]); | 1598 | assert_eq!(decoded.size, [2, 1]); |
| 1496 | assert_eq!(decoded.pixels(), [255, 64, 0, 255, 0, 93, 188, 128]); | 1599 | assert_eq!(decoded.pixels(), [255, 64, 0, 255, 0, 93, 188, 128]); |
| 1497 | for end in 0..encoded.len() - 12 { | 1600 | for end in 0..encoded.len() - 12 { |
| 1498 | assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); | 1601 | assert!( |
| 1602 | RasterImage::decode(&encoded[..end], [2, 1]).is_err(), | ||
| 1603 | "{end}" | ||
| 1604 | ); | ||
| 1499 | } | 1605 | } |
| 1500 | 1606 | ||
| 1501 | let mut jpeg = Vec::new(); | 1607 | let mut jpeg = Vec::new(); |
| 1502 | image::codecs::jpeg::JpegEncoder::new(&mut jpeg) | 1608 | image::codecs::jpeg::JpegEncoder::new(&mut jpeg) |
| 1503 | .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) | 1609 | .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) |
| 1504 | .unwrap(); | 1610 | .unwrap(); |
| 1505 | let decoded = RasterImage::decode(&jpeg).unwrap(); | 1611 | let decoded = RasterImage::decode(&jpeg, [4, 4]).unwrap(); |
| 1506 | assert_eq!(decoded.size, [4, 4]); | 1612 | assert_eq!(decoded.size, [4, 4]); |
| 1507 | assert_eq!(decoded.pixels(), [255; 4 * 4 * 4]); | 1613 | assert_eq!(decoded.pixels(), [255; 4 * 4 * 4]); |
| 1508 | 1614 | ||
| ... | @@ -1510,11 +1616,11 @@ mod tests { | ... | @@ -1510,11 +1616,11 @@ mod tests { |
| 1510 | image::codecs::gif::GifEncoder::new(&mut gif) | 1616 | image::codecs::gif::GifEncoder::new(&mut gif) |
| 1511 | .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) | 1617 | .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) |
| 1512 | .unwrap(); | 1618 | .unwrap(); |
| 1513 | let decoded = RasterImage::decode(&gif).unwrap(); | 1619 | let decoded = RasterImage::decode(&gif, [2, 2]).unwrap(); |
| 1514 | assert_eq!(decoded.size, [2, 2]); | 1620 | assert_eq!(decoded.size, [2, 2]); |
| 1515 | assert_eq!(decoded.pixels(), [255; 2 * 2 * 4]); | 1621 | assert_eq!(decoded.pixels(), [255; 2 * 2 * 4]); |
| 1516 | 1622 | ||
| 1517 | for size in [[8192, 8192], [16_385, 1]] { | 1623 | for size in [[8193, 8192], [16_385, 1]] { |
| 1518 | let mut header = Vec::new(); | 1624 | let mut header = Vec::new(); |
| 1519 | let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); | 1625 | let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); |
| 1520 | encoder.set_color(png::ColorType::Rgba); | 1626 | encoder.set_color(png::ColorType::Rgba); |
| ... | @@ -1522,8 +1628,8 @@ mod tests { | ... | @@ -1522,8 +1628,8 @@ mod tests { |
| 1522 | let mut writer = encoder.write_header().unwrap(); | 1628 | let mut writer = encoder.write_header().unwrap(); |
| 1523 | writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); | 1629 | writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); |
| 1524 | drop(writer); | 1630 | drop(writer); |
| 1525 | let error = RasterImage::decode(&header).err().unwrap(); | 1631 | let error = RasterImage::measure(&header).err().unwrap(); |
| 1526 | if size[0] == 8192 { | 1632 | if size[0] == 8193 { |
| 1527 | assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); | 1633 | assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); |
| 1528 | } else { | 1634 | } else { |
| 1529 | assert!( | 1635 | assert!( |
| ... | @@ -1537,6 +1643,39 @@ mod tests { | ... | @@ -1537,6 +1643,39 @@ mod tests { |
| 1537 | } | 1643 | } |
| 1538 | } | 1644 | } |
| 1539 | 1645 | ||
| 1646 | #[test] | ||
| 1647 | fn decoding_shrinks_to_the_size_shown_without_bleeding_transparent_colour() { | ||
| 1648 | let mut encoded = Vec::new(); | ||
| 1649 | let mut encoder = png::Encoder::new(&mut encoded, 4, 2); | ||
| 1650 | encoder.set_color(png::ColorType::Rgba); | ||
| 1651 | encoder.set_depth(png::BitDepth::Eight); | ||
| 1652 | let row = [ | ||
| 1653 | [255, 0, 0, 255], | ||
| 1654 | [255, 0, 0, 255], | ||
| 1655 | [0, 255, 0, 0], | ||
| 1656 | [0, 255, 0, 0], | ||
| 1657 | ]; | ||
| 1658 | encoder | ||
| 1659 | .write_header() | ||
| 1660 | .unwrap() | ||
| 1661 | .write_image_data(&[row, row].as_flattened().concat()) | ||
| 1662 | .unwrap(); | ||
| 1663 | assert_eq!(RasterImage::measure(&encoded).unwrap(), [4, 2]); | ||
| 1664 | let shown = RasterImage::decode(&encoded, [2, 1]).unwrap(); | ||
| 1665 | assert_eq!(shown.size(), [2, 1]); | ||
| 1666 | let [left, right] = [&shown.pixels()[..4], &shown.pixels()[4..]]; | ||
| 1667 | assert!(left[0] > 200 && left[3] > 200, "{left:?}"); | ||
| 1668 | assert!(right[1] == 0 && right[0] <= right[3], "{right:?}"); | ||
| 1669 | assert_eq!( | ||
| 1670 | RasterImage::decode(&encoded, [8, 8]).unwrap().size(), | ||
| 1671 | [4, 2] | ||
| 1672 | ); | ||
| 1673 | assert_eq!( | ||
| 1674 | RasterImage::decode(&encoded, [0, 0]).unwrap().size(), | ||
| 1675 | [1, 1] | ||
| 1676 | ); | ||
| 1677 | } | ||
| 1678 | |||
| 1540 | #[test] | 1679 | #[test] |
| 1541 | fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { | 1680 | fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { |
| 1542 | assert!(matches!( | 1681 | assert!(matches!( |
| ... | @@ -1641,6 +1780,7 @@ mod tests { | ... | @@ -1641,6 +1780,7 @@ mod tests { |
| 1641 | scale: 2.0, | 1780 | scale: 2.0, |
| 1642 | origin: [24.0; 2], | 1781 | origin: [24.0; 2], |
| 1643 | clip: None, | 1782 | clip: None, |
| 1783 | backdrop: None, | ||
| 1644 | primitives, | 1784 | primitives, |
| 1645 | }] | 1785 | }] |
| 1646 | } | 1786 | } |
| ... | @@ -1980,6 +2120,12 @@ mod tests { | ... | @@ -1980,6 +2120,12 @@ mod tests { |
| 1980 | } | 2120 | } |
| 1981 | assert!(renderer.images.len() <= MAX_IMAGES); | 2121 | assert!(renderer.images.len() <= MAX_IMAGES); |
| 1982 | assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); | 2122 | assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); |
| 2123 | let last = previous.take().unwrap().id(); | ||
| 2124 | target.draw(&mut renderer, &page(&[])).unwrap(); | ||
| 2125 | assert!( | ||
| 2126 | !renderer.images.contains_key(&last), | ||
| 2127 | "a texture outlived its image" | ||
| 2128 | ); | ||
| 1983 | renderer.images.clear(); | 2129 | renderer.images.clear(); |
| 1984 | target | 2130 | target |
| 1985 | .draw( | 2131 | .draw( |
| ... | @@ -2035,6 +2181,7 @@ mod tests { | ... | @@ -2035,6 +2181,7 @@ mod tests { |
| 2035 | scale: 2.0, | 2181 | scale: 2.0, |
| 2036 | origin: [0.0; 2], | 2182 | origin: [0.0; 2], |
| 2037 | clip: None, | 2183 | clip: None, |
| 2184 | backdrop: None, | ||
| 2038 | primitives: &primitives, | 2185 | primitives: &primitives, |
| 2039 | }], | 2186 | }], |
| 2040 | ) | 2187 | ) |
| ... | @@ -2096,12 +2243,14 @@ mod tests { | ... | @@ -2096,12 +2243,14 @@ mod tests { |
| 2096 | scale: 1.0, | 2243 | scale: 1.0, |
| 2097 | origin: [0.0; 2], | 2244 | origin: [0.0; 2], |
| 2098 | clip: Some([100.0, 50.0, 200.5, 80.0]), | 2245 | clip: Some([100.0, 50.0, 200.5, 80.0]), |
| 2246 | backdrop: None, | ||
| 2099 | primitives: &fill, | 2247 | primitives: &fill, |
| 2100 | }, | 2248 | }, |
| 2101 | Layer { | 2249 | Layer { |
| 2102 | scale: 2.0, | 2250 | scale: 2.0, |
| 2103 | origin: [300.0, 10.0], | 2251 | origin: [300.0, 10.0], |
| 2104 | clip: None, | 2252 | clip: None, |
| 2253 | backdrop: None, | ||
| 2105 | primitives: &chrome, | 2254 | primitives: &chrome, |
| 2106 | }, | 2255 | }, |
| 2107 | ], | 2256 | ], |
| ... | @@ -2128,6 +2277,7 @@ mod tests { | ... | @@ -2128,6 +2277,7 @@ mod tests { |
| 2128 | scale: 0.0, | 2277 | scale: 0.0, |
| 2129 | origin: [0.0; 2], | 2278 | origin: [0.0; 2], |
| 2130 | clip: None, | 2279 | clip: None, |
| 2280 | backdrop: None, | ||
| 2131 | primitives: &fill, | 2281 | primitives: &fill, |
| 2132 | }] | 2282 | }] |
| 2133 | ), | 2283 | ), |
crates/mobile/Cargo.toml created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | [package] | ||
| 2 | name = "mobile" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | |||
| 7 | # A C surface over a canvas page for native mobile shells; see resources/ios-port.md. | ||
| 8 | [lib] | ||
| 9 | crate-type = ["staticlib"] | ||
| 10 | |||
| 11 | [dependencies] | ||
| 12 | canvas = { path = "../canvas", features = ["interaction"] } | ||
| 13 | draw = { path = "../draw" } | ||
| 14 | onestore = { path = "../onestore" } | ||
| 15 | wgpu.workspace = true | ||
| 16 | parley.workspace = true | ||
| 17 | pollster = "0.4" | ||
crates/mobile/include/snowbound.h created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | // The C surface of crates/mobile; see its lib.rs for each call's contract. | ||
| 2 | #pragma once | ||
| 3 | |||
| 4 | #include <stdbool.h> | ||
| 5 | #include <stddef.h> | ||
| 6 | #include <stdint.h> | ||
| 7 | |||
| 8 | typedef struct Section Section; | ||
| 9 | typedef struct View View; | ||
| 10 | |||
| 11 | Section *sb_section_open(const char *path); | ||
| 12 | size_t sb_section_count(const Section *section); | ||
| 13 | const char *sb_section_title(const Section *section, size_t index); | ||
| 14 | void sb_section_free(Section *section); | ||
| 15 | |||
| 16 | View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale); | ||
| 17 | void sb_view_free(View *view); | ||
| 18 | void sb_view_resize(View *view, float width, float height, float scale); | ||
| 19 | bool sb_view_render(View *view); | ||
| 20 | bool sb_view_frame_pending(const View *view); | ||
| 21 | void sb_view_content(View *view, float bounds[4]); | ||
| 22 | void sb_view_set_transform(View *view, float zoom, float x, float y); | ||
| 23 | bool sb_view_tap(View *view, float x, float y); | ||
| 24 | bool sb_view_in_active_text(const View *view, float x, float y); | ||
| 25 | |||
| 26 | uint32_t sb_text_length(const View *view); | ||
| 27 | char *sb_text(const View *view, uint32_t start, uint32_t end); | ||
| 28 | void sb_string_free(char *text); | ||
| 29 | void sb_selection(const View *view, uint32_t range[2]); | ||
| 30 | bool sb_select(View *view, uint32_t start, uint32_t end); | ||
| 31 | bool sb_marked(const View *view, uint32_t range[2]); | ||
| 32 | bool sb_set_marked(View *view, const char *text, uint32_t selected_start, uint32_t selected_end); | ||
| 33 | void sb_unmark(View *view); | ||
| 34 | bool sb_insert(View *view, const char *text); | ||
| 35 | bool sb_replace(View *view, uint32_t start, uint32_t end, const char *text); | ||
| 36 | bool sb_paste(View *view, const char *text, const char *language); | ||
| 37 | bool sb_delete_backward(View *view); | ||
| 38 | bool sb_caret_rect(const View *view, uint32_t offset, float rect[4]); | ||
| 39 | size_t sb_range_rects(const View *view, uint32_t start, uint32_t end, float (*rects)[4], size_t capacity); | ||
| 40 | uint32_t sb_closest(const View *view, float x, float y); | ||
crates/mobile/src/lib.rs created+676| ... | @@ -0,0 +1,676 @@ | ||
| 1 | //! A canvas page behind a C surface for native mobile shells: a section's pages, one drawn | ||
| 2 | //! into a `CAMetalLayer`, and the active outline's text as flat UTF-16 offsets, paragraphs | ||
| 3 | //! joined by `\n`, the model UIKit's `UITextInput` speaks. | ||
| 4 | //! | ||
| 5 | //! Lengths and positions are in the host's points; the view converts through its display | ||
| 6 | //! scale. Calls returning `bool` report whether the page or selection changed, after which | ||
| 7 | //! the host redraws and rereads `sb_view_content`. | ||
| 8 | |||
| 9 | use canvas::{ | ||
| 10 | document::TextPosition, | ||
| 11 | editor::Selection, | ||
| 12 | gpu::{Paper, Viewport, page::PageScene}, | ||
| 13 | interaction::{Hit, PageView, Response, TextColors, page_hit_test}, | ||
| 14 | layout::TextEngine, | ||
| 15 | }; | ||
| 16 | use draw::edit::{Key, NamedKey, SelectionUnit}; | ||
| 17 | use onestore::{RevisionIndex, Store, document::Document, page::Page}; | ||
| 18 | use parley::{Affinity, BoundingBox}; | ||
| 19 | use std::{ | ||
| 20 | error::Error, | ||
| 21 | ffi::{CStr, CString, c_char, c_void}, | ||
| 22 | sync::{ | ||
| 23 | Arc, | ||
| 24 | atomic::{AtomicBool, Ordering}, | ||
| 25 | }, | ||
| 26 | task::{Wake, Waker}, | ||
| 27 | time::{Duration, Instant}, | ||
| 28 | }; | ||
| 29 | |||
| 30 | type Result<T> = std::result::Result<T, Box<dyn Error>>; | ||
| 31 | |||
| 32 | /// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch. | ||
| 33 | const POINT: f32 = 96.0 / 72.0; | ||
| 34 | |||
| 35 | /// The platform's text interaction draws the caret and selection. | ||
| 36 | const COLORS: TextColors = TextColors { | ||
| 37 | caret: [0.0; 4], | ||
| 38 | selection: [0.0; 4], | ||
| 39 | paper: Paper::WHITE, | ||
| 40 | }; | ||
| 41 | |||
| 42 | pub struct Section { | ||
| 43 | pages: Vec<Page>, | ||
| 44 | titles: Vec<CString>, | ||
| 45 | } | ||
| 46 | |||
| 47 | impl Section { | ||
| 48 | fn open(bytes: &[u8]) -> Result<Self> { | ||
| 49 | let store = Store::parse(bytes)?; | ||
| 50 | let index = RevisionIndex::parse(&store)?; | ||
| 51 | let document = Document::parse(&index)?; | ||
| 52 | let pages = document | ||
| 53 | .pages()? | ||
| 54 | .into_iter() | ||
| 55 | .map(|(space, id)| Page::from_revision(document.active(space)?, id)) | ||
| 56 | .collect::<std::result::Result<Vec<_>, _>>()?; | ||
| 57 | let titles = pages | ||
| 58 | .iter() | ||
| 59 | .map(|page| CString::new(page.title.replace('\0', ""))) | ||
| 60 | .collect::<std::result::Result<_, _>>()?; | ||
| 61 | Ok(Self { pages, titles }) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Set off the main thread when a picture raster lands and the page should be drawn again. | ||
| 66 | #[derive(Default)] | ||
| 67 | struct Frame(AtomicBool); | ||
| 68 | |||
| 69 | impl Wake for Frame { | ||
| 70 | fn wake(self: Arc<Self>) { | ||
| 71 | self.0.store(true, Ordering::Release); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | pub struct View { | ||
| 76 | page: PageView, | ||
| 77 | surface: wgpu::Surface<'static>, | ||
| 78 | config: wgpu::SurfaceConfiguration, | ||
| 79 | renderer: draw::Renderer, | ||
| 80 | frame: Arc<Frame>, | ||
| 81 | } | ||
| 82 | |||
| 83 | fn report<T>(result: Result<T>) -> Option<T> { | ||
| 84 | result.map_err(|error| eprintln!("snowbound: {error}")).ok() | ||
| 85 | } | ||
| 86 | |||
| 87 | fn moved(response: Response) -> bool { | ||
| 88 | response.changed || response.moved | ||
| 89 | } | ||
| 90 | |||
| 91 | impl View { | ||
| 92 | fn new(layer: *mut c_void, page: Page, size: [f32; 2], scale: f32) -> Result<Self> { | ||
| 93 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | ||
| 94 | // SAFETY: the host passes a live CAMetalLayer that outlives the view. | ||
| 95 | let surface = unsafe { | ||
| 96 | instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) | ||
| 97 | }?; | ||
| 98 | let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { | ||
| 99 | compatible_surface: Some(&surface), | ||
| 100 | ..Default::default() | ||
| 101 | }))?; | ||
| 102 | // The simulator's adapter falls short of wgpu's default limits. | ||
| 103 | let (device, queue) = | ||
| 104 | pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { | ||
| 105 | required_limits: adapter.limits(), | ||
| 106 | ..Default::default() | ||
| 107 | }))?; | ||
| 108 | let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); | ||
| 109 | let config = surface | ||
| 110 | .get_default_config(&adapter, pixels[0], pixels[1]) | ||
| 111 | .ok_or("No supported surface configuration")?; | ||
| 112 | surface.configure(&device, &config); | ||
| 113 | let renderer = draw::Renderer::new(device, queue, config.format); | ||
| 114 | let mut engine = TextEngine::default(); | ||
| 115 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; | ||
| 116 | Ok(Self { | ||
| 117 | page: PageView::new( | ||
| 118 | editor, | ||
| 119 | engine, | ||
| 120 | Some((scene, [0.0; 2])), | ||
| 121 | pixels, | ||
| 122 | scale, | ||
| 123 | Duration::from_millis(350), | ||
| 124 | ), | ||
| 125 | surface, | ||
| 126 | config, | ||
| 127 | renderer, | ||
| 128 | frame: Arc::default(), | ||
| 129 | }) | ||
| 130 | } | ||
| 131 | |||
| 132 | /// Device pixels per host point. | ||
| 133 | fn display_scale(&self) -> f32 { | ||
| 134 | self.page.viewport.scale / (self.page.zoom() * POINT) | ||
| 135 | } | ||
| 136 | |||
| 137 | fn device(&self, point: [f32; 2]) -> [f32; 2] { | ||
| 138 | point.map(|value| value * self.display_scale()) | ||
| 139 | } | ||
| 140 | |||
| 141 | fn render(&mut self) -> Result<()> { | ||
| 142 | let frame = match self.surface.get_current_texture() { | ||
| 143 | wgpu::CurrentSurfaceTexture::Success(frame) | ||
| 144 | | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, | ||
| 145 | wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { | ||
| 146 | self.surface.configure(&self.renderer.device, &self.config); | ||
| 147 | return Ok(()); | ||
| 148 | } | ||
| 149 | _ => return Ok(()), | ||
| 150 | }; | ||
| 151 | self.page | ||
| 152 | .update_pictures(COLORS.paper, &Waker::from(self.frame.clone())); | ||
| 153 | let primitives = self.page.primitives(COLORS)?; | ||
| 154 | self.renderer | ||
| 155 | .draw( | ||
| 156 | &frame.texture.create_view(&Default::default()), | ||
| 157 | [self.config.width, self.config.height], | ||
| 158 | COLORS.paper.color, | ||
| 159 | &[self.page.viewport.layer(&primitives)], | ||
| 160 | ) | ||
| 161 | .map_err(|error| format!("Page drawing failed: {error:?}"))?; | ||
| 162 | self.renderer.queue.present(frame); | ||
| 163 | Ok(()) | ||
| 164 | } | ||
| 165 | |||
| 166 | /// The page content's extent in document points: the scroll limits of a view with no | ||
| 167 | /// size at scale 1. | ||
| 168 | fn content(&mut self) -> [f32; 4] { | ||
| 169 | let viewport = self.page.viewport; | ||
| 170 | self.page.viewport = Viewport { | ||
| 171 | size: [0; 2], | ||
| 172 | scale: 1.0, | ||
| 173 | origin: [0.0; 2], | ||
| 174 | }; | ||
| 175 | let scroll = self.page.scroll(); | ||
| 176 | self.page.viewport = viewport; | ||
| 177 | [scroll.min[0], scroll.min[1], scroll.max[0], scroll.max[1]] | ||
| 178 | } | ||
| 179 | |||
| 180 | fn set_transform(&mut self, zoom: f32, corner: [f32; 2]) { | ||
| 181 | let scale = self.display_scale(); | ||
| 182 | self.page.viewport.scale = scale * POINT * zoom; | ||
| 183 | self.page.viewport.origin = corner.map(|value| -value * scale * zoom); | ||
| 184 | } | ||
| 185 | |||
| 186 | fn tap(&mut self, point: [f32; 2]) -> Result<bool> { | ||
| 187 | let mut changed = moved(self.page.pointer_moved(self.device(point))?); | ||
| 188 | changed |= moved(self.page.pointer_pressed(Instant::now())?); | ||
| 189 | changed |= moved(self.page.pointer_released()?); | ||
| 190 | // A finger leaves no hover behind. | ||
| 191 | let _ = self.page.pointer_left(); | ||
| 192 | Ok(changed) | ||
| 193 | } | ||
| 194 | |||
| 195 | /// Whether `point` lands on the text of the outline taking input, where the platform's | ||
| 196 | /// own text interaction places the caret. | ||
| 197 | fn in_active_text(&self, point: [f32; 2]) -> bool { | ||
| 198 | let point = self.page.viewport.document_point(self.device(point)); | ||
| 199 | let pixel = self.display_scale() / self.page.viewport.scale; | ||
| 200 | matches!( | ||
| 201 | page_hit_test(&self.page.editor, self.page.scene.as_ref(), point, pixel), | ||
| 202 | Some(Hit::Text { id, .. }) if id == self.page.editor.active_outline().id | ||
| 203 | ) | ||
| 204 | } | ||
| 205 | |||
| 206 | /// UTF-16 lengths of the active outline's text paragraphs. | ||
| 207 | fn lengths(&self) -> Result<Vec<u32>> { | ||
| 208 | self.page | ||
| 209 | .editor | ||
| 210 | .active_outline() | ||
| 211 | .document() | ||
| 212 | .paragraphs() | ||
| 213 | .map(|paragraph| Ok(paragraph.utf16_offset(paragraph.text().len())?)) | ||
| 214 | .collect() | ||
| 215 | } | ||
| 216 | |||
| 217 | fn position(&self, flat: u32) -> Result<TextPosition> { | ||
| 218 | let lengths = self.lengths()?; | ||
| 219 | let mut rest = flat; | ||
| 220 | for (paragraph, length) in lengths.iter().enumerate() { | ||
| 221 | if rest <= *length { | ||
| 222 | return Ok(TextPosition { | ||
| 223 | paragraph, | ||
| 224 | offset: rest, | ||
| 225 | }); | ||
| 226 | } | ||
| 227 | rest -= length + 1; | ||
| 228 | } | ||
| 229 | Ok(TextPosition { | ||
| 230 | paragraph: lengths.len() - 1, | ||
| 231 | offset: lengths[lengths.len() - 1], | ||
| 232 | }) | ||
| 233 | } | ||
| 234 | |||
| 235 | fn flat(&self, position: TextPosition) -> Result<u32> { | ||
| 236 | let before: u32 = self.lengths()?[..position.paragraph] | ||
| 237 | .iter() | ||
| 238 | .map(|length| length + 1) | ||
| 239 | .sum(); | ||
| 240 | Ok(before + position.offset) | ||
| 241 | } | ||
| 242 | |||
| 243 | fn length(&self) -> Result<u32> { | ||
| 244 | let lengths = self.lengths()?; | ||
| 245 | Ok(lengths.iter().sum::<u32>() + lengths.len() as u32 - 1) | ||
| 246 | } | ||
| 247 | |||
| 248 | fn text(&self, range: [u32; 2]) -> Result<String> { | ||
| 249 | let range = self.position(range[0])?..self.position(range[1])?; | ||
| 250 | Ok(self | ||
| 251 | .page | ||
| 252 | .editor | ||
| 253 | .active_outline() | ||
| 254 | .document() | ||
| 255 | .slice(range)? | ||
| 256 | .iter() | ||
| 257 | .map(|paragraph| paragraph.text()) | ||
| 258 | .collect::<Vec<_>>() | ||
| 259 | .join("\n")) | ||
| 260 | } | ||
| 261 | |||
| 262 | fn selection(&self) -> Result<[u32; 2]> { | ||
| 263 | let [anchor, focus] = self.page.editor.selection().positions; | ||
| 264 | Ok([self.flat(anchor.min(focus))?, self.flat(anchor.max(focus))?]) | ||
| 265 | } | ||
| 266 | |||
| 267 | fn select(&mut self, range: [u32; 2]) -> Result<()> { | ||
| 268 | let positions = [self.position(range[0])?, self.position(range[1])?]; | ||
| 269 | Ok(self.page.editor.select(Selection::from(positions))?) | ||
| 270 | } | ||
| 271 | |||
| 272 | fn marked(&self) -> Result<Option<[u32; 2]>> { | ||
| 273 | self.page | ||
| 274 | .editor | ||
| 275 | .marked_range() | ||
| 276 | .map(|range| Ok([self.flat(range.start)?, self.flat(range.end)?])) | ||
| 277 | .transpose() | ||
| 278 | } | ||
| 279 | |||
| 280 | /// Marked text with its selection in UTF-16 units of `text`. | ||
| 281 | fn set_marked(&mut self, text: String, selected: [u32; 2]) -> Result<bool> { | ||
| 282 | let byte = |utf16: u32| { | ||
| 283 | let mut units = 0; | ||
| 284 | text.char_indices() | ||
| 285 | .find(|(_, char)| { | ||
| 286 | let found = units >= utf16; | ||
| 287 | units += char.len_utf16() as u32; | ||
| 288 | found | ||
| 289 | }) | ||
| 290 | .map_or(text.len(), |(byte, _)| byte) | ||
| 291 | }; | ||
| 292 | let cursor = (byte(selected[0]), byte(selected[1])); | ||
| 293 | Ok(moved(self.page.compose(text, Some(cursor))?)) | ||
| 294 | } | ||
| 295 | |||
| 296 | fn insert(&mut self, text: String) -> Result<bool> { | ||
| 297 | let response = if text == "\n" { | ||
| 298 | self.page.key(&Key::Named(NamedKey::Enter), None)? | ||
| 299 | } else { | ||
| 300 | self.page.commit_text(text)? | ||
| 301 | }; | ||
| 302 | Ok(moved(response)) | ||
| 303 | } | ||
| 304 | |||
| 305 | /// A document rectangle in the active outline, in the view's points as `[x, y, width, | ||
| 306 | /// height]`. | ||
| 307 | fn view_rect(&self, rect: BoundingBox, origin: [f32; 2]) -> [f32; 4] { | ||
| 308 | let viewport = self.page.viewport; | ||
| 309 | let points = self.display_scale(); | ||
| 310 | let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]]; | ||
| 311 | [ | ||
| 312 | (x * viewport.scale + viewport.origin[0]) / points, | ||
| 313 | (y * viewport.scale + viewport.origin[1]) / points, | ||
| 314 | rect.width() as f32 * viewport.scale / points, | ||
| 315 | rect.height() as f32 * viewport.scale / points, | ||
| 316 | ] | ||
| 317 | } | ||
| 318 | |||
| 319 | fn caret_rect(&self, flat: u32) -> Result<[f32; 4]> { | ||
| 320 | let position = self.position(flat)?; | ||
| 321 | let outline = self.page.editor.active_outline(); | ||
| 322 | let paragraph = outline.paragraph_layout(position.paragraph)?; | ||
| 323 | let visible = paragraph.projection.visible_offset(position.offset)?; | ||
| 324 | let byte = paragraph.projection.text().byte_offset(visible)?; | ||
| 325 | let caret = paragraph | ||
| 326 | .text | ||
| 327 | .caret(paragraph.text.cursor(byte, Affinity::Downstream), 1.0); | ||
| 328 | let origin = outline.origin(); | ||
| 329 | Ok(self.view_rect( | ||
| 330 | caret, | ||
| 331 | [ | ||
| 332 | origin[0] + paragraph.origin[0], | ||
| 333 | origin[1] + paragraph.origin[1], | ||
| 334 | ], | ||
| 335 | )) | ||
| 336 | } | ||
| 337 | |||
| 338 | /// The selection rectangles of a range, one or more per line. | ||
| 339 | fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> { | ||
| 340 | let [start, end] = [self.position(range[0])?, self.position(range[1])?]; | ||
| 341 | let outline = self.page.editor.active_outline(); | ||
| 342 | let origin = outline.origin(); | ||
| 343 | let mut rects = Vec::new(); | ||
| 344 | for (index, paragraph) in outline.layouts() { | ||
| 345 | if index < start.paragraph || index > end.paragraph { | ||
| 346 | continue; | ||
| 347 | } | ||
| 348 | let cursor = |offset: u32, affinity| -> Result<_> { | ||
| 349 | let visible = paragraph.projection.visible_offset(offset)?; | ||
| 350 | let byte = paragraph.projection.text().byte_offset(visible)?; | ||
| 351 | Ok(paragraph.text.cursor(byte, affinity)) | ||
| 352 | }; | ||
| 353 | let first = cursor( | ||
| 354 | if index == start.paragraph { | ||
| 355 | start.offset | ||
| 356 | } else { | ||
| 357 | 0 | ||
| 358 | }, | ||
| 359 | Affinity::Downstream, | ||
| 360 | )?; | ||
| 361 | let last = if index == end.paragraph { | ||
| 362 | cursor(end.offset, Affinity::Upstream)? | ||
| 363 | } else { | ||
| 364 | paragraph | ||
| 365 | .text | ||
| 366 | .cursor(paragraph.projection.text().text().len(), Affinity::Upstream) | ||
| 367 | }; | ||
| 368 | let origin = [ | ||
| 369 | origin[0] + paragraph.origin[0], | ||
| 370 | origin[1] + paragraph.origin[1], | ||
| 371 | ]; | ||
| 372 | for rect in paragraph | ||
| 373 | .text | ||
| 374 | .selection(parley::editing::Selection::new(first, last)) | ||
| 375 | { | ||
| 376 | rects.push(self.view_rect(rect, origin)); | ||
| 377 | } | ||
| 378 | } | ||
| 379 | Ok(rects) | ||
| 380 | } | ||
| 381 | |||
| 382 | fn closest(&self, point: [f32; 2]) -> Result<u32> { | ||
| 383 | let point = self.page.viewport.document_point(self.device(point)); | ||
| 384 | let origin = self.page.editor.active_outline().origin(); | ||
| 385 | let selection = self.page.editor.selection_at( | ||
| 386 | point[0] - origin[0], | ||
| 387 | point[1] - origin[1], | ||
| 388 | SelectionUnit::Grapheme, | ||
| 389 | )?; | ||
| 390 | self.flat(selection.positions[1]) | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | /// # Safety | ||
| 395 | /// `path` is a NUL-terminated path. | ||
| 396 | #[unsafe(no_mangle)] | ||
| 397 | pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section { | ||
| 398 | let path = unsafe { CStr::from_ptr(path) }; | ||
| 399 | report( | ||
| 400 | std::fs::read(path.to_str().unwrap_or_default()) | ||
| 401 | .map_err(Into::into) | ||
| 402 | .and_then(|bytes| Section::open(&bytes)), | ||
| 403 | ) | ||
| 404 | .map_or(std::ptr::null_mut(), |section| { | ||
| 405 | Box::into_raw(Box::new(section)) | ||
| 406 | }) | ||
| 407 | } | ||
| 408 | |||
| 409 | #[unsafe(no_mangle)] | ||
| 410 | pub extern "C" fn sb_section_count(section: &Section) -> usize { | ||
| 411 | section.pages.len() | ||
| 412 | } | ||
| 413 | |||
| 414 | /// The page's title, alive as long as the section. | ||
| 415 | #[unsafe(no_mangle)] | ||
| 416 | pub extern "C" fn sb_section_title(section: &Section, index: usize) -> *const c_char { | ||
| 417 | section.titles[index].as_ptr() | ||
| 418 | } | ||
| 419 | |||
| 420 | /// # Safety | ||
| 421 | /// `section` came from `sb_section_open` and is not used again. | ||
| 422 | #[unsafe(no_mangle)] | ||
| 423 | pub unsafe extern "C" fn sb_section_free(section: *mut Section) { | ||
| 424 | drop(unsafe { Box::from_raw(section) }); | ||
| 425 | } | ||
| 426 | |||
| 427 | /// A view drawing page `index` of `section` into `layer`, a `CAMetalLayer`, `size` points | ||
| 428 | /// at `scale` pixels per point; null if it cannot. | ||
| 429 | /// | ||
| 430 | /// # Safety | ||
| 431 | /// `layer` is a live `CAMetalLayer` that outlives the view. | ||
| 432 | #[unsafe(no_mangle)] | ||
| 433 | pub unsafe extern "C" fn sb_view_new( | ||
| 434 | layer: *mut c_void, | ||
| 435 | section: &Section, | ||
| 436 | index: usize, | ||
| 437 | width: f32, | ||
| 438 | height: f32, | ||
| 439 | scale: f32, | ||
| 440 | ) -> *mut View { | ||
| 441 | report(View::new( | ||
| 442 | layer, | ||
| 443 | section.pages[index].clone(), | ||
| 444 | [width, height], | ||
| 445 | scale, | ||
| 446 | )) | ||
| 447 | .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view))) | ||
| 448 | } | ||
| 449 | |||
| 450 | /// # Safety | ||
| 451 | /// `view` came from `sb_view_new` and is not used again. | ||
| 452 | #[unsafe(no_mangle)] | ||
| 453 | pub unsafe extern "C" fn sb_view_free(view: *mut View) { | ||
| 454 | drop(unsafe { Box::from_raw(view) }); | ||
| 455 | } | ||
| 456 | |||
| 457 | #[unsafe(no_mangle)] | ||
| 458 | pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) { | ||
| 459 | let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32); | ||
| 460 | if scale != view.display_scale() { | ||
| 461 | let _ = report(view.page.scale_factor_changed(scale)); | ||
| 462 | } | ||
| 463 | let _ = report(view.page.resized(pixels)); | ||
| 464 | view.config.width = pixels[0]; | ||
| 465 | view.config.height = pixels[1]; | ||
| 466 | view.surface.configure(&view.renderer.device, &view.config); | ||
| 467 | } | ||
| 468 | |||
| 469 | #[unsafe(no_mangle)] | ||
| 470 | pub extern "C" fn sb_view_render(view: &mut View) -> bool { | ||
| 471 | report(view.render()).is_some() | ||
| 472 | } | ||
| 473 | |||
| 474 | /// Whether a picture landed since the last call, so the page should be drawn again. | ||
| 475 | #[unsafe(no_mangle)] | ||
| 476 | pub extern "C" fn sb_view_frame_pending(view: &View) -> bool { | ||
| 477 | view.frame.0.swap(false, Ordering::Acquire) | ||
| 478 | } | ||
| 479 | |||
| 480 | /// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right | ||
| 481 | /// including the margin OneNote scrolls past the last object. | ||
| 482 | #[unsafe(no_mangle)] | ||
| 483 | pub extern "C" fn sb_view_content(view: &mut View, bounds: &mut [f32; 4]) { | ||
| 484 | *bounds = view.content().map(|value| value * POINT); | ||
| 485 | } | ||
| 486 | |||
| 487 | /// Zooms and scrolls so the page point `x`, `y` sits at the view's corner; the host's | ||
| 488 | /// scroll view owns the limits and rubber-banding. | ||
| 489 | #[unsafe(no_mangle)] | ||
| 490 | pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f32) { | ||
| 491 | view.set_transform(zoom, [x, y]); | ||
| 492 | } | ||
| 493 | |||
| 494 | /// A tap: places the caret, focuses an outline or starts a new one. | ||
| 495 | #[unsafe(no_mangle)] | ||
| 496 | pub extern "C" fn sb_view_tap(view: &mut View, x: f32, y: f32) -> bool { | ||
| 497 | report(view.tap([x, y])).unwrap_or(false) | ||
| 498 | } | ||
| 499 | |||
| 500 | #[unsafe(no_mangle)] | ||
| 501 | pub extern "C" fn sb_view_in_active_text(view: &View, x: f32, y: f32) -> bool { | ||
| 502 | view.in_active_text([x, y]) | ||
| 503 | } | ||
| 504 | |||
| 505 | #[unsafe(no_mangle)] | ||
| 506 | pub extern "C" fn sb_text_length(view: &View) -> u32 { | ||
| 507 | report(view.length()).unwrap_or(0) | ||
| 508 | } | ||
| 509 | |||
| 510 | /// The text in `start..end`, freed with `sb_string_free`. | ||
| 511 | #[unsafe(no_mangle)] | ||
| 512 | pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char { | ||
| 513 | report(view.text([start, end])) | ||
| 514 | .and_then(|text| CString::new(text.replace('\0', "")).ok()) | ||
| 515 | .map_or(std::ptr::null_mut(), CString::into_raw) | ||
| 516 | } | ||
| 517 | |||
| 518 | /// # Safety | ||
| 519 | /// `text` came from `sb_text` and is not used again. | ||
| 520 | #[unsafe(no_mangle)] | ||
| 521 | pub unsafe extern "C" fn sb_string_free(text: *mut c_char) { | ||
| 522 | drop(unsafe { CString::from_raw(text) }); | ||
| 523 | } | ||
| 524 | |||
| 525 | #[unsafe(no_mangle)] | ||
| 526 | pub extern "C" fn sb_selection(view: &View, range: &mut [u32; 2]) { | ||
| 527 | if let Some(selection) = report(view.selection()) { | ||
| 528 | *range = selection; | ||
| 529 | } | ||
| 530 | } | ||
| 531 | |||
| 532 | #[unsafe(no_mangle)] | ||
| 533 | pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool { | ||
| 534 | report(view.select([start, end])).is_some() | ||
| 535 | } | ||
| 536 | |||
| 537 | #[unsafe(no_mangle)] | ||
| 538 | pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool { | ||
| 539 | report(view.marked()) | ||
| 540 | .flatten() | ||
| 541 | .map(|marked| *range = marked) | ||
| 542 | .is_some() | ||
| 543 | } | ||
| 544 | |||
| 545 | /// # Safety | ||
| 546 | /// `text` is NUL-terminated UTF-8. | ||
| 547 | #[unsafe(no_mangle)] | ||
| 548 | pub unsafe extern "C" fn sb_set_marked( | ||
| 549 | view: &mut View, | ||
| 550 | text: *const c_char, | ||
| 551 | selected_start: u32, | ||
| 552 | selected_end: u32, | ||
| 553 | ) -> bool { | ||
| 554 | let text = unsafe { CStr::from_ptr(text) } | ||
| 555 | .to_string_lossy() | ||
| 556 | .into_owned(); | ||
| 557 | report(view.set_marked(text, [selected_start, selected_end])).unwrap_or(false) | ||
| 558 | } | ||
| 559 | |||
| 560 | #[unsafe(no_mangle)] | ||
| 561 | pub extern "C" fn sb_unmark(view: &mut View) { | ||
| 562 | view.page.editor.finish_composition(); | ||
| 563 | } | ||
| 564 | |||
| 565 | /// Types `text`, a lone `\n` as the Return key. | ||
| 566 | /// | ||
| 567 | /// # Safety | ||
| 568 | /// `text` is NUL-terminated UTF-8. | ||
| 569 | #[unsafe(no_mangle)] | ||
| 570 | pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool { | ||
| 571 | let text = unsafe { CStr::from_ptr(text) } | ||
| 572 | .to_string_lossy() | ||
| 573 | .into_owned(); | ||
| 574 | report(view.insert(text)).unwrap_or(false) | ||
| 575 | } | ||
| 576 | |||
| 577 | /// Replaces `start..end` with `text`, as autocorrection and dictation do. | ||
| 578 | /// | ||
| 579 | /// # Safety | ||
| 580 | /// `text` is NUL-terminated UTF-8. | ||
| 581 | #[unsafe(no_mangle)] | ||
| 582 | pub unsafe extern "C" fn sb_replace( | ||
| 583 | view: &mut View, | ||
| 584 | start: u32, | ||
| 585 | end: u32, | ||
| 586 | text: *const c_char, | ||
| 587 | ) -> bool { | ||
| 588 | let text = unsafe { CStr::from_ptr(text) } | ||
| 589 | .to_string_lossy() | ||
| 590 | .into_owned(); | ||
| 591 | report( | ||
| 592 | view.select([start, end]) | ||
| 593 | .and_then(|()| Ok(moved(view.page.commit_text(text)?))), | ||
| 594 | ) | ||
| 595 | .unwrap_or(false) | ||
| 596 | } | ||
| 597 | |||
| 598 | /// Pastes clipboard `text` in `language`, the keyboard's BCP-47 tag. | ||
| 599 | /// | ||
| 600 | /// # Safety | ||
| 601 | /// `text` and `language` are NUL-terminated UTF-8. | ||
| 602 | #[unsafe(no_mangle)] | ||
| 603 | pub unsafe extern "C" fn sb_paste( | ||
| 604 | view: &mut View, | ||
| 605 | text: *const c_char, | ||
| 606 | language: *const c_char, | ||
| 607 | ) -> bool { | ||
| 608 | let text = unsafe { CStr::from_ptr(text) }.to_string_lossy(); | ||
| 609 | let language = unsafe { CStr::from_ptr(language) }.to_string_lossy(); | ||
| 610 | report( | ||
| 611 | view.page | ||
| 612 | .paste(&text, canvas::language::lcid(&language)) | ||
| 613 | .map(moved), | ||
| 614 | ) | ||
| 615 | .unwrap_or(false) | ||
| 616 | } | ||
| 617 | |||
| 618 | #[unsafe(no_mangle)] | ||
| 619 | pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { | ||
| 620 | report( | ||
| 621 | view.page | ||
| 622 | .key(&Key::Named(NamedKey::Backspace), None) | ||
| 623 | .map(moved), | ||
| 624 | ) | ||
| 625 | .unwrap_or(false) | ||
| 626 | } | ||
| 627 | |||
| 628 | /// The caret at `offset` as `[x, y, width, height]` in the view's points. | ||
| 629 | #[unsafe(no_mangle)] | ||
| 630 | pub extern "C" fn sb_caret_rect(view: &View, offset: u32, rect: &mut [f32; 4]) -> bool { | ||
| 631 | report(view.caret_rect(offset)) | ||
| 632 | .map(|caret| *rect = caret) | ||
| 633 | .is_some() | ||
| 634 | } | ||
| 635 | |||
| 636 | /// Writes up to `capacity` rectangles covering `start..end`; returns how many there are. | ||
| 637 | /// | ||
| 638 | /// # Safety | ||
| 639 | /// `rects` has room for `capacity` rectangles. | ||
| 640 | #[unsafe(no_mangle)] | ||
| 641 | pub unsafe extern "C" fn sb_range_rects( | ||
| 642 | view: &View, | ||
| 643 | start: u32, | ||
| 644 | end: u32, | ||
| 645 | rects: *mut [f32; 4], | ||
| 646 | capacity: usize, | ||
| 647 | ) -> usize { | ||
| 648 | let found = report(view.range_rects([start, end])).unwrap_or_default(); | ||
| 649 | for (index, rect) in found.iter().take(capacity).enumerate() { | ||
| 650 | unsafe { rects.add(index).write(*rect) }; | ||
| 651 | } | ||
| 652 | found.len() | ||
| 653 | } | ||
| 654 | |||
| 655 | #[unsafe(no_mangle)] | ||
| 656 | pub extern "C" fn sb_closest(view: &View, x: f32, y: f32) -> u32 { | ||
| 657 | report(view.closest([x, y])).unwrap_or(0) | ||
| 658 | } | ||
| 659 | |||
| 660 | #[cfg(test)] | ||
| 661 | mod tests { | ||
| 662 | use super::*; | ||
| 663 | |||
| 664 | #[test] | ||
| 665 | fn corpus_section_lists_its_pages() { | ||
| 666 | let path = concat!( | ||
| 667 | env!("CARGO_MANIFEST_DIR"), | ||
| 668 | "/../../corpus/media-edit/candidate/Features.one" | ||
| 669 | ); | ||
| 670 | let section = Section::open(&std::fs::read(path).unwrap()).unwrap(); | ||
| 671 | for title in &section.titles { | ||
| 672 | eprintln!("{title:?}"); | ||
| 673 | } | ||
| 674 | assert!(!section.pages.is_empty()); | ||
| 675 | } | ||
| 676 | } | ||
crates/snowbound/src/macos.rs+78-3| ... | @@ -58,6 +58,33 @@ unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, ran | ... | @@ -58,6 +58,33 @@ unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, ran |
| 58 | } | 58 | } |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | fn ns_window(window: &Window) -> Retained<AnyObject> { | ||
| 62 | let RawWindowHandle::AppKit(handle) = | ||
| 63 | window.window_handle().expect("Live AppKit window").as_raw() | ||
| 64 | else { | ||
| 65 | unreachable!() | ||
| 66 | }; | ||
| 67 | unsafe { | ||
| 68 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | ||
| 69 | msg_send_id![view, window] | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Zooms the window once the current event is handled: AppKit's zoom animation runs its | ||
| 74 | /// own loop, and started from inside winit's handler it would hold every resize until the | ||
| 75 | /// end, stretching the last frame instead of drawing each step. | ||
| 76 | pub fn zoom(window: &Window) { | ||
| 77 | let window = ns_window(window); | ||
| 78 | unsafe { | ||
| 79 | let _: () = msg_send![ | ||
| 80 | &window, | ||
| 81 | performSelector: sel!(zoom:), | ||
| 82 | withObject: std::ptr::null::<AnyObject>(), | ||
| 83 | afterDelay: 0.0f64 | ||
| 84 | ]; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 61 | /// Install before AccessKit subclasses the same view, preserving its restoration chain. | 88 | /// Install before AccessKit subclasses the same view, preserving its restoration chain. |
| 62 | pub fn install_text_input(window: &Window) { | 89 | pub fn install_text_input(window: &Window) { |
| 63 | MainThreadMarker::new().expect("Text input belongs to the main thread"); | 90 | MainThreadMarker::new().expect("Text input belongs to the main thread"); |
| ... | @@ -261,6 +288,17 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) { | ... | @@ -261,6 +288,17 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) { |
| 261 | } | 288 | } |
| 262 | } | 289 | } |
| 263 | 290 | ||
| 291 | /// Commits a frame presented with the transaction: winit redraws after Core Animation's | ||
| 292 | /// commit observer, so otherwise the frame waits for the next event. A live resize leaves | ||
| 293 | /// the commit to AppKit, which pairs the frame with the window's new size. | ||
| 294 | pub fn commit_presentation(window: &Window) { | ||
| 295 | let resizing: bool = unsafe { msg_send![&ns_window(window), inLiveResize] }; | ||
| 296 | if !resizing { | ||
| 297 | let class = AnyClass::get("CATransaction").expect("QuartzCore is linked"); | ||
| 298 | let _: () = unsafe { msg_send![class, flush] }; | ||
| 299 | } | ||
| 300 | } | ||
| 301 | |||
| 264 | /// The application's icon as the Dock shows it, `pixels` square. | 302 | /// The application's icon as the Dock shows it, `pixels` square. |
| 265 | pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> { | 303 | pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> { |
| 266 | let side = pixels as usize; | 304 | let side = pixels as usize; |
| ... | @@ -311,12 +349,22 @@ pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> { | ... | @@ -311,12 +349,22 @@ pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> { |
| 311 | draw::RasterImage::new([pixels; 2], rgba).ok() | 349 | draw::RasterImage::new([pixels; 2], rgba).ok() |
| 312 | } | 350 | } |
| 313 | 351 | ||
| 314 | pub fn text_colors() -> [[f32; 4]; 2] { | 352 | /// The insertion point's colour and selected text's fill with and without keyboard focus, |
| 353 | /// linear RGBA, in `window`'s appearance. | ||
| 354 | pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { | ||
| 355 | let class = AnyClass::get("NSAppearance").expect("AppKit is linked"); | ||
| 315 | unsafe { | 356 | unsafe { |
| 357 | let appearance: Retained<AnyObject> = | ||
| 358 | msg_send_id![&ns_window(window), effectiveAppearance]; | ||
| 359 | // System colours resolve in the thread's appearance, which outside drawing does | ||
| 360 | // not follow the window's. | ||
| 361 | let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance]; | ||
| 362 | let _: () = msg_send![class, setCurrentAppearance: &*appearance]; | ||
| 316 | let space = NSColorSpace::sRGBColorSpace(); | 363 | let space = NSColorSpace::sRGBColorSpace(); |
| 317 | [ | 364 | let colors = [ |
| 318 | NSColor::textInsertionPointColor(), | 365 | NSColor::textInsertionPointColor(), |
| 319 | NSColor::selectedTextBackgroundColor(), | 366 | NSColor::selectedTextBackgroundColor(), |
| 367 | NSColor::unemphasizedSelectedTextBackgroundColor(), | ||
| 320 | ] | 368 | ] |
| 321 | .map(|color| { | 369 | .map(|color| { |
| 322 | let color = color | 370 | let color = color |
| ... | @@ -336,7 +384,9 @@ pub fn text_colors() -> [[f32; 4]; 2] { | ... | @@ -336,7 +384,9 @@ pub fn text_colors() -> [[f32; 4]; 2] { |
| 336 | } | 384 | } |
| 337 | }); | 385 | }); |
| 338 | [r, g, b, color.alphaComponent() as f32] | 386 | [r, g, b, color.alphaComponent() as f32] |
| 339 | }) | 387 | }); |
| 388 | let _: () = msg_send![class, setCurrentAppearance: previous.as_deref()]; | ||
| 389 | colors | ||
| 340 | } | 390 | } |
| 341 | } | 391 | } |
| 342 | 392 | ||
| ... | @@ -425,3 +475,28 @@ pub fn clear_marked_text(window: &Window) { | ... | @@ -425,3 +475,28 @@ pub fn clear_marked_text(window: &Window) { |
| 425 | } | 475 | } |
| 426 | } | 476 | } |
| 427 | } | 477 | } |
| 478 | |||
| 479 | #[link(name = "Carbon", kind = "framework")] | ||
| 480 | unsafe extern "C" { | ||
| 481 | fn TISCopyCurrentKeyboardInputSource() -> *mut AnyObject; | ||
| 482 | fn TISGetInputSourceProperty<'a>( | ||
| 483 | source: &'a AnyObject, | ||
| 484 | key: &NSString, | ||
| 485 | ) -> Option<&'a AnyObject>; | ||
| 486 | static kTISPropertyInputSourceLanguages: &'static NSString; | ||
| 487 | } | ||
| 488 | |||
| 489 | /// The current keyboard input source's primary language as a BCP-47 tag, empty if it has none. | ||
| 490 | pub fn input_language() -> String { | ||
| 491 | MainThreadMarker::new().expect("Text Input Sources belong to the main thread"); | ||
| 492 | // Input sources are CFTypes, released as Objective-C objects; languages is an NSArray. | ||
| 493 | unsafe { | ||
| 494 | let Some(source) = Retained::from_raw(TISCopyCurrentKeyboardInputSource()) else { | ||
| 495 | return String::new(); | ||
| 496 | }; | ||
| 497 | let language: Option<Retained<NSString>> = | ||
| 498 | TISGetInputSourceProperty(&source, kTISPropertyInputSourceLanguages) | ||
| 499 | .and_then(|languages| msg_send_id![languages, firstObject]); | ||
| 500 | language.map_or_else(String::new, |language| language.to_string()) | ||
| 501 | } | ||
| 502 | } |
crates/snowbound/src/main.rs+205-66| ... | @@ -21,7 +21,7 @@ use onestore::page::text::Paragraph; | ... | @@ -21,7 +21,7 @@ use onestore::page::text::Paragraph; |
| 21 | use std::{ | 21 | use std::{ |
| 22 | error::Error, | 22 | error::Error, |
| 23 | path::{Path, PathBuf}, | 23 | path::{Path, PathBuf}, |
| 24 | sync::Arc, | 24 | sync::{Arc, mpsc}, |
| 25 | time::Instant, | 25 | time::Instant, |
| 26 | }; | 26 | }; |
| 27 | use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; | 27 | use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; |
| ... | @@ -229,6 +229,27 @@ impl Session { | ... | @@ -229,6 +229,27 @@ impl Session { |
| 229 | } | 229 | } |
| 230 | } | 230 | } |
| 231 | 231 | ||
| 232 | /// A section or page read on a loader thread. | ||
| 233 | enum Loaded { | ||
| 234 | Section(Session), | ||
| 235 | Page { space: ExGuid, page: Page }, | ||
| 236 | } | ||
| 237 | |||
| 238 | /// A loader thread's request number and what it read. | ||
| 239 | type Read = (u64, Result<Loaded, String>); | ||
| 240 | |||
| 241 | /// The newest page read, laid out and waiting for the pictures it shows first, so it | ||
| 242 | /// never appears without them. | ||
| 243 | struct Opening { | ||
| 244 | loaded: Loaded, | ||
| 245 | scene: (PageScene, [f32; 2]), | ||
| 246 | editor: CanvasEditor, | ||
| 247 | since: Instant, | ||
| 248 | } | ||
| 249 | |||
| 250 | /// How long an opening page waits for its pictures before showing without them. | ||
| 251 | const HOLD: std::time::Duration = std::time::Duration::from_millis(200); | ||
| 252 | |||
| 232 | /// Work the interface asked for, done after the frame is built. | 253 | /// Work the interface asked for, done after the frame is built. |
| 233 | enum Command { | 254 | enum Command { |
| 234 | OpenSection(usize), | 255 | OpenSection(usize), |
| ... | @@ -240,6 +261,11 @@ enum Command { | ... | @@ -240,6 +261,11 @@ enum Command { |
| 240 | struct State { | 261 | struct State { |
| 241 | window: Arc<Window>, | 262 | window: Arc<Window>, |
| 242 | proxy: EventLoopProxy<UserEvent>, | 263 | proxy: EventLoopProxy<UserEvent>, |
| 264 | /// Results of reads done off the frame thread, by request number. | ||
| 265 | loads: (mpsc::Sender<Read>, mpsc::Receiver<Read>), | ||
| 266 | /// The newest read requested; older ones are dropped when they finish. | ||
| 267 | loading: u64, | ||
| 268 | opening: Option<Opening>, | ||
| 243 | /// Asks for a frame when a worker thread finishes something the page shows. | 269 | /// Asks for a frame when a worker thread finishes something the page shows. |
| 244 | redraw: std::task::Waker, | 270 | redraw: std::task::Waker, |
| 245 | instance: wgpu::Instance, | 271 | instance: wgpu::Instance, |
| ... | @@ -248,7 +274,7 @@ struct State { | ... | @@ -248,7 +274,7 @@ struct State { |
| 248 | renderer: Renderer, | 274 | renderer: Renderer, |
| 249 | ui: Ui, | 275 | ui: Ui, |
| 250 | view: PageView, | 276 | view: PageView, |
| 251 | library: Option<Library>, | 277 | library: Option<Arc<Library>>, |
| 252 | session: Option<Session>, | 278 | session: Option<Session>, |
| 253 | /// Text filtering the page list. | 279 | /// Text filtering the page list. |
| 254 | filter: String, | 280 | filter: String, |
| ... | @@ -265,7 +291,6 @@ struct State { | ... | @@ -265,7 +291,6 @@ struct State { |
| 265 | moved: bool, | 291 | moved: bool, |
| 266 | /// Whether the page last heard it had keyboard focus. | 292 | /// Whether the page last heard it had keyboard focus. |
| 267 | page_focused: bool, | 293 | page_focused: bool, |
| 268 | window_focused: bool, | ||
| 269 | /// The pointer in logical pixels, for window drags from the strip. | 294 | /// The pointer in logical pixels, for window drags from the strip. |
| 270 | pointer: [f32; 2], | 295 | pointer: [f32; 2], |
| 271 | /// When the strip was last pressed, to zoom on a double click. | 296 | /// When the strip was last pressed, to zoom on a double click. |
| ... | @@ -399,9 +424,10 @@ impl State { | ... | @@ -399,9 +424,10 @@ impl State { |
| 399 | .find(|(_, candidate, _)| *candidate == title) | 424 | .find(|(_, candidate, _)| *candidate == title) |
| 400 | .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))? | 425 | .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))? |
| 401 | .0; | 426 | .0; |
| 402 | library = Some(opened); | 427 | library = Some(Arc::new(opened)); |
| 403 | let (scene, editor) = | 428 | let opened = read_session(section, 0, Some(space))?; |
| 404 | open_session(section, 0, Some(space), &mut engine, &mut session)?; | 429 | let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?; |
| 430 | session = Some(opened); | ||
| 405 | (editor, Some((scene, [0.0; 2]))) | 431 | (editor, Some((scene, [0.0; 2]))) |
| 406 | } | 432 | } |
| 407 | Input::Notebook { root, cache } => { | 433 | Input::Notebook { root, cache } => { |
| ... | @@ -417,8 +443,10 @@ impl State { | ... | @@ -417,8 +443,10 @@ impl State { |
| 417 | cache, | 443 | cache, |
| 418 | }; | 444 | }; |
| 419 | let section = opened.open(0, proxy.clone())?; | 445 | let section = opened.open(0, proxy.clone())?; |
| 420 | library = Some(opened); | 446 | library = Some(Arc::new(opened)); |
| 421 | let (scene, editor) = open_session(section, 0, None, &mut engine, &mut session)?; | 447 | let opened = read_session(section, 0, None)?; |
| 448 | let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?; | ||
| 449 | session = Some(opened); | ||
| 422 | (editor, Some((scene, [0.0; 2]))) | 450 | (editor, Some((scene, [0.0; 2]))) |
| 423 | } | 451 | } |
| 424 | }; | 452 | }; |
| ... | @@ -451,6 +479,8 @@ impl State { | ... | @@ -451,6 +479,8 @@ impl State { |
| 451 | .fonts | 479 | .fonts |
| 452 | .collection | 480 | .collection |
| 453 | .family_names() | 481 | .family_names() |
| 482 | // Dot-named families are the system's private ones, hidden from font menus. | ||
| 483 | .filter(|name| !name.starts_with('.')) | ||
| 454 | .map(String::from) | 484 | .map(String::from) |
| 455 | .collect(); | 485 | .collect(); |
| 456 | fonts.sort_unstable_by_key(|name| name.to_lowercase()); | 486 | fonts.sort_unstable_by_key(|name| name.to_lowercase()); |
| ... | @@ -482,7 +512,6 @@ impl State { | ... | @@ -482,7 +512,6 @@ impl State { |
| 482 | changed: false, | 512 | changed: false, |
| 483 | moved: false, | 513 | moved: false, |
| 484 | page_focused: true, | 514 | page_focused: true, |
| 485 | window_focused: true, | ||
| 486 | pointer: [0.0; 2], | 515 | pointer: [0.0; 2], |
| 487 | strip_press: None, | 516 | strip_press: None, |
| 488 | snapshot: None, | 517 | snapshot: None, |
| ... | @@ -492,6 +521,9 @@ impl State { | ... | @@ -492,6 +521,9 @@ impl State { |
| 492 | occluded: false, | 521 | occluded: false, |
| 493 | ime_allowed: true, | 522 | ime_allowed: true, |
| 494 | clipboard: arboard::Clipboard::new()?, | 523 | clipboard: arboard::Clipboard::new()?, |
| 524 | loads: mpsc::channel(), | ||
| 525 | loading: 0, | ||
| 526 | opening: None, | ||
| 495 | access_adapter, | 527 | access_adapter, |
| 496 | accessibility: accessibility::Accessibility::default(), | 528 | accessibility: accessibility::Accessibility::default(), |
| 497 | }; | 529 | }; |
| ... | @@ -503,6 +535,10 @@ impl State { | ... | @@ -503,6 +535,10 @@ impl State { |
| 503 | /// frame that shows the result. | 535 | /// frame that shows the result. |
| 504 | fn frame(&mut self) -> Result<(), Box<dyn Error>> { | 536 | fn frame(&mut self) -> Result<(), Box<dyn Error>> { |
| 505 | let start = Instant::now(); | 537 | let start = Instant::now(); |
| 538 | if let Err(error) = self.open_loaded() { | ||
| 539 | eprintln!("{error}"); | ||
| 540 | } | ||
| 541 | lap("open", start); | ||
| 506 | let size = self.window.inner_size(); | 542 | let size = self.window.inner_size(); |
| 507 | let scale = self.window.scale_factor() as f32; | 543 | let scale = self.window.scale_factor() as f32; |
| 508 | self.ui.begin( | 544 | self.ui.begin( |
| ... | @@ -541,7 +577,7 @@ impl State { | ... | @@ -541,7 +577,7 @@ impl State { |
| 541 | self.draw()?; | 577 | self.draw()?; |
| 542 | lap("drawn", start); | 578 | lap("drawn", start); |
| 543 | let commands = std::mem::take(&mut self.commands); | 579 | let commands = std::mem::take(&mut self.commands); |
| 544 | let follow = !commands.is_empty() || self.ui.wants_frame(); | 580 | let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some(); |
| 545 | for command in commands { | 581 | for command in commands { |
| 546 | self.apply(command)?; | 582 | self.apply(command)?; |
| 547 | } | 583 | } |
| ... | @@ -582,6 +618,11 @@ impl State { | ... | @@ -582,6 +618,11 @@ impl State { |
| 582 | accent: ease("accent", target.accent), | 618 | accent: ease("accent", target.accent), |
| 583 | }; | 619 | }; |
| 584 | self.ui.theme.accent = section.accent; | 620 | self.ui.theme.accent = section.accent; |
| 621 | [ | ||
| 622 | self.ui.theme.caret, | ||
| 623 | self.ui.theme.selection, | ||
| 624 | self.ui.theme.inactive_selection, | ||
| 625 | ] = macos::text_colors(&self.window); | ||
| 585 | let theme = self.ui.theme.clone(); | 626 | let theme = self.ui.theme.clone(); |
| 586 | self.title_bar(&theme); | 627 | self.title_bar(&theme); |
| 587 | self.toolbar(&theme)?; | 628 | self.toolbar(&theme)?; |
| ... | @@ -685,7 +726,7 @@ impl State { | ... | @@ -685,7 +726,7 @@ impl State { |
| 685 | } | 726 | } |
| 686 | self.ui.close(); | 727 | self.ui.close(); |
| 687 | let signal = self.ui.signal(page()); | 728 | let signal = self.ui.signal(page()); |
| 688 | let focused = self.window_focused && self.ui.focused() == Some(page()); | 729 | let focused = self.ui.window_focused && self.ui.focused() == Some(page()); |
| 689 | if focused != self.page_focused { | 730 | if focused != self.page_focused { |
| 690 | self.page_focused = focused; | 731 | self.page_focused = focused; |
| 691 | let response = self.view.focus_changed(focused)?; | 732 | let response = self.view.focus_changed(focused)?; |
| ... | @@ -891,27 +932,38 @@ impl State { | ... | @@ -891,27 +932,38 @@ impl State { |
| 891 | group(ui, "text", |ui| { | 932 | group(ui, "text", |ui| { |
| 892 | row(ui, 0, |ui| { | 933 | row(ui, 0, |ui| { |
| 893 | let combo = ui.id("font"); | 934 | let combo = ui.id("font"); |
| 894 | if ui::shell::combo(ui, "font", &font, 120.0).clicked { | 935 | if ui::shell::combo(ui, "font", &font, 120.0).pressed { |
| 895 | ui.open_popup(popup("font")); | 936 | ui.open_popup(popup("font")); |
| 896 | } | 937 | } |
| 897 | let items: Vec<_> = fonts | 938 | let items: Vec<_> = fonts |
| 898 | .iter() | 939 | .iter() |
| 899 | .map(|name| ui::popup::Item { | 940 | .map(|name| ui::popup::Item { |
| 900 | text: name, | 941 | text: name, |
| 942 | font: Some(name), | ||
| 901 | checked: *name == font, | 943 | checked: *name == font, |
| 902 | ..Default::default() | 944 | ..Default::default() |
| 903 | }) | 945 | }) |
| 904 | .collect(); | 946 | .collect(); |
| 905 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); | 947 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); |
| 906 | if let Some(index) = ui::popup::menu(ui, popup("font"), anchor, &items, Some(&font)) | 948 | if let Some(index) = ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font")) |
| 907 | { | 949 | { |
| 908 | command = Some(Formatting::Font(fonts[index].clone())); | 950 | command = Some(Formatting::Font(fonts[index].clone())); |
| 909 | } | 951 | } |
| 910 | let combo = ui.id("size"); | 952 | let combo = ui.id("size"); |
| 911 | if ui::shell::combo(ui, "size", &size, 44.0).clicked { | 953 | if ui::shell::combo(ui, "size", &size, 44.0).pressed { |
| 912 | ui.open_popup(popup("size")); | 954 | ui.open_popup(popup("size")); |
| 913 | } | 955 | } |
| 914 | let labels = SIZES.map(|size| format!("{size}")); | 956 | // A size typed in the field joins the list, in half points as stored. |
| 957 | let mut sizes = SIZES.to_vec(); | ||
| 958 | if let Some(typed) = ui::popup::query(ui, popup("size")) | ||
| 959 | .and_then(|query| query.trim().parse::<f32>().ok()) | ||
| 960 | .map(|typed| (typed * 2.0).round() / 2.0) | ||
| 961 | .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed)) | ||
| 962 | { | ||
| 963 | let at = sizes.partition_point(|size| *size < typed); | ||
| 964 | sizes.insert(at, typed); | ||
| 965 | } | ||
| 966 | let labels: Vec<_> = sizes.iter().map(|size| format!("{size}")).collect(); | ||
| 915 | let items: Vec<_> = labels | 967 | let items: Vec<_> = labels |
| 916 | .iter() | 968 | .iter() |
| 917 | .map(|label| ui::popup::Item { | 969 | .map(|label| ui::popup::Item { |
| ... | @@ -923,7 +975,7 @@ impl State { | ... | @@ -923,7 +975,7 @@ impl State { |
| 923 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); | 975 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); |
| 924 | if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size)) | 976 | if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size)) |
| 925 | { | 977 | { |
| 926 | command = Some(Formatting::FontSize(SIZES[index])); | 978 | command = Some(Formatting::FontSize(sizes[index])); |
| 927 | } | 979 | } |
| 928 | let bullets = | 980 | let bullets = |
| 929 | ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets); | 981 | ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets); |
| ... | @@ -970,7 +1022,7 @@ impl State { | ... | @@ -970,7 +1022,7 @@ impl State { |
| 970 | Toggle::Subscript | 1022 | Toggle::Subscript |
| 971 | })); | 1023 | })); |
| 972 | } | 1024 | } |
| 973 | if menu.clicked { | 1025 | if menu.pressed { |
| 974 | ui.open_popup(popup("script")); | 1026 | ui.open_popup(popup("script")); |
| 975 | } | 1027 | } |
| 976 | let items = [ | 1028 | let items = [ |
| ... | @@ -1026,7 +1078,7 @@ impl State { | ... | @@ -1026,7 +1078,7 @@ impl State { |
| 1026 | if button.clicked { | 1078 | if button.clicked { |
| 1027 | command = Some(paint(Some(default))); | 1079 | command = Some(paint(Some(default))); |
| 1028 | } | 1080 | } |
| 1029 | if menu.clicked { | 1081 | if menu.pressed { |
| 1030 | ui.open_popup(popup(part)); | 1082 | ui.open_popup(popup(part)); |
| 1031 | } | 1083 | } |
| 1032 | let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); | 1084 | let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); |
| ... | @@ -1060,7 +1112,7 @@ impl State { | ... | @@ -1060,7 +1112,7 @@ impl State { |
| 1060 | .find(|(alignment, ..)| Some(*alignment) == state.alignment) | 1112 | .find(|(alignment, ..)| Some(*alignment) == state.alignment) |
| 1061 | .unwrap_or(&alignments[0]); | 1113 | .unwrap_or(&alignments[0]); |
| 1062 | let [button, menu] = ui::shell::split_button(ui, "align", current.2, None, false); | 1114 | let [button, menu] = ui::shell::split_button(ui, "align", current.2, None, false); |
| 1063 | if button.clicked || menu.clicked { | 1115 | if button.pressed || menu.pressed { |
| 1064 | ui.open_popup(popup("align")); | 1116 | ui.open_popup(popup("align")); |
| 1065 | } | 1117 | } |
| 1066 | let items = alignments.map(|(alignment, name, icon)| ui::popup::Item { | 1118 | let items = alignments.map(|(alignment, name, icon)| ui::popup::Item { |
| ... | @@ -1350,6 +1402,8 @@ impl State { | ... | @@ -1350,6 +1402,8 @@ impl State { |
| 1350 | hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)), | 1402 | hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)), |
| 1351 | radius: ROUNDING, | 1403 | radius: ROUNDING, |
| 1352 | inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], | 1404 | inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], |
| 1405 | // The label keeps its place as the tab opens and closes. | ||
| 1406 | pad: [spec.pad[0] - PILL_MARGIN, 0.0], | ||
| 1353 | ..spec | 1407 | ..spec |
| 1354 | } | 1408 | } |
| 1355 | }; | 1409 | }; |
| ... | @@ -1425,30 +1479,30 @@ impl State { | ... | @@ -1425,30 +1479,30 @@ impl State { |
| 1425 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { | 1479 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { |
| 1426 | match command { | 1480 | match command { |
| 1427 | Command::OpenSection(tab) => { | 1481 | Command::OpenSection(tab) => { |
| 1428 | let library = self.library.as_ref().ok_or("No sections to open")?; | 1482 | let library = Arc::clone(self.library.as_ref().ok_or("No sections to open")?); |
| 1429 | let section = library.open(tab, self.proxy.clone())?; | 1483 | let proxy = self.proxy.clone(); |
| 1430 | let (scene, editor) = | 1484 | self.load(move || { |
| 1431 | open_session(section, tab, None, &mut self.view.engine, &mut self.session)?; | 1485 | let section = library.open(tab, proxy)?; |
| 1432 | self.view.open(editor, Some((scene, [0.0; 2]))); | 1486 | Ok(Loaded::Section(read_session(section, tab, None)?)) |
| 1433 | self.filter.clear(); | 1487 | }); |
| 1434 | self.opened()?; | ||
| 1435 | } | 1488 | } |
| 1436 | Command::OpenPage(space) => { | 1489 | Command::OpenPage(space) => { |
| 1437 | let session = self.session.as_mut().ok_or("No section is open")?; | 1490 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 1438 | let page = session.section.page(space)?; | 1491 | let replica = Arc::clone(session.section.replica()); |
| 1439 | let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; | 1492 | self.load(move || { |
| 1440 | session.space = space; | 1493 | Ok(Loaded::Page { |
| 1441 | session.before = page; | 1494 | space, |
| 1442 | session.refresh_conflict()?; | 1495 | page: replica.page(space)?, |
| 1443 | self.view.open(editor, Some((scene, [0.0; 2]))); | 1496 | }) |
| 1444 | self.opened()?; | 1497 | }); |
| 1445 | } | 1498 | } |
| 1446 | Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?, | 1499 | Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?, |
| 1447 | Command::Page(Request::EditDate(field)) => self.edit_date(field)?, | 1500 | Command::Page(Request::EditDate(field)) => self.edit_date(field)?, |
| 1448 | Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, | 1501 | Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, |
| 1449 | Command::Page(Request::Paste) => { | 1502 | Command::Page(Request::Paste) => { |
| 1450 | let text = self.clipboard.get_text()?; | 1503 | let text = self.clipboard.get_text()?; |
| 1451 | let response = self.view.paste(&text)?; | 1504 | let language = canvas::language::lcid(&macos::input_language()); |
| 1505 | let response = self.view.paste(&text, language)?; | ||
| 1452 | self.respond(response); | 1506 | self.respond(response); |
| 1453 | } | 1507 | } |
| 1454 | Command::Page(Request::CharacterPalette) => macos::show_character_palette(), | 1508 | Command::Page(Request::CharacterPalette) => macos::show_character_palette(), |
| ... | @@ -1456,6 +1510,68 @@ impl State { | ... | @@ -1456,6 +1510,68 @@ impl State { |
| 1456 | Ok(()) | 1510 | Ok(()) |
| 1457 | } | 1511 | } |
| 1458 | 1512 | ||
| 1513 | /// Runs `read` on a thread of its own; `open_loaded` shows what it read unless a newer | ||
| 1514 | /// read was asked for meanwhile. | ||
| 1515 | fn load(&mut self, read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static) { | ||
| 1516 | self.loading += 1; | ||
| 1517 | let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); | ||
| 1518 | std::thread::spawn(move || { | ||
| 1519 | let _ = sender.send((id, read().map_err(|error| error.to_string()))); | ||
| 1520 | redraw.wake(); | ||
| 1521 | }); | ||
| 1522 | } | ||
| 1523 | |||
| 1524 | /// Lays out the newest page read, and shows it once the pictures it shows first are | ||
| 1525 | /// drawn, or after `HOLD`. | ||
| 1526 | fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> { | ||
| 1527 | for (id, loaded) in self.loads.1.try_iter() { | ||
| 1528 | if id != self.loading { | ||
| 1529 | continue; | ||
| 1530 | } | ||
| 1531 | let loaded = loaded?; | ||
| 1532 | let page = match &loaded { | ||
| 1533 | Loaded::Section(session) => &session.before, | ||
| 1534 | Loaded::Page { page, .. } => page, | ||
| 1535 | }; | ||
| 1536 | let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; | ||
| 1537 | self.opening = Some(Opening { | ||
| 1538 | loaded, | ||
| 1539 | scene: (scene, [0.0; 2]), | ||
| 1540 | editor, | ||
| 1541 | since: Instant::now(), | ||
| 1542 | }); | ||
| 1543 | } | ||
| 1544 | let Some(mut opening) = self.opening.take() else { | ||
| 1545 | return Ok(()); | ||
| 1546 | }; | ||
| 1547 | let paper = canvas::gpu::Paper { | ||
| 1548 | color: self.ui.theme.paper, | ||
| 1549 | ink: self.ui.theme.paper_ink, | ||
| 1550 | }; | ||
| 1551 | if !self | ||
| 1552 | .view | ||
| 1553 | .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw) | ||
| 1554 | && opening.since.elapsed() < HOLD | ||
| 1555 | { | ||
| 1556 | self.opening = Some(opening); | ||
| 1557 | return Ok(()); | ||
| 1558 | } | ||
| 1559 | match opening.loaded { | ||
| 1560 | Loaded::Section(session) => { | ||
| 1561 | self.session = Some(session); | ||
| 1562 | self.filter.clear(); | ||
| 1563 | } | ||
| 1564 | Loaded::Page { space, page } => { | ||
| 1565 | let session = self.session.as_mut().ok_or("No section is open")?; | ||
| 1566 | session.space = space; | ||
| 1567 | session.before = page; | ||
| 1568 | session.refresh_conflict()?; | ||
| 1569 | } | ||
| 1570 | } | ||
| 1571 | self.view.open(opening.editor, Some(opening.scene)); | ||
| 1572 | self.opened() | ||
| 1573 | } | ||
| 1574 | |||
| 1459 | /// Follows a page shown in place of another. | 1575 | /// Follows a page shown in place of another. |
| 1460 | fn opened(&mut self) -> Result<(), Box<dyn Error>> { | 1576 | fn opened(&mut self) -> Result<(), Box<dyn Error>> { |
| 1461 | self.ui.set_focus(Some(page())); | 1577 | self.ui.set_focus(Some(page())); |
| ... | @@ -1465,14 +1581,15 @@ impl State { | ... | @@ -1465,14 +1581,15 @@ impl State { |
| 1465 | Ok(()) | 1581 | Ok(()) |
| 1466 | } | 1582 | } |
| 1467 | 1583 | ||
| 1468 | /// Follows an edit: the input method's position, accessibility, saving and the title. | 1584 | /// Follows an edit: saving, the title, then the input method's position and accessibility, |
| 1585 | /// whose failures must not cost the edit. | ||
| 1469 | fn after_edit(&mut self) -> Result<(), Box<dyn Error>> { | 1586 | fn after_edit(&mut self) -> Result<(), Box<dyn Error>> { |
| 1470 | self.after_move()?; | ||
| 1471 | let start = Instant::now(); | 1587 | let start = Instant::now(); |
| 1472 | self.persist()?; | 1588 | let saved = self.persist(); |
| 1473 | lap("save", start); | 1589 | lap("save", start); |
| 1474 | self.title(); | 1590 | self.title(); |
| 1475 | Ok(()) | 1591 | self.after_move()?; |
| 1592 | saved | ||
| 1476 | } | 1593 | } |
| 1477 | 1594 | ||
| 1478 | /// Follows the view moving: the input method's position and accessibility. | 1595 | /// Follows the view moving: the input method's position and accessibility. |
| ... | @@ -1625,6 +1742,7 @@ impl State { | ... | @@ -1625,6 +1742,7 @@ impl State { |
| 1625 | let shown = (session.status, session.conflict); | 1742 | let shown = (session.status, session.conflict); |
| 1626 | let mut refreshed = false; | 1743 | let mut refreshed = false; |
| 1627 | let mut stale = false; | 1744 | let mut stale = false; |
| 1745 | let mut conflicted = session.conflict; | ||
| 1628 | for (space, outcome) in session.section.saved() { | 1746 | for (space, outcome) in session.section.saved() { |
| 1629 | match outcome { | 1747 | match outcome { |
| 1630 | Ok(notebook::session::Save::Queued(_)) => session.status = "Saving", | 1748 | Ok(notebook::session::Save::Queued(_)) => session.status = "Saving", |
| ... | @@ -1650,7 +1768,10 @@ impl State { | ... | @@ -1650,7 +1768,10 @@ impl State { |
| 1650 | Event::Attempt { | 1768 | Event::Attempt { |
| 1651 | status: notebook::EditStatus::Conflict(_), | 1769 | status: notebook::EditStatus::Conflict(_), |
| 1652 | .. | 1770 | .. |
| 1653 | } => "Conflict", | 1771 | } => { |
| 1772 | conflicted = true; | ||
| 1773 | "Conflict" | ||
| 1774 | } | ||
| 1654 | Event::Attempt { .. } => "Saving", | 1775 | Event::Attempt { .. } => "Saving", |
| 1655 | Event::Unreachable(_) => "Offline", | 1776 | Event::Unreachable(_) => "Offline", |
| 1656 | Event::Failed(error) => { | 1777 | Event::Failed(error) => { |
| ... | @@ -1659,7 +1780,11 @@ impl State { | ... | @@ -1659,7 +1780,11 @@ impl State { |
| 1659 | } | 1780 | } |
| 1660 | }; | 1781 | }; |
| 1661 | } | 1782 | } |
| 1662 | session.refresh_conflict()?; | 1783 | // Reading the queue waits on the save thread, so only a conflict shown or reported |
| 1784 | // sends the frame thread there. | ||
| 1785 | if conflicted { | ||
| 1786 | session.refresh_conflict()?; | ||
| 1787 | } | ||
| 1663 | if refreshed || shown != (session.status, session.conflict) { | 1788 | if refreshed || shown != (session.status, session.conflict) { |
| 1664 | self.window.request_redraw(); | 1789 | self.window.request_redraw(); |
| 1665 | } | 1790 | } |
| ... | @@ -1672,7 +1797,9 @@ impl State { | ... | @@ -1672,7 +1797,9 @@ impl State { |
| 1672 | { | 1797 | { |
| 1673 | let first = session.pages.first().ok_or("The section has no pages")?.0; | 1798 | let first = session.pages.first().ok_or("The section has no pages")?.0; |
| 1674 | self.commands.push(Command::OpenPage(first)); | 1799 | self.commands.push(Command::OpenPage(first)); |
| 1675 | } else if session.section.page(session.space)? != session.before { | 1800 | } else if !session.section.saving() |
| 1801 | && session.section.page(session.space)? != session.before | ||
| 1802 | { | ||
| 1676 | self.reload()?; | 1803 | self.reload()?; |
| 1677 | } | 1804 | } |
| 1678 | } else if stale { | 1805 | } else if stale { |
| ... | @@ -1817,6 +1944,7 @@ impl State { | ... | @@ -1817,6 +1944,7 @@ impl State { |
| 1817 | self.window.pre_present_notify(); | 1944 | self.window.pre_present_notify(); |
| 1818 | let start = Instant::now(); | 1945 | let start = Instant::now(); |
| 1819 | self.renderer.queue.present(frame); | 1946 | self.renderer.queue.present(frame); |
| 1947 | macos::commit_presentation(&self.window); | ||
| 1820 | lap("present", start); | 1948 | lap("present", start); |
| 1821 | trace_input(&"Present submitted"); | 1949 | trace_input(&"Present submitted"); |
| 1822 | if reconfigure { | 1950 | if reconfigure { |
| ... | @@ -1828,15 +1956,19 @@ impl State { | ... | @@ -1828,15 +1956,19 @@ impl State { |
| 1828 | /// Paints the interface with the page in its box. | 1956 | /// Paints the interface with the page in its box. |
| 1829 | fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> { | 1957 | fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> { |
| 1830 | let start = Instant::now(); | 1958 | let start = Instant::now(); |
| 1831 | let [caret, selection] = macos::text_colors(); | ||
| 1832 | let paper = canvas::gpu::Paper { | 1959 | let paper = canvas::gpu::Paper { |
| 1833 | color: self.ui.theme.paper, | 1960 | color: self.ui.theme.paper, |
| 1834 | ink: self.ui.theme.paper_ink, | 1961 | ink: self.ui.theme.paper_ink, |
| 1835 | }; | 1962 | }; |
| 1836 | self.view.update_backgrounds(paper, &self.redraw); | 1963 | self.view.update_pictures(paper, &self.redraw); |
| 1964 | let theme = &self.ui.theme; | ||
| 1837 | let page_primitives = self.view.primitives(TextColors { | 1965 | let page_primitives = self.view.primitives(TextColors { |
| 1838 | caret, | 1966 | caret: theme.caret, |
| 1839 | selection, | 1967 | selection: if self.page_focused { |
| 1968 | theme.selection | ||
| 1969 | } else { | ||
| 1970 | theme.inactive_selection | ||
| 1971 | }, | ||
| 1840 | paper, | 1972 | paper, |
| 1841 | })?; | 1973 | })?; |
| 1842 | lap("page primitives", start); | 1974 | lap("page primitives", start); |
| ... | @@ -1851,6 +1983,7 @@ impl State { | ... | @@ -1851,6 +1983,7 @@ impl State { |
| 1851 | scale, | 1983 | scale, |
| 1852 | origin: [0.0; 2], | 1984 | origin: [0.0; 2], |
| 1853 | clip: clip.map(|clip| clip.map(|value| value * scale)), | 1985 | clip: clip.map(|clip| clip.map(|value| value * scale)), |
| 1986 | backdrop: None, | ||
| 1854 | primitives, | 1987 | primitives, |
| 1855 | }, | 1988 | }, |
| 1856 | ui::Layer::Custom { rect, .. } => draw::Layer { | 1989 | ui::Layer::Custom { rect, .. } => draw::Layer { |
| ... | @@ -1860,6 +1993,7 @@ impl State { | ... | @@ -1860,6 +1993,7 @@ impl State { |
| 1860 | viewport.origin[1] + corner[1] * scale, | 1993 | viewport.origin[1] + corner[1] * scale, |
| 1861 | ], | 1994 | ], |
| 1862 | clip: Some(rect.map(|value| value * scale)), | 1995 | clip: Some(rect.map(|value| value * scale)), |
| 1996 | backdrop: Some(self.ui.theme.paper), | ||
| 1863 | primitives: &page_primitives, | 1997 | primitives: &page_primitives, |
| 1864 | }, | 1998 | }, |
| 1865 | }) | 1999 | }) |
| ... | @@ -1969,7 +2103,7 @@ impl State { | ... | @@ -1969,7 +2103,7 @@ impl State { |
| 1969 | }); | 2103 | }); |
| 1970 | self.strip_press = (!double).then_some(at); | 2104 | self.strip_press = (!double).then_some(at); |
| 1971 | if double { | 2105 | if double { |
| 1972 | self.window.set_maximized(!self.window.is_maximized()); | 2106 | macos::zoom(&self.window); |
| 1973 | } else { | 2107 | } else { |
| 1974 | let _ = self.window.drag_window(); | 2108 | let _ = self.window.drag_window(); |
| 1975 | } | 2109 | } |
| ... | @@ -2072,23 +2206,19 @@ fn tabs(notebook: &notebook::session::Notebook) -> Vec<Tab> { | ... | @@ -2072,23 +2206,19 @@ fn tabs(notebook: &notebook::session::Notebook) -> Vec<Tab> { |
| 2072 | .collect() | 2206 | .collect() |
| 2073 | } | 2207 | } |
| 2074 | 2208 | ||
| 2075 | /// Makes `section` the open one showing `space`, or its first page, and returns the | 2209 | /// The session for `section` showing `space`, or its first page. |
| 2076 | /// page's scene and editor. | 2210 | fn read_session( |
| 2077 | fn open_session( | ||
| 2078 | section: notebook::session::Section, | 2211 | section: notebook::session::Section, |
| 2079 | tab: usize, | 2212 | tab: usize, |
| 2080 | space: Option<ExGuid>, | 2213 | space: Option<ExGuid>, |
| 2081 | engine: &mut TextEngine, | 2214 | ) -> Result<Session, Box<dyn Error>> { |
| 2082 | session: &mut Option<Session>, | ||
| 2083 | ) -> Result<(PageScene, CanvasEditor), Box<dyn Error>> { | ||
| 2084 | let pages = section.pages()?; | 2215 | let pages = section.pages()?; |
| 2085 | let space = match space { | 2216 | let space = match space { |
| 2086 | Some(space) => space, | 2217 | Some(space) => space, |
| 2087 | None => pages.first().ok_or("The section has no pages")?.0, | 2218 | None => pages.first().ok_or("The section has no pages")?.0, |
| 2088 | }; | 2219 | }; |
| 2089 | let before = section.page(space)?; | 2220 | let before = section.page(space)?; |
| 2090 | let (scene, editor) = PageScene::from_page(before.clone(), engine)?; | 2221 | let mut session = Session { |
| 2091 | let mut opened = Session { | ||
| 2092 | section, | 2222 | section, |
| 2093 | tab, | 2223 | tab, |
| 2094 | pages, | 2224 | pages, |
| ... | @@ -2097,9 +2227,8 @@ fn open_session( | ... | @@ -2097,9 +2227,8 @@ fn open_session( |
| 2097 | status: "", | 2227 | status: "", |
| 2098 | conflict: false, | 2228 | conflict: false, |
| 2099 | }; | 2229 | }; |
| 2100 | opened.refresh_conflict()?; | 2230 | session.refresh_conflict()?; |
| 2101 | *session = Some(opened); | 2231 | Ok(session) |
| 2102 | Ok((scene, editor)) | ||
| 2103 | } | 2232 | } |
| 2104 | 2233 | ||
| 2105 | impl App { | 2234 | impl App { |
| ... | @@ -2170,7 +2299,10 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -2170,7 +2299,10 @@ impl ApplicationHandler<UserEvent> for App { |
| 2170 | Replay::Input(event) => state.input(event), | 2299 | Replay::Input(event) => state.input(event), |
| 2171 | Replay::Snapshot(path) => state.snapshot = Some(path), | 2300 | Replay::Snapshot(path) => state.snapshot = Some(path), |
| 2172 | Replay::Tick => {} | 2301 | Replay::Tick => {} |
| 2173 | Replay::Appearance(appearance) => state.ui.theme = theme(appearance), | 2302 | Replay::Appearance(appearance) => { |
| 2303 | state.window.set_theme(Some(appearance)); | ||
| 2304 | state.ui.theme = theme(appearance); | ||
| 2305 | } | ||
| 2174 | } | 2306 | } |
| 2175 | // A covered window gets no redraws, so each step draws its own frame. | 2307 | // A covered window gets no redraws, so each step draws its own frame. |
| 2176 | if let Err(error) = state.frame() { | 2308 | if let Err(error) = state.frame() { |
| ... | @@ -2276,7 +2408,7 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -2276,7 +2408,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 2276 | state.window.request_redraw(); | 2408 | state.window.request_redraw(); |
| 2277 | } | 2409 | } |
| 2278 | WindowEvent::Focused(focused) => { | 2410 | WindowEvent::Focused(focused) => { |
| 2279 | state.window_focused = focused; | 2411 | state.ui.window_focused = focused; |
| 2280 | state.window.request_redraw(); | 2412 | state.window.request_redraw(); |
| 2281 | } | 2413 | } |
| 2282 | WindowEvent::Occluded(occluded) => { | 2414 | WindowEvent::Occluded(occluded) => { |
| ... | @@ -2334,14 +2466,21 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -2334,14 +2466,21 @@ impl ApplicationHandler<UserEvent> for App { |
| 2334 | let Some(state) = &mut self.state else { | 2466 | let Some(state) = &mut self.state else { |
| 2335 | return; | 2467 | return; |
| 2336 | }; | 2468 | }; |
| 2337 | let (repaint, next) = if state.occluded { | 2469 | if state.occluded { |
| 2338 | (false, None) | 2470 | event_loop.set_control_flow(ControlFlow::Wait); |
| 2339 | } else { | 2471 | return; |
| 2340 | state.view.blink(Instant::now()) | 2472 | } |
| 2341 | }; | 2473 | let now = Instant::now(); |
| 2342 | if repaint { | 2474 | let (repaint, blink) = state.view.blink(now); |
| 2475 | // A due interface change waits on the frame that shows it, which sets the next. | ||
| 2476 | let wake = state.ui.wake_at(); | ||
| 2477 | if repaint || wake.is_some_and(|wake| wake <= now) { | ||
| 2343 | state.window.request_redraw(); | 2478 | state.window.request_redraw(); |
| 2344 | } | 2479 | } |
| 2480 | let next = [blink, wake.filter(|wake| *wake > now)] | ||
| 2481 | .into_iter() | ||
| 2482 | .flatten() | ||
| 2483 | .min(); | ||
| 2345 | event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil)); | 2484 | event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil)); |
| 2346 | } | 2485 | } |
| 2347 | } | 2486 | } |
crates/ui/examples/popups.rs+6-5| ... | @@ -601,14 +601,14 @@ fn palette(scene: &mut Scene) { | ... | @@ -601,14 +601,14 @@ fn palette(scene: &mut Scene) { |
| 601 | fn tags(scene: &mut Scene) { | 601 | fn tags(scene: &mut Scene) { |
| 602 | let tags = [ | 602 | let tags = [ |
| 603 | ("To Do", None::<&'static [&'static str]>, "⌘1"), | 603 | ("To Do", None::<&'static [&'static str]>, "⌘1"), |
| 604 | ("Important", Some(art!("tags/star")), "⌘2"), | 604 | ("Important", Some(&[include_str!("../../canvas/assets/tags/star.svg")]), "⌘2"), |
| 605 | ("Question", None, "⌘3"), | 605 | ("Question", None, "⌘3"), |
| 606 | ("Remember for later", Some(art!("tags/remember")), "⌘4"), | 606 | ("Remember for later", Some(art!("tags/remember")), "⌘4"), |
| 607 | ("Definition", Some(art!("tags/definition")), "⌘5"), | 607 | ("Definition", Some(art!("tags/definition")), "⌘5"), |
| 608 | ("Highlight", Some(art!("tags/highlight")), "⌘6"), | 608 | ("Highlight", Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]), "⌘6"), |
| 609 | ("Contact", Some(art!("tags/contact")), "⌘7"), | 609 | ("Contact", Some(&[include_str!("../../canvas/assets/tags/contact.svg")]), "⌘7"), |
| 610 | ("Address", Some(art!("tags/address")), "⌘8"), | 610 | ("Address", Some(&[include_str!("../../canvas/assets/tags/address.svg")]), "⌘8"), |
| 611 | ("Phone number", Some(art!("tags/phone")), "⌘9"), | 611 | ("Phone number", Some(&[include_str!("../../canvas/assets/tags/phone.svg")]), "⌘9"), |
| 612 | ("Web site to visit", None, ""), | 612 | ("Web site to visit", None, ""), |
| 613 | ("Idea", None, ""), | 613 | ("Idea", None, ""), |
| 614 | ("Password", None, ""), | 614 | ("Password", None, ""), |
| ... | @@ -690,6 +690,7 @@ fn paint( | ... | @@ -690,6 +690,7 @@ fn paint( |
| 690 | scale: SCALE, | 690 | scale: SCALE, |
| 691 | origin: [0.0; 2], | 691 | origin: [0.0; 2], |
| 692 | clip: clip.map(|clip| clip.map(|value| value * SCALE)), | 692 | clip: clip.map(|clip| clip.map(|value| value * SCALE)), |
| 693 | backdrop: None, | ||
| 693 | primitives, | 694 | primitives, |
| 694 | }), | 695 | }), |
| 695 | ui::Layer::Custom { .. } => None, | 696 | ui::Layer::Custom { .. } => None, |
crates/ui/src/layout.rs+1-2| ... | @@ -79,8 +79,7 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32 | ... | @@ -79,8 +79,7 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32 |
| 79 | let mut cursor = nodes[index].pad[axis]; | 79 | let mut cursor = nodes[index].pad[axis]; |
| 80 | for child in nodes[index].children.clone() { | 80 | for child in nodes[index].children.clone() { |
| 81 | nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor { | 81 | nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor { |
| 82 | let slide = (1.0 - nodes[child].alpha) * crate::SLIDE; | 82 | anchor.place(axis, nodes[child].computed[axis], window) |
| 83 | anchor.place(axis, nodes[child].computed[axis], window, slide) | ||
| 84 | } else if nodes[child].flags.contains(Flags::FLOAT) { | 83 | } else if nodes[child].flags.contains(Flags::FLOAT) { |
| 85 | nodes[child].position[axis] | 84 | nodes[child].position[axis] |
| 86 | } else if along(&nodes[index], axis) { | 85 | } else if along(&nodes[index], axis) { |
crates/ui/src/lib.rs+58-30| ... | @@ -40,8 +40,6 @@ const HALF_LIFE: f32 = 0.03; | ... | @@ -40,8 +40,6 @@ const HALF_LIFE: f32 = 0.03; |
| 40 | /// falls; a popup's, floating higher, spreads and falls further. | 40 | /// falls; a popup's, floating higher, spreads and falls further. |
| 41 | const SHADOW: [f32; 2] = [3.0, 0.0]; | 41 | const SHADOW: [f32; 2] = [3.0, 0.0]; |
| 42 | const POPUP_SHADOW: [f32; 2] = [12.0, 4.0]; | 42 | const POPUP_SHADOW: [f32; 2] = [12.0, 4.0]; |
| 43 | /// How far a popup slides in from its anchor as it fades in. | ||
| 44 | const SLIDE: f32 = 6.0; | ||
| 45 | /// Logical size of a box's icon, and its distance from the label. | 43 | /// Logical size of a box's icon, and its distance from the label. |
| 46 | const ICON: f32 = 16.0; | 44 | const ICON: f32 = 16.0; |
| 47 | const ICON_GAP: f32 = 6.0; | 45 | const ICON_GAP: f32 = 6.0; |
| ... | @@ -182,9 +180,8 @@ pub enum Anchor { | ... | @@ -182,9 +180,8 @@ pub enum Anchor { |
| 182 | 180 | ||
| 183 | impl Anchor { | 181 | impl Anchor { |
| 184 | /// Where a popup `size` long on `axis` starts in a window `room` long: past the | 182 | /// Where a popup `size` long on `axis` starts in a window `room` long: past the |
| 185 | /// anchor on the axis it opens along, level with it otherwise, `slide` nearer to it | 183 | /// anchor on the axis it opens along, level with it otherwise. |
| 186 | /// along the way it opens. | 184 | fn place(self, axis: usize, size: f32, room: f32) -> f32 { |
| 187 | fn place(self, axis: usize, size: f32, room: f32, slide: f32) -> f32 { | ||
| 188 | let (rect, along) = match self { | 185 | let (rect, along) = match self { |
| 189 | Anchor::Below(rect) => (rect, Some(1)), | 186 | Anchor::Below(rect) => (rect, Some(1)), |
| 190 | Anchor::Right(rect) => (rect, Some(0)), | 187 | Anchor::Right(rect) => (rect, Some(0)), |
| ... | @@ -192,15 +189,15 @@ impl Anchor { | ... | @@ -192,15 +189,15 @@ impl Anchor { |
| 192 | Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), | 189 | Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), |
| 193 | }; | 190 | }; |
| 194 | let [low, high] = [rect[axis], rect[axis + 2]]; | 191 | let [low, high] = [rect[axis], rect[axis + 2]]; |
| 195 | let (first, second, slide) = if along == Some(axis) { | 192 | let (first, second) = if along == Some(axis) { |
| 196 | (high, low - size, slide) | 193 | (high, low - size) |
| 197 | } else { | 194 | } else { |
| 198 | (low, high - size, 0.0) | 195 | (low, high - size) |
| 199 | }; | 196 | }; |
| 200 | if first + size <= room { | 197 | if first + size <= room { |
| 201 | first - slide | 198 | first |
| 202 | } else if second >= 0.0 { | 199 | } else if second >= 0.0 { |
| 203 | second + slide | 200 | second |
| 204 | } else { | 201 | } else { |
| 205 | first.min(room - size).max(0.0) | 202 | first.min(room - size).max(0.0) |
| 206 | } | 203 | } |
| ... | @@ -215,6 +212,8 @@ pub struct Spec<'a> { | ... | @@ -215,6 +212,8 @@ pub struct Spec<'a> { |
| 215 | /// The axis its children flow along. | 212 | /// The axis its children flow along. |
| 216 | pub axis: Axis, | 213 | pub axis: Axis, |
| 217 | pub text: Option<&'a str>, | 214 | pub text: Option<&'a str>, |
| 215 | /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. | ||
| 216 | pub font: Option<&'a str>, | ||
| 218 | /// The label's colour; the theme's text colour otherwise. | 217 | /// The label's colour; the theme's text colour otherwise. |
| 219 | pub color: Option<[f32; 4]>, | 218 | pub color: Option<[f32; 4]>, |
| 220 | pub fill: Option<[f32; 4]>, | 219 | pub fill: Option<[f32; 4]>, |
| ... | @@ -334,7 +333,7 @@ struct Built { | ... | @@ -334,7 +333,7 @@ struct Built { |
| 334 | alpha: f32, | 333 | alpha: f32, |
| 335 | fade_into: Option<[f32; 4]>, | 334 | fade_into: Option<[f32; 4]>, |
| 336 | /// Rectangles relative to the box, painted over its fill. | 335 | /// Rectangles relative to the box, painted over its fill. |
| 337 | marks: Vec<([f32; 4], [f32; 4])>, | 336 | marks: Vec<([f32; 4], [f32; 4], f32)>, |
| 338 | computed: [f32; 2], | 337 | computed: [f32; 2], |
| 339 | relative: [f32; 2], | 338 | relative: [f32; 2], |
| 340 | rect: [f32; 4], | 339 | rect: [f32; 4], |
| ... | @@ -453,6 +452,9 @@ impl Display { | ... | @@ -453,6 +452,9 @@ impl Display { |
| 453 | 452 | ||
| 454 | pub struct Ui { | 453 | pub struct Ui { |
| 455 | pub theme: Theme, | 454 | pub theme: Theme, |
| 455 | /// Whether the window has keyboard focus; without it a field hides its caret and dims | ||
| 456 | /// its selection. | ||
| 457 | pub window_focused: bool, | ||
| 456 | frame: u64, | 458 | frame: u64, |
| 457 | now: Instant, | 459 | now: Instant, |
| 458 | scale: f32, | 460 | scale: f32, |
| ... | @@ -479,7 +481,13 @@ pub struct Ui { | ... | @@ -479,7 +481,13 @@ pub struct Ui { |
| 479 | modifiers: ModifiersState, | 481 | modifiers: ModifiersState, |
| 480 | clicks: Clicks, | 482 | clicks: Clicks, |
| 481 | display: Vec<Display>, | 483 | display: Vec<Display>, |
| 484 | /// Where the popups' painting starts in `display`, for edges drawn beneath them. | ||
| 485 | popups_painted: usize, | ||
| 482 | animating: bool, | 486 | animating: bool, |
| 487 | /// The field showing a caret, when its blink started and the frame it last showed. | ||
| 488 | caret: Option<(Id, Instant, u64)>, | ||
| 489 | /// When the next timed change is due, such as a caret's blink. | ||
| 490 | wake: Option<Instant>, | ||
| 483 | } | 491 | } |
| 484 | 492 | ||
| 485 | impl Ui { | 493 | impl Ui { |
| ... | @@ -487,6 +495,7 @@ impl Ui { | ... | @@ -487,6 +495,7 @@ impl Ui { |
| 487 | pub fn new(theme: Theme, double_click: Duration) -> Self { | 495 | pub fn new(theme: Theme, double_click: Duration) -> Self { |
| 488 | Self { | 496 | Self { |
| 489 | theme, | 497 | theme, |
| 498 | window_focused: true, | ||
| 490 | frame: 0, | 499 | frame: 0, |
| 491 | now: Instant::now(), | 500 | now: Instant::now(), |
| 492 | scale: 1.0, | 501 | scale: 1.0, |
| ... | @@ -509,7 +518,10 @@ impl Ui { | ... | @@ -509,7 +518,10 @@ impl Ui { |
| 509 | modifiers: ModifiersState::empty(), | 518 | modifiers: ModifiersState::empty(), |
| 510 | clicks: Clicks::new(double_click), | 519 | clicks: Clicks::new(double_click), |
| 511 | display: Vec::new(), | 520 | display: Vec::new(), |
| 521 | popups_painted: 0, | ||
| 512 | animating: false, | 522 | animating: false, |
| 523 | caret: None, | ||
| 524 | wake: None, | ||
| 513 | } | 525 | } |
| 514 | } | 526 | } |
| 515 | 527 | ||
| ... | @@ -523,6 +535,11 @@ impl Ui { | ... | @@ -523,6 +535,11 @@ impl Ui { |
| 523 | self.animating || !self.queue.is_empty() | 535 | self.animating || !self.queue.is_empty() |
| 524 | } | 536 | } |
| 525 | 537 | ||
| 538 | /// When a timed change, such as a caret's blink, next needs a frame. | ||
| 539 | pub fn wake_at(&self) -> Option<Instant> { | ||
| 540 | self.wake | ||
| 541 | } | ||
| 542 | |||
| 526 | pub fn scale(&self) -> f32 { | 543 | pub fn scale(&self) -> f32 { |
| 527 | self.scale | 544 | self.scale |
| 528 | } | 545 | } |
| ... | @@ -591,6 +608,7 @@ impl Ui { | ... | @@ -591,6 +608,7 @@ impl Ui { |
| 591 | self.stack.push(0); | 608 | self.stack.push(0); |
| 592 | self.signals.clear(); | 609 | self.signals.clear(); |
| 593 | self.moved = false; | 610 | self.moved = false; |
| 611 | self.wake = None; | ||
| 594 | for event in std::mem::take(&mut self.queue) { | 612 | for event in std::mem::take(&mut self.queue) { |
| 595 | self.route(event); | 613 | self.route(event); |
| 596 | } | 614 | } |
| ... | @@ -766,9 +784,7 @@ impl Ui { | ... | @@ -766,9 +784,7 @@ impl Ui { |
| 766 | highlight: None, | 784 | highlight: None, |
| 767 | }); | 785 | }); |
| 768 | self.focus = Some(id); | 786 | self.focus = Some(id); |
| 769 | let state = self.states.entry(id).or_default(); | 787 | self.states.entry(id).or_default().touched = self.frame; |
| 770 | state.touched = self.frame; | ||
| 771 | state.tween = Some([0.0, 1.0]); | ||
| 772 | } | 788 | } |
| 773 | 789 | ||
| 774 | pub fn popup_open(&self, id: Id) -> bool { | 790 | pub fn popup_open(&self, id: Id) -> bool { |
| ... | @@ -839,7 +855,7 @@ impl Ui { | ... | @@ -839,7 +855,7 @@ impl Ui { |
| 839 | pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id { | 855 | pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id { |
| 840 | let label = spec | 856 | let label = spec |
| 841 | .text | 857 | .text |
| 842 | .map(|text| self.texts.label(text, self.theme.font_size, self.frame)); | 858 | .map(|text| self.texts.label(text, self.theme.font_size, spec.font, self.frame)); |
| 843 | // Popups hang from the root, outside the clips and flow of where they are built. | 859 | // Popups hang from the root, outside the clips and flow of where they are built. |
| 844 | let parent = if spec.anchor.is_some() { | 860 | let parent = if spec.anchor.is_some() { |
| 845 | 0 | 861 | 0 |
| ... | @@ -847,15 +863,9 @@ impl Ui { | ... | @@ -847,15 +863,9 @@ impl Ui { |
| 847 | *self.stack.last().unwrap() | 863 | *self.stack.last().unwrap() |
| 848 | }; | 864 | }; |
| 849 | let index = self.nodes.len(); | 865 | let index = self.nodes.len(); |
| 850 | let state = self.states.entry(id).or_default(); | 866 | self.states.entry(id).or_default().touched = self.frame; |
| 851 | state.touched = self.frame; | 867 | self.nodes |
| 852 | let reveal = match spec.anchor { | 868 | .push(Built::new(id, parent, spec, label, self.theme.text)); |
| 853 | Some(Anchor::Point(_)) | None => 1.0, | ||
| 854 | Some(_) => state.tween.map_or(1.0, |tween| tween[0]), | ||
| 855 | }; | ||
| 856 | let mut built = Built::new(id, parent, spec, label, self.theme.text); | ||
| 857 | built.alpha *= reveal; | ||
| 858 | self.nodes.push(built); | ||
| 859 | self.nodes[parent].children.push(index); | 869 | self.nodes[parent].children.push(index); |
| 860 | self.stack.push(index); | 870 | self.stack.push(index); |
| 861 | id | 871 | id |
| ... | @@ -892,14 +902,31 @@ impl Ui { | ... | @@ -892,14 +902,31 @@ impl Ui { |
| 892 | /// The size of `text` as a label, in logical pixels. | 902 | /// The size of `text` as a label, in logical pixels. |
| 893 | pub fn measure(&mut self, text: &str) -> [f32; 2] { | 903 | pub fn measure(&mut self, text: &str) -> [f32; 2] { |
| 894 | self.texts | 904 | self.texts |
| 895 | .label(text, self.theme.font_size, self.frame) | 905 | .label(text, self.theme.font_size, None, self.frame) |
| 896 | .size | 906 | .size |
| 897 | } | 907 | } |
| 898 | 908 | ||
| 899 | /// Paints `color` over the current box at `rect`, relative to its corner. | 909 | /// Paints `color` over the current box at `rect`, relative to its corner, with corners |
| 900 | pub fn mark(&mut self, rect: [f32; 4], color: [f32; 4]) { | 910 | /// of `radius`. |
| 911 | pub fn mark(&mut self, rect: [f32; 4], color: [f32; 4], radius: f32) { | ||
| 901 | let index = *self.stack.last().unwrap(); | 912 | let index = *self.stack.last().unwrap(); |
| 902 | self.nodes[index].marks.push((rect, color)); | 913 | self.nodes[index].marks.push((rect, color, radius)); |
| 914 | } | ||
| 915 | |||
| 916 | /// The caret opacity of field `id` this frame, restarting its blink when `moved` or | ||
| 917 | /// when it was not shown the frame before. | ||
| 918 | pub(crate) fn blink(&mut self, id: Id, moved: bool) -> f32 { | ||
| 919 | let start = match self.caret { | ||
| 920 | Some((shown, start, frame)) if shown == id && frame + 1 == self.frame && !moved => { | ||
| 921 | start | ||
| 922 | } | ||
| 923 | _ => self.now, | ||
| 924 | }; | ||
| 925 | self.caret = Some((id, start, self.frame)); | ||
| 926 | let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start)); | ||
| 927 | let due = self.now + hold; | ||
| 928 | self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); | ||
| 929 | opacity | ||
| 903 | } | 930 | } |
| 904 | 931 | ||
| 905 | /// How the user acted on the box this frame; a box's routed events are taken once. | 932 | /// How the user acted on the box this frame; a box's routed events are taken once. |
| ... | @@ -940,6 +967,7 @@ impl Ui { | ... | @@ -940,6 +967,7 @@ impl Ui { |
| 940 | self.display.clear(); | 967 | self.display.clear(); |
| 941 | self.hits.clear(); | 968 | self.hits.clear(); |
| 942 | self.paint(0, None); | 969 | self.paint(0, None); |
| 970 | self.popups_painted = self.display.len(); | ||
| 943 | let beneath = self.hits.len(); | 971 | let beneath = self.hits.len(); |
| 944 | for index in self.nodes[0].children.clone() { | 972 | for index in self.nodes[0].children.clone() { |
| 945 | if self.nodes[index].anchor.is_some() { | 973 | if self.nodes[index].anchor.is_some() { |
| ... | @@ -1025,7 +1053,7 @@ impl Ui { | ... | @@ -1025,7 +1053,7 @@ impl Ui { |
| 1025 | }); | 1053 | }); |
| 1026 | } | 1054 | } |
| 1027 | } | 1055 | } |
| 1028 | for (mark, color) in &node.marks { | 1056 | for (mark, color, radius) in &node.marks { |
| 1029 | self.display.push(Display::Rect { | 1057 | self.display.push(Display::Rect { |
| 1030 | rect: [ | 1058 | rect: [ |
| 1031 | rect[0] + mark[0], | 1059 | rect[0] + mark[0], |
| ... | @@ -1036,7 +1064,7 @@ impl Ui { | ... | @@ -1036,7 +1064,7 @@ impl Ui { |
| 1036 | fill: *color, | 1064 | fill: *color, |
| 1037 | shade: None, | 1065 | shade: None, |
| 1038 | border: None, | 1066 | border: None, |
| 1039 | radius: 0.0, | 1067 | radius: *radius, |
| 1040 | }); | 1068 | }); |
| 1041 | } | 1069 | } |
| 1042 | let inner = [ | 1070 | let inner = [ |
crates/ui/src/list.rs+30-1| ... | @@ -7,10 +7,12 @@ use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, sc | ... | @@ -7,10 +7,12 @@ use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, sc |
| 7 | use std::collections::{HashMap, HashSet}; | 7 | use std::collections::{HashMap, HashSet}; |
| 8 | use winit::keyboard::NamedKey; | 8 | use winit::keyboard::NamedKey; |
| 9 | 9 | ||
| 10 | /// How far rows fade out under a scrolled list's edges. | ||
| 11 | const EDGE: f64 = 10.0; | ||
| 10 | /// Room beside a scrolling list's rows for its scrollbar. | 12 | /// Room beside a scrolling list's rows for its scrollbar. |
| 11 | pub(crate) const GUTTER: f32 = 12.0; | 13 | pub(crate) const GUTTER: f32 = 12.0; |
| 12 | /// Seconds rows take to reach their new places. | 14 | /// Seconds rows take to reach their new places. |
| 13 | const DURATION: f32 = 0.15; | 15 | const DURATION: f32 = 0.25; |
| 14 | 16 | ||
| 15 | /// Items a list shows, in order. | 17 | /// Items a list shows, in order. |
| 16 | pub trait Rows { | 18 | pub trait Rows { |
| ... | @@ -502,6 +504,27 @@ pub fn list<R: Rows>( | ... | @@ -502,6 +504,27 @@ pub fn list<R: Rows>( |
| 502 | ); | 504 | ); |
| 503 | ui.close(); | 505 | ui.close(); |
| 504 | } | 506 | } |
| 507 | // Rows fade out under the list's edges rather than end at a cut. | ||
| 508 | let clear = [background[0], background[1], background[2], 0.0]; | ||
| 509 | let edges = [ | ||
| 510 | (scroll > 0.0, 0.0, [background, clear]), | ||
| 511 | (scroll < most, view - EDGE, [clear, background]), | ||
| 512 | ]; | ||
| 513 | for (index, (shown, at, [top, bottom])) in edges.into_iter().enumerate() { | ||
| 514 | if shown { | ||
| 515 | ui.leaf( | ||
| 516 | ("edge", index), | ||
| 517 | Spec { | ||
| 518 | flags: Flags::FLOAT, | ||
| 519 | size: [width, px(EDGE as f32)], | ||
| 520 | position: [0.0, at as f32], | ||
| 521 | fill: Some(top), | ||
| 522 | gradient: Some(bottom), | ||
| 523 | ..Spec::default() | ||
| 524 | }, | ||
| 525 | ); | ||
| 526 | } | ||
| 527 | } | ||
| 505 | let thumb = mix(theme.text_dim, theme.chip, 0.5); | 528 | let thumb = mix(theme.text_dim, theme.chip, 0.5); |
| 506 | if let Some(offset) = scrollbar( | 529 | if let Some(offset) = scrollbar( |
| 507 | ui, | 530 | ui, |
| ... | @@ -512,6 +535,12 @@ pub fn list<R: Rows>( | ... | @@ -512,6 +535,12 @@ pub fn list<R: Rows>( |
| 512 | view as f32, | 535 | view as f32, |
| 513 | thumb, | 536 | thumb, |
| 514 | ) { | 537 | ) { |
| 538 | // Rows keep their places relative to the new offset, or next frame's hold on the | ||
| 539 | // first row showing would scroll straight back. | ||
| 540 | let step = f64::from(offset) - scroll; | ||
| 541 | for shown in shown.iter_mut().chain(&mut folding) { | ||
| 542 | shown.place -= step; | ||
| 543 | } | ||
| 515 | state.scroll = f64::from(offset); | 544 | state.scroll = f64::from(offset); |
| 516 | state.target = state.scroll; | 545 | state.target = state.scroll; |
| 517 | } | 546 | } |
crates/ui/src/popup.rs+11| ... | @@ -35,6 +35,8 @@ const KEYS: [NamedKey; 7] = [ | ... | @@ -35,6 +35,8 @@ const KEYS: [NamedKey; 7] = [ |
| 35 | #[derive(Clone, Copy, Debug, Default)] | 35 | #[derive(Clone, Copy, Debug, Default)] |
| 36 | pub struct Item<'a> { | 36 | pub struct Item<'a> { |
| 37 | pub text: &'a str, | 37 | pub text: &'a str, |
| 38 | /// The family the text previews, as in a font menu. | ||
| 39 | pub font: Option<&'a str>, | ||
| 38 | pub icon: Option<&'static [&'static str]>, | 40 | pub icon: Option<&'static [&'static str]>, |
| 39 | /// The icon has colours of its own, so the text's colour does not tint it. | 41 | /// The icon has colours of its own, so the text's colour does not tint it. |
| 40 | pub colored: bool, | 42 | pub colored: bool, |
| ... | @@ -280,6 +282,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { | ... | @@ -280,6 +282,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { |
| 280 | Spec { | 282 | Spec { |
| 281 | size: [fill(), px(ROW)], | 283 | size: [fill(), px(ROW)], |
| 282 | text: Some(item.text), | 284 | text: Some(item.text), |
| 285 | font: item.font, | ||
| 283 | color: Some(color), | 286 | color: Some(color), |
| 284 | ..Spec::default() | 287 | ..Spec::default() |
| 285 | }, | 288 | }, |
| ... | @@ -444,6 +447,14 @@ fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { | ... | @@ -444,6 +447,14 @@ fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { |
| 444 | ui.open_as(id, spec); | 447 | ui.open_as(id, spec); |
| 445 | } | 448 | } |
| 446 | 449 | ||
| 450 | /// What is typed in open popup `id`'s filter field. | ||
| 451 | pub fn query(ui: &Ui, id: Id) -> Option<&str> { | ||
| 452 | ui.popups | ||
| 453 | .iter() | ||
| 454 | .find(|popup| popup.id == id) | ||
| 455 | .map(|popup| popup.query.as_str()) | ||
| 456 | } | ||
| 457 | |||
| 447 | fn state(ui: &mut Ui, id: Id) -> &mut Popup { | 458 | fn state(ui: &mut Ui, id: Id) -> &mut Popup { |
| 448 | ui.popups | 459 | ui.popups |
| 449 | .iter_mut() | 460 | .iter_mut() |
crates/ui/src/shell.rs+10-4| ... | @@ -161,7 +161,7 @@ pub fn split_button( | ... | @@ -161,7 +161,7 @@ pub fn split_button( |
| 161 | if let Some(color) = bar { | 161 | if let Some(color) = bar { |
| 162 | // The icon leaves its lowest three units for the bar. | 162 | // The icon leaves its lowest three units for the bar. |
| 163 | let top = (TOOL - 16.0) / 2.0; | 163 | let top = (TOOL - 16.0) / 2.0; |
| 164 | ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color); | 164 | ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0); |
| 165 | } | 165 | } |
| 166 | ui.close(); | 166 | ui.close(); |
| 167 | let menu = ui.leaf( | 167 | let menu = ui.leaf( |
| ... | @@ -220,6 +220,12 @@ pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: f32) -> Signal { | ... | @@ -220,6 +220,12 @@ pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: f32) -> Signal { |
| 220 | } | 220 | } |
| 221 | 221 | ||
| 222 | impl Ui { | 222 | impl Ui { |
| 223 | /// Paints `item` over the frame's boxes but under its popups. | ||
| 224 | fn beneath_popups(&mut self, item: Display) { | ||
| 225 | self.display.insert(self.popups_painted, item); | ||
| 226 | self.popups_painted += 1; | ||
| 227 | } | ||
| 228 | |||
| 223 | /// Draws a 1 px border along `points`, each with the radius its corner rounds by, | 229 | /// Draws a 1 px border along `points`, each with the radius its corner rounds by, |
| 224 | /// joining the last back to the first when `closed`. Called between `end` and | 230 | /// joining the last back to the first when `closed`. Called between `end` and |
| 225 | /// `layers`, it paints over the frame's boxes where they were just laid out. | 231 | /// `layers`, it paints over the frame's boxes where they were just laid out. |
| ... | @@ -239,14 +245,14 @@ impl Ui { | ... | @@ -239,14 +245,14 @@ impl Ui { |
| 239 | let to = corners[next] | 245 | let to = corners[next] |
| 240 | .as_ref() | 246 | .as_ref() |
| 241 | .map_or(points[next].0, |corner| corner.start); | 247 | .map_or(points[next].0, |corner| corner.start); |
| 242 | self.display.push(Display::Segment { from, to, color }); | 248 | self.beneath_popups(Display::Segment { from, to, color }); |
| 243 | } | 249 | } |
| 244 | for corner in corners.into_iter().flatten() { | 250 | for corner in corners.into_iter().flatten() { |
| 245 | let start = [ | 251 | let start = [ |
| 246 | corner.start[0] - corner.point[0], | 252 | corner.start[0] - corner.point[0], |
| 247 | corner.start[1] - corner.point[1], | 253 | corner.start[1] - corner.point[1], |
| 248 | ]; | 254 | ]; |
| 249 | self.display.push(Display::Path { | 255 | self.beneath_popups(Display::Path { |
| 250 | data: format!("M{} {}{}", start[0], start[1], corner.curve(corner.point)), | 256 | data: format!("M{} {}{}", start[0], start[1], corner.curve(corner.point)), |
| 251 | origin: corner.point, | 257 | origin: corner.point, |
| 252 | style: PathStyle::Stroke(1.0), | 258 | style: PathStyle::Stroke(1.0), |
| ... | @@ -273,7 +279,7 @@ impl Ui { | ... | @@ -273,7 +279,7 @@ impl Ui { |
| 273 | corner.start[0] - corner.point[0], | 279 | corner.start[0] - corner.point[0], |
| 274 | corner.start[1] - corner.point[1], | 280 | corner.start[1] - corner.point[1], |
| 275 | ]; | 281 | ]; |
| 276 | self.display.push(Display::Path { | 282 | self.beneath_popups(Display::Path { |
| 277 | data: format!( | 283 | data: format!( |
| 278 | "M{} {}{}L0 0Z", | 284 | "M{} {}{}L0 0Z", |
| 279 | start[0], | 285 | start[0], |
crates/ui/src/tests.rs+101-17| ... | @@ -341,7 +341,7 @@ fn focused_field(text: &str) -> (Ui, String) { | ... | @@ -341,7 +341,7 @@ fn focused_field(text: &str) -> (Ui, String) { |
| 341 | fn point_to(ui: &mut Ui, text: &str, index: usize) { | 341 | fn point_to(ui: &mut Ui, text: &str, index: usize) { |
| 342 | let size = ui.theme.font_size; | 342 | let size = ui.theme.font_size; |
| 343 | let (texts, frame) = ui.texts(); | 343 | let (texts, frame) = ui.texts(); |
| 344 | let layout = &texts.label(text, size, frame).layout; | 344 | let layout = &texts.label(text, size, None, frame).layout; |
| 345 | let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream) | 345 | let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream) |
| 346 | .geometry(layout, 1.0) | 346 | .geometry(layout, 1.0) |
| 347 | .x0 as f32; | 347 | .x0 as f32; |
| ... | @@ -382,6 +382,53 @@ fn text_fields_edit_with_keys_and_selection() { | ... | @@ -382,6 +382,53 @@ fn text_fields_edit_with_keys_and_selection() { |
| 382 | assert_eq!(text, ""); | 382 | assert_eq!(text, ""); |
| 383 | } | 383 | } |
| 384 | 384 | ||
| 385 | #[test] | ||
| 386 | fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() { | ||
| 387 | let (mut ui, mut text) = focused_field("one"); | ||
| 388 | let start = Instant::now(); | ||
| 389 | let mut at = |ui: &mut Ui, millis: u64| { | ||
| 390 | ui.begin([400.0, 300.0], 2.0, start + Duration::from_millis(millis)); | ||
| 391 | text_field( | ||
| 392 | ui, | ||
| 393 | field(), | ||
| 394 | &mut text, | ||
| 395 | "Filter", | ||
| 396 | Spec { | ||
| 397 | size: [px(200.0), px(26.0)], | ||
| 398 | pad: [6.0, 0.0], | ||
| 399 | ..Spec::default() | ||
| 400 | }, | ||
| 401 | ); | ||
| 402 | ui.end(); | ||
| 403 | let caret = ui.theme.caret; | ||
| 404 | let opacity = ui | ||
| 405 | .layers() | ||
| 406 | .iter() | ||
| 407 | .flat_map(|layer| match layer { | ||
| 408 | Layer::Primitives { primitives, .. } => primitives.as_slice(), | ||
| 409 | Layer::Custom { .. } => &[], | ||
| 410 | }) | ||
| 411 | .find_map(|primitive| match primitive { | ||
| 412 | Primitive::RoundedRect { rect, color, .. } if color[..3] == caret[..3] => { | ||
| 413 | assert_eq!(rect[2] - rect[0], draw::edit::CARET_WIDTH); | ||
| 414 | Some(color[3]) | ||
| 415 | } | ||
| 416 | _ => None, | ||
| 417 | }) | ||
| 418 | .unwrap_or(0.0); | ||
| 419 | (opacity, ui.wake_at().map(|wake| wake - start)) | ||
| 420 | }; | ||
| 421 | assert_eq!(at(&mut ui, 0), (1.0, Some(Duration::from_micros(687_500)))); | ||
| 422 | let theme = Theme::dark(); | ||
| 423 | let faded = draw::edit::caret_color(theme.caret, theme.base, 0.75)[3]; | ||
| 424 | assert_eq!(at(&mut ui, 700).0, faded); | ||
| 425 | assert_eq!(at(&mut ui, 900).0, 0.0); | ||
| 426 | ui.event(key(NamedKey::ArrowRight)); | ||
| 427 | assert_eq!(at(&mut ui, 910), (1.0, Some(Duration::from_micros(1_597_500)))); | ||
| 428 | ui.window_focused = false; | ||
| 429 | assert_eq!(at(&mut ui, 920), (0.0, None), "an inactive window shows no caret"); | ||
| 430 | } | ||
| 431 | |||
| 385 | #[test] | 432 | #[test] |
| 386 | fn text_fields_move_and_select_by_word() { | 433 | fn text_fields_move_and_select_by_word() { |
| 387 | let (mut ui, mut text) = focused_field("one two three"); | 434 | let (mut ui, mut text) = focused_field("one two three"); |
| ... | @@ -621,35 +668,31 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { | ... | @@ -621,35 +668,31 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { |
| 621 | } | 668 | } |
| 622 | 669 | ||
| 623 | #[test] | 670 | #[test] |
| 624 | fn popups_fade_in_except_at_a_point() { | 671 | fn popups_show_at_once_by_their_anchor() { |
| 625 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | 672 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| 626 | menu_frame(&mut ui, BELOW, None); | 673 | menu_frame(&mut ui, BELOW, None); |
| 627 | ui.open_popup(menu_id()); | 674 | ui.open_popup(menu_id()); |
| 628 | menu_frame(&mut ui, BELOW, None); | 675 | menu_frame(&mut ui, BELOW, None); |
| 629 | menu_frame(&mut ui, BELOW, None); | 676 | menu_frame(&mut ui, BELOW, None); |
| 630 | let reveal = |ui: &Ui| { | 677 | let alpha = |ui: &Ui| { |
| 631 | ui.nodes | 678 | ui.nodes |
| 632 | .iter() | 679 | .iter() |
| 633 | .find(|node| node.id == menu_id()) | 680 | .find(|node| node.id == menu_id()) |
| 634 | .unwrap() | 681 | .unwrap() |
| 635 | .alpha | 682 | .alpha |
| 636 | }; | 683 | }; |
| 637 | assert!(reveal(&ui) > 0.0 && reveal(&ui) < 1.0); | 684 | assert_eq!(alpha(&ui), 1.0); |
| 638 | assert!( | 685 | assert_eq!( |
| 639 | ui.rect(menu_id()).unwrap()[1] < 44.0, | 686 | ui.rect(menu_id()).unwrap()[1], |
| 640 | "and slide from the anchor" | 687 | 44.0, |
| 688 | "right under the anchor" | ||
| 641 | ); | 689 | ); |
| 642 | assert!(ui.wants_frame()); | ||
| 643 | for _ in 0..10 { | ||
| 644 | menu_frame(&mut ui, BELOW, None); | ||
| 645 | } | ||
| 646 | assert!(reveal(&ui) > 0.95, "within about 150 ms"); | ||
| 647 | ui.close_popup(menu_id()); | 690 | ui.close_popup(menu_id()); |
| 648 | menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); | 691 | menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); |
| 649 | ui.open_popup(menu_id()); | 692 | ui.open_popup(menu_id()); |
| 650 | menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); | 693 | menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); |
| 651 | menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); | 694 | menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); |
| 652 | assert_eq!(reveal(&ui), 1.0, "a context menu shows at once"); | 695 | assert_eq!(alpha(&ui), 1.0); |
| 653 | assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]); | 696 | assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]); |
| 654 | } | 697 | } |
| 655 | 698 | ||
| ... | @@ -1053,16 +1096,17 @@ fn rows_slide_while_the_rest_fold_away_and_unfold_at_once() { | ... | @@ -1053,16 +1096,17 @@ fn rows_slide_while_the_rest_fold_away_and_unfold_at_once() { |
| 1053 | assert!(alpha > 0.0 && alpha < 1.0, "{key} folds where it was"); | 1096 | assert!(alpha > 0.0 && alpha < 1.0, "{key} folds where it was"); |
| 1054 | } | 1097 | } |
| 1055 | assert!(row_top(&ui, 5).unwrap() < 0.0, "out of the seam at the top"); | 1098 | assert!(row_top(&ui, 5).unwrap() < 0.0, "out of the seam at the top"); |
| 1056 | list_frame(&mut ui, &rows, &mut selected, &[]); | 1099 | for _ in 0..4 { |
| 1057 | list_frame(&mut ui, &rows, &mut selected, &[]); | 1100 | list_frame(&mut ui, &rows, &mut selected, &[]); |
| 1101 | } | ||
| 1058 | for key in [5, 100] { | 1102 | for key in [5, 100] { |
| 1059 | let alpha = alpha(&ui, key).unwrap(); | 1103 | let alpha = alpha(&ui, key).unwrap(); |
| 1060 | assert!(alpha > 0.0 && alpha < 1.0, "{key} unfolds where it goes"); | 1104 | assert!(alpha > 0.0 && alpha < 1.0, "{key} unfolds where it goes"); |
| 1061 | } | 1105 | } |
| 1062 | for _ in 0..7 { | 1106 | for _ in 0..11 { |
| 1063 | list_frame(&mut ui, &rows, &mut selected, &[]); | 1107 | list_frame(&mut ui, &rows, &mut selected, &[]); |
| 1064 | } | 1108 | } |
| 1065 | assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 150 ms"); | 1109 | assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 250 ms"); |
| 1066 | assert_eq!(row_top(&ui, 0), Some(LIST_ROW)); | 1110 | assert_eq!(row_top(&ui, 0), Some(LIST_ROW)); |
| 1067 | assert_eq!(row_top(&ui, 100), Some(5.0 * LIST_ROW)); | 1111 | assert_eq!(row_top(&ui, 100), Some(5.0 * LIST_ROW)); |
| 1068 | assert_eq!(alpha(&ui, 100), Some(1.0)); | 1112 | assert_eq!(alpha(&ui, 100), Some(1.0)); |
| ... | @@ -1162,3 +1206,43 @@ fn keys_move_the_selection_and_the_view_eases_after_it() { | ... | @@ -1162,3 +1206,43 @@ fn keys_move_the_selection_and_the_view_eases_after_it() { |
| 1162 | settle_list(&mut ui, &rows, &mut selected); | 1206 | settle_list(&mut ui, &rows, &mut selected); |
| 1163 | assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0))); | 1207 | assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0))); |
| 1164 | } | 1208 | } |
| 1209 | |||
| 1210 | #[test] | ||
| 1211 | fn a_long_menu_scrolls_by_dragging_its_thumb() { | ||
| 1212 | let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect(); | ||
| 1213 | let items: Vec<_> = names | ||
| 1214 | .iter() | ||
| 1215 | .map(|text| popup::Item { | ||
| 1216 | text, | ||
| 1217 | ..popup::Item::default() | ||
| 1218 | }) | ||
| 1219 | .collect(); | ||
| 1220 | let build = |ui: &mut Ui| { | ||
| 1221 | frame(ui, |ui| { | ||
| 1222 | popup::menu(ui, menu_id(), BELOW, &items, None); | ||
| 1223 | }) | ||
| 1224 | }; | ||
| 1225 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | ||
| 1226 | build(&mut ui); | ||
| 1227 | ui.open_popup(menu_id()); | ||
| 1228 | build(&mut ui); | ||
| 1229 | build(&mut ui); | ||
| 1230 | let thumb = ui.rect(menu_id().child("rows").child("bar")).unwrap(); | ||
| 1231 | let top = ui | ||
| 1232 | .rect(menu_id().child("rows").child(0u64)) | ||
| 1233 | .map(|rect| rect[1]); | ||
| 1234 | let at = Instant::now(); | ||
| 1235 | let x = (thumb[0] + thumb[2]) / 2.0; | ||
| 1236 | ui.event(Event::PointerMoved([x, thumb[1] + 2.0])); | ||
| 1237 | press(&mut ui, at, true); | ||
| 1238 | build(&mut ui); | ||
| 1239 | ui.event(Event::PointerMoved([x, thumb[1] + 60.0])); | ||
| 1240 | build(&mut ui); | ||
| 1241 | build(&mut ui); | ||
| 1242 | assert!(ui.popup_open(menu_id())); | ||
| 1243 | assert_ne!( | ||
| 1244 | ui.rect(menu_id().child("rows").child(0u64)) | ||
| 1245 | .map(|rect| rect[1]), | ||
| 1246 | top | ||
| 1247 | ); | ||
| 1248 | } |
crates/ui/src/text.rs+16-10| ... | @@ -11,6 +11,8 @@ pub(crate) struct Label { | ... | @@ -11,6 +11,8 @@ pub(crate) struct Label { |
| 11 | pub size: [f32; 2], | 11 | pub size: [f32; 2], |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | type LabelKey = (String, u32, Option<String>); | ||
| 15 | |||
| 14 | /// Labels shaped this frame or the previous one, keyed by their text and size. | 16 | /// Labels shaped this frame or the previous one, keyed by their text and size. |
| 15 | #[derive(Default)] | 17 | #[derive(Default)] |
| 16 | pub(crate) struct Texts { | 18 | pub(crate) struct Texts { |
| ... | @@ -18,13 +20,14 @@ pub(crate) struct Texts { | ... | @@ -18,13 +20,14 @@ pub(crate) struct Texts { |
| 18 | context: LayoutContext<()>, | 20 | context: LayoutContext<()>, |
| 19 | /// A family registered in place of the system's interface font. | 21 | /// A family registered in place of the system's interface font. |
| 20 | family: Option<String>, | 22 | family: Option<String>, |
| 21 | cache: HashMap<(String, u32), (Rc<Label>, u64)>, | 23 | /// By text, size and preferred family, with the frame each was last used. |
| 24 | cache: HashMap<LabelKey, (Rc<Label>, u64)>, | ||
| 22 | } | 25 | } |
| 23 | 26 | ||
| 24 | impl Texts { | 27 | impl Texts { |
| 25 | /// `size` is in logical pixels. | 28 | /// `size` is in logical pixels; `font` names a family to prefer over the interface's. |
| 26 | pub fn label(&mut self, text: &str, size: f32, frame: u64) -> Rc<Label> { | 29 | pub fn label(&mut self, text: &str, size: f32, font: Option<&str>, frame: u64) -> Rc<Label> { |
| 27 | let key = (text.to_owned(), size.to_bits()); | 30 | let key = (text.to_owned(), size.to_bits(), font.map(str::to_owned)); |
| 28 | if let Some((label, touched)) = self.cache.get_mut(&key) { | 31 | if let Some((label, touched)) = self.cache.get_mut(&key) { |
| 29 | *touched = frame; | 32 | *touched = frame; |
| 30 | return label.clone(); | 33 | return label.clone(); |
| ... | @@ -33,12 +36,15 @@ impl Texts { | ... | @@ -33,12 +36,15 @@ impl Texts { |
| 33 | .context | 36 | .context |
| 34 | .ranged_builder(&mut self.fonts, text, 1.0, false); | 37 | .ranged_builder(&mut self.fonts, text, 1.0, false); |
| 35 | let system = FontFamilyName::Generic(GenericFamily::SystemUi); | 38 | let system = FontFamilyName::Generic(GenericFamily::SystemUi); |
| 36 | builder.push_default(StyleProperty::FontFamily(match &self.family { | 39 | let families: Vec<_> = font |
| 37 | Some(family) => { | 40 | .into_iter() |
| 38 | FontFamily::List(Cow::Owned(vec![FontFamilyName::named(family), system])) | 41 | .chain(self.family.as_deref()) |
| 39 | } | 42 | .map(FontFamilyName::named) |
| 40 | None => FontFamily::Single(system), | 43 | .chain([system]) |
| 41 | })); | 44 | .collect(); |
| 45 | builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned( | ||
| 46 | families, | ||
| 47 | )))); | ||
| 42 | builder.push_default(StyleProperty::FontSize(size)); | 48 | builder.push_default(StyleProperty::FontSize(size)); |
| 43 | let mut layout = builder.build(text); | 49 | let mut layout = builder.build(text); |
| 44 | layout.break_all_lines(None); | 50 | layout.break_all_lines(None); |
crates/ui/src/theme.rs+12-1| ... | @@ -11,6 +11,11 @@ pub struct Theme { | ... | @@ -11,6 +11,11 @@ pub struct Theme { |
| 11 | /// The window's title bar, toolbar and tab row. | 11 | /// The window's title bar, toolbar and tab row. |
| 12 | pub strip: [f32; 4], | 12 | pub strip: [f32; 4], |
| 13 | pub accent: [f32; 4], | 13 | pub accent: [f32; 4], |
| 14 | /// The text caret, and selected text's fill with and without keyboard focus; the | ||
| 15 | /// platform's own where it has them. | ||
| 16 | pub caret: [f32; 4], | ||
| 17 | pub selection: [f32; 4], | ||
| 18 | pub inactive_selection: [f32; 4], | ||
| 14 | pub text: [f32; 4], | 19 | pub text: [f32; 4], |
| 15 | pub text_dim: [f32; 4], | 20 | pub text_dim: [f32; 4], |
| 16 | /// Text on section colours. | 21 | /// Text on section colours. |
| ... | @@ -59,6 +64,9 @@ impl Theme { | ... | @@ -59,6 +64,9 @@ impl Theme { |
| 59 | panel: srgb(0x1f, 0x22, 0x23), | 64 | panel: srgb(0x1f, 0x22, 0x23), |
| 60 | strip: srgb(0x27, 0x2a, 0x2b), | 65 | strip: srgb(0x27, 0x2a, 0x2b), |
| 61 | accent: srgb(0x00, 0x79, 0xa6), | 66 | accent: srgb(0x00, 0x79, 0xa6), |
| 67 | caret: srgb(0x00, 0x7a, 0xff), | ||
| 68 | selection: srgb(0x3f, 0x63, 0x8b), | ||
| 69 | inactive_selection: srgb(0x46, 0x46, 0x46), | ||
| 62 | text: srgb(0xdd, 0xde, 0xe0), | 70 | text: srgb(0xdd, 0xde, 0xe0), |
| 63 | text_dim: srgb(0x6b, 0x70, 0x78), | 71 | text_dim: srgb(0x6b, 0x70, 0x78), |
| 64 | ink: srgb(0xe8, 0xe9, 0xeb), | 72 | ink: srgb(0xe8, 0xe9, 0xeb), |
| ... | @@ -83,6 +91,9 @@ impl Theme { | ... | @@ -83,6 +91,9 @@ impl Theme { |
| 83 | panel: srgb(0xf4, 0xf5, 0xf7), | 91 | panel: srgb(0xf4, 0xf5, 0xf7), |
| 84 | strip: srgb(0xeb, 0xed, 0xf0), | 92 | strip: srgb(0xeb, 0xed, 0xf0), |
| 85 | accent: srgb(0x00, 0x79, 0xa6), | 93 | accent: srgb(0x00, 0x79, 0xa6), |
| 94 | caret: srgb(0x00, 0x7a, 0xff), | ||
| 95 | selection: srgb(0xb3, 0xd7, 0xff), | ||
| 96 | inactive_selection: srgb(0xdc, 0xdc, 0xdc), | ||
| 86 | text: srgb(0x1f, 0x23, 0x28), | 97 | text: srgb(0x1f, 0x23, 0x28), |
| 87 | text_dim: srgb(0x7b, 0x82, 0x8c), | 98 | text_dim: srgb(0x7b, 0x82, 0x8c), |
| 88 | ink: srgb(0x1d, 0x1e, 0x20), | 99 | ink: srgb(0x1d, 0x1e, 0x20), |
| ... | @@ -100,7 +111,7 @@ impl Theme { | ... | @@ -100,7 +111,7 @@ impl Theme { |
| 100 | } | 111 | } |
| 101 | } | 112 | } |
| 102 | 113 | ||
| 103 | /// Hovered controls and selected text: the accent over the base. | 114 | /// Hovered controls: the accent over the base. |
| 104 | pub fn hover(&self) -> [f32; 4] { | 115 | pub fn hover(&self) -> [f32; 4] { |
| 105 | crate::mix(self.base, self.accent, 0.35) | 116 | crate::mix(self.base, self.accent, 0.35) |
| 106 | } | 117 | } |
crates/ui/src/widgets.rs+22-5| ... | @@ -110,8 +110,9 @@ pub fn text_field( | ... | @@ -110,8 +110,9 @@ pub fn text_field( |
| 110 | let (selection, press) = ui.field(id); | 110 | let (selection, press) = ui.field(id); |
| 111 | (*selection, *press) | 111 | (*selection, *press) |
| 112 | }; | 112 | }; |
| 113 | let before = [selection.anchor(), selection.focus()]; | ||
| 113 | let (texts, frame) = ui.texts(); | 114 | let (texts, frame) = ui.texts(); |
| 114 | let mut label = texts.label(text, size, frame); | 115 | let mut label = texts.label(text, size, None, frame); |
| 115 | if let Some(x) = pointer { | 116 | if let Some(x) = pointer { |
| 116 | let layout = &label.layout; | 117 | let layout = &label.layout; |
| 117 | let unit = if signal.pressed { signal.unit } else { press.1 }; | 118 | let unit = if signal.pressed { signal.unit } else { press.1 }; |
| ... | @@ -197,13 +198,14 @@ pub fn text_field( | ... | @@ -197,13 +198,14 @@ pub fn text_field( |
| 197 | _ => continue, | 198 | _ => continue, |
| 198 | }; | 199 | }; |
| 199 | text.replace_range(range.clone(), inserted); | 200 | text.replace_range(range.clone(), inserted); |
| 200 | label = texts.label(text, size, frame); | 201 | label = texts.label(text, size, None, frame); |
| 201 | selection = Selection::from_byte_index( | 202 | selection = Selection::from_byte_index( |
| 202 | &label.layout, | 203 | &label.layout, |
| 203 | range.start + inserted.len(), | 204 | range.start + inserted.len(), |
| 204 | Affinity::Downstream, | 205 | Affinity::Downstream, |
| 205 | ); | 206 | ); |
| 206 | } | 207 | } |
| 208 | let moved = signal.pressed || before != [selection.anchor(), selection.focus()]; | ||
| 207 | { | 209 | { |
| 208 | let (stored, stored_press) = ui.field(id); | 210 | let (stored, stored_press) = ui.field(id); |
| 209 | *stored = selection; | 211 | *stored = selection; |
| ... | @@ -216,6 +218,7 @@ pub fn text_field( | ... | @@ -216,6 +218,7 @@ pub fn text_field( |
| 216 | text.as_str() | 218 | text.as_str() |
| 217 | }; | 219 | }; |
| 218 | let height = spec.size[1]; | 220 | let height = spec.size[1]; |
| 221 | let backdrop = spec.fill.unwrap_or(theme.base); | ||
| 219 | ui.open_as( | 222 | ui.open_as( |
| 220 | id, | 223 | id, |
| 221 | Spec { | 224 | Spec { |
| ... | @@ -236,14 +239,28 @@ pub fn text_field( | ... | @@ -236,14 +239,28 @@ pub fn text_field( |
| 236 | crate::Size::Pixels(height) => (height - line) / 2.0, | 239 | crate::Size::Pixels(height) => (height - line) / 2.0, |
| 237 | _ => 0.0, | 240 | _ => 0.0, |
| 238 | }; | 241 | }; |
| 242 | let fill = if ui.window_focused { | ||
| 243 | theme.selection | ||
| 244 | } else { | ||
| 245 | theme.inactive_selection | ||
| 246 | }; | ||
| 239 | for (rect, _) in selection.geometry(&label.layout) { | 247 | for (rect, _) in selection.geometry(&label.layout) { |
| 240 | ui.mark( | 248 | ui.mark( |
| 241 | [pad + rect.x0 as f32, top, pad + rect.x1 as f32, top + line], | 249 | [pad + rect.x0 as f32, top, pad + rect.x1 as f32, top + line], |
| 242 | theme.hover(), | 250 | fill, |
| 251 | 0.0, | ||
| 252 | ); | ||
| 253 | } | ||
| 254 | if ui.window_focused && selection.is_collapsed() { | ||
| 255 | let opacity = ui.blink(id, moved); | ||
| 256 | let x = pad + selection.focus().geometry(&label.layout, 0.0).x0 as f32; | ||
| 257 | let half = edit::CARET_WIDTH / 2.0; | ||
| 258 | ui.mark( | ||
| 259 | [x - half, top, x + half, top + line], | ||
| 260 | edit::caret_color(theme.caret, backdrop, opacity), | ||
| 261 | half, | ||
| 243 | ); | 262 | ); |
| 244 | } | 263 | } |
| 245 | let x = pad + selection.focus().geometry(&label.layout, 1.0).x0 as f32; | ||
| 246 | ui.mark([x, top, x + 1.0, top + line], theme.text); | ||
| 247 | } | 264 | } |
| 248 | ui.close(); | 265 | ui.close(); |
| 249 | signal | 266 | signal |