authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-26 09:36:04-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log216e64b3c750a6df75ac13a7f3779327f01cbe9e
tree2b425e0f21e068c09a07ce3ae206e502a3d76b14
parent878b4a52ab2bcb1aa6ef93be7f006594429deb4a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: native caret and selection, tag geometry, OneNote scroll and snap, iOS spike

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-5

47 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"
735dependencies = [735dependencies = [
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]
15521553
1554[[package]]
1555name = "mobile"
1556version = "0.1.0"
1557dependencies = [
1558 "canvas",
1559 "draw",
1560 "onestore",
1561 "parley",
1562 "pollster",
1563 "wgpu",
1564]
1565
1553[[package]]1566[[package]]
1554name = "moxcms"1567name = "moxcms"
1555version = "0.8.1"1568version = "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 @@
1import UIKit
2import UniformTypeIdentifiers
3
4@main
5final 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
17final 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.
36final 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
53final 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
113final 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 @@
1import UIKit
2
3private 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.
8private final class Position: UITextPosition {
9 let value: Int
10 init(_ value: Int) { self.value = value }
11}
12
13private 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
22private 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
34private extension UITextPosition { var offset: Int { (self as! Position).value } }
35private 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.
44final 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
2import UIKit
3
4extension 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.
3set -eu
4case "$PLATFORM_NAME" in
5iphonesimulator) target=aarch64-apple-ios-sim ;;
6*) target=aarch64-apple-ios ;;
7esac
8release=
9[ "$CONFIGURATION" = Release ] && release=--release
10root="$SRCROOT/../.."
11# Xcode's SDK variables would point host build scripts at the iOS SDK.
12exec 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}
236236
237/// Whether UTF-16 `offset` lies inside a hyperlink field, which OneNote has not been seen
238/// splitting between paragraphs.
239fn 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.
253fn 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`.
238fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize> + '_ {271fn 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 {
666709
667 /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties710 /// 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 subtree711 /// 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_container726 if container != end_container
679 || last <= first727 || 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};
8use onestore::ExGuid;8use onestore::ExGuid;
9use onestore::document::{Format, Kind};9use onestore::document::{Format, Kind};
10use onestore::page::text::{EditError, Paragraph};10use onestore::page::text::{EditError, Paragraph};
11use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title};11use onestore::page::{
12 Definition, Outline, Page, PageObject, PageParagraph, ParagraphContent, Title,
13};
12use parley::{14use 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 }
782813
...@@ -1099,10 +1130,14 @@ impl CanvasEditor {...@@ -1099,10 +1130,14 @@ impl CanvasEditor {
1099 })1130 })
1100 }1131 }
11011132
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 {
22262266
2227 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style2267 /// 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 = text2277 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 = nodes2338 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 keeps3041 /// 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 = self3064 pending.extend(
2996 .definitions3065 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 }
47824858
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}
213213
...@@ -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}
592593
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]
597fn 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]
594fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() {649fn 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}
670641
642impl 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 OneNote720/// 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 at722/// `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 }
12121261
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 {
5354
54 /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its55 /// 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 @@
1mod background;1mod background;
2mod picture;
23
3use super::{Paper, colorref};4use super::{Paper, colorref};
4use crate::editor::ReadOnlyObject;5use 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};
11use background::Background;12use background::Background;
12use draw::{Primitive, RasterImage, RenderError};13use draw::Primitive;
13use onestore::page::Page;14use onestore::page::Page;
14use std::fmt;15use picture::Picture;
16use std::{
17 fmt,
18 sync::{Arc, Mutex, OnceLock, mpsc},
19 task::Waker,
20 time::{Duration, Instant},
21};
1522
16/// Retained drawing data in the source page's coordinate system.23/// Retained drawing data in the source page's coordinate system.
17pub struct PageScene {24pub 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}
2331
32/// How long the zoom must hold before a raster is made again for it.
33const SETTLE: Duration = Duration::from_millis(150);
34
35/// Where work done off the frame thread lands while its requester keeps it.
36type 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`.
40fn 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.
71fn 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)]
25pub enum SceneHit<T> {76pub 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 }
166215
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 }
187237
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 }
262304
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.
266308 /// 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, rasterizing309 /// its raster for this paper and scale.
268 /// off this thread; `waker` is woken when a raster lands and the page should be drawn310 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 settled322 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 }
283396
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 }
673801
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 scene1394 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 reference1892 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 and1//! 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.
33
4use super::{SETTLE, Slot, density, queue};
4use crate::gpu::Paper;5use crate::gpu::Paper;
5use draw::RasterImage;6use draw::RasterImage;
6use resvg::{tiny_skia, usvg};7use resvg::{tiny_skia, usvg};
7use sha2::{Digest, Sha256};8use sha2::{Digest, Sha256};
8use std::{9use 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.9311/// 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.
313const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0;313const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0;
314/// How long the zoom must hold before art is rasterized again for it.
315const SETTLE: Duration = Duration::from_millis(150);
316314
317/// Template art standing in for a page's background picture.315/// Template art standing in for a page's background picture.
318pub(super) struct Background {316pub(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.
335struct 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}
344331
345impl Background {332impl 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_f32365 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 SETTLE391 };
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}
418398
419fn dark(paper: Paper) -> bool {399fn 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 > lightness402 ink > lightness
423}403}
424404
425/// The process's one background rasterizer, started on first use.
426fn 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
448fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> RasterImage {405fn 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 and436/// 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.
481fn onto(svg: &str, paper: Paper) -> String {438fn 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.92458 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 recoloured467 recoloured
511}468}
512469
513/// OKLab lightness and opponent axes of linear RGB.
514fn 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.
529fn 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}
543470
544#[cfg(test)]471#[cfg(test)]
545mod tests {472mod 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};
554481
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 scene645 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
4use super::{SETTLE, Slot, density, queue};
5use draw::RasterImage;
6use onestore::ExGuid;
7use 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.
16pub(super) const BUDGET: u64 = draw::MAX_IMAGE_BYTES / 2;
17
18pub(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
32impl 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
55fn 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.
64pub(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}
103103
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)]
107struct Paint {107struct 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}
192194
195/// Room, in OneNote pixels, the view leaves beyond content it scrolls to.
196const 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.
194fn home(display_scale: f32) -> [f32; 2] {199fn home(display_scale: f32) -> [f32; 2] {
195 [48.0 * display_scale; 2]200 [48.0 * display_scale; 2]
196}201}
197202
203/// Brings `scene`, drawn at `offset` in the view, to what `viewport` shows.
204fn 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
198impl PageView {218impl 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 }
229251
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 }
237259
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 }
244283
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 }
371410
...@@ -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 }
514558
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 }
521565
522 /// Brings the page's template backgrounds to `paper` and the current zoom before a566 /// 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 be567 /// 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 }
530574
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.caret628 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 }
610671
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 }
830898
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 }
839907
...@@ -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_focus979 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 = editor1373 // 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}
15331614
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.
1617fn 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
1534fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {1638fn 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 outline1787 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}
1010
11impl Scroll {11impl 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`.
16pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint {16pub(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}
15241524
1525#[test]
1526fn 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
1525fn click(view: &mut PageView, point: [f32; 2], now: Instant) {1568fn 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]
1701fn 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]
1770fn 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]
1820fn 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]
1840fn 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.
3const 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.
107const 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.
111pub 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)]
137mod 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 {
414414
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 = self438 let index = self
418 .shaped439 .shaped
419 .lines()440 .lines()
crates/canvas/src/lib.rs+1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1pub mod document;1pub mod document;
2pub mod editor;2pub mod editor;
3pub mod language;
3pub mod layout;4pub mod layout;
4pub mod math;5pub mod math;
5pub mod outline;6pub 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}
229234
230impl ParagraphTag {235impl 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}
233267
268/// How much further than its list spacing OneNote 2010 sets a marker's advance from its text.
269const MARKER_OFFSET: f32 = 3.9;
270
234/// A one-run paragraph standing in for an object's caption, so it lays out through the same271/// 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.
236fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParagraph {273fn 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}
266303
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.
268const DEFAULT_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0];305const MISSING_INDENT: f64 = 27.0;
269306
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.
270pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> {309pub(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 = equation616 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}
590647
...@@ -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; each664 /// 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 = self667 -self
611 .paragraphs668 .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 }
618673
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 }
695751
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 of767 /// 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 = unsupported918 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 }
11911263
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 }
18181901
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 }
20912407
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")]
22
3use canvas::{gpu::page::PageScene, layout::TextEngine};3use canvas::{
4 gpu::{Paper, page::PageScene},
5 layout::TextEngine,
6};
4use onestore::{7use 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);
155159
...@@ -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) = page167 let PageObject::Image(tiff) = page
163 .objects168 .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
6use canvas::{
7 document::TextPosition,
8 editor::{CanvasEditor, Formatting},
9 layout::TextEngine,
10};
11use onestore::{
12 ExGuid, PreparedEdit, RevisionIndex, Store,
13 document::Document,
14 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent},
15};
16use std::{collections::BTreeMap, path::Path};
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
19enum 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
33const 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
47struct Random(u64);
48
49impl 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
58fn 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.
77fn 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.
122fn 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`.
149fn 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.
164fn 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)]
287struct Tally {
288 samples: usize,
289 failures: BTreeMap<String, Vec<String>>,
290}
291
292fn 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]
369fn 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.
402enum Caret {
403 Before(&'static str),
404 After(&'static str),
405}
406
407/// A key or command the scripted edits press.
408enum 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.
420fn 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]
493fn 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]
8default = ["render"]8default = ["render"]
9# The wgpu renderer; `edit` needs only parley.9# The wgpu renderer; `edit` needs only parley.
10render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"]10render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash", "dep:wgpu"]
1111
12[dependencies]12[dependencies]
13parley.workspace = true13parley.workspace = true
1414
15bytemuck = { version = "1.25", features = ["derive"], optional = true }15bytemuck = { version = "1.25", features = ["derive"], optional = true }
16image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }16image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }
17linebender_resource_handle = { version = "0.1.1", optional = true }
17roxmltree = { version = "0.21", optional = true }18roxmltree = { version = "0.21", optional = true }
18swash = { version = "0.2.10", optional = true }19swash = { version = "0.2.10", optional = true }
19wgpu = { workspace = true, optional = true }20wgpu = { 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}
185185
186/// The caret's width in logical pixels, as AppKit's insertion indicator draws it.
187pub 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.
192pub 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.
219pub 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 the249/// 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, so250/// 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};
55
6use bytemuck::{Pod, Zeroable};6use bytemuck::{Pod, Zeroable};
7use image::ImageDecoder;7use image::ImageDecoder;
8use linebender_resource_handle::WeakBlob;
8use parley::fontique::Blob;9use parley::fontique::Blob;
9use std::{10use 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.
23pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;24pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
24const MAX_IMAGES: usize = 256;25const MAX_IMAGES: usize = 256;
26/// Decoded bytes one picture may take before `RasterImage::decode` shrinks it.
27const MAX_DECODE_BYTES: u64 = 256 * 1024 * 1024;
2528
26#[derive(Clone)]29#[derive(Clone)]
27pub struct RasterImage {30pub 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}
3134
32impl 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> {36fn 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.
65fn 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 limits75 }
52 .reserve(decoder.total_bytes())76}
53 .map_err(RenderError::ImageDecode)?;77
54 decoder78impl 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 }
62105
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 }
92128
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 {
104struct CachedImage {144struct 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}
108150
109struct Batch {151struct 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}
173218
...@@ -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}
181227
182impl Space {228impl 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 } = primitive606 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.images869 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 }
823879
...@@ -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}
14111469
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.
1472const LEGIBLE: f32 = 0.4;
1473
1474/// `color` lifted clear of a dark `backdrop`'s lightness, keeping hue and chroma.
1475fn 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.
1486pub 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.
1501pub 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
1413pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {1516pub 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 }
15001606
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]);
15081614
...@@ -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]);
15161622
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 }
15391645
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 target2130 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]
2name = "mobile"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7# A C surface over a canvas page for native mobile shells; see resources/ios-port.md.
8[lib]
9crate-type = ["staticlib"]
10
11[dependencies]
12canvas = { path = "../canvas", features = ["interaction"] }
13draw = { path = "../draw" }
14onestore = { path = "../onestore" }
15wgpu.workspace = true
16parley.workspace = true
17pollster = "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
8typedef struct Section Section;
9typedef struct View View;
10
11Section *sb_section_open(const char *path);
12size_t sb_section_count(const Section *section);
13const char *sb_section_title(const Section *section, size_t index);
14void sb_section_free(Section *section);
15
16View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale);
17void sb_view_free(View *view);
18void sb_view_resize(View *view, float width, float height, float scale);
19bool sb_view_render(View *view);
20bool sb_view_frame_pending(const View *view);
21void sb_view_content(View *view, float bounds[4]);
22void sb_view_set_transform(View *view, float zoom, float x, float y);
23bool sb_view_tap(View *view, float x, float y);
24bool sb_view_in_active_text(const View *view, float x, float y);
25
26uint32_t sb_text_length(const View *view);
27char *sb_text(const View *view, uint32_t start, uint32_t end);
28void sb_string_free(char *text);
29void sb_selection(const View *view, uint32_t range[2]);
30bool sb_select(View *view, uint32_t start, uint32_t end);
31bool sb_marked(const View *view, uint32_t range[2]);
32bool sb_set_marked(View *view, const char *text, uint32_t selected_start, uint32_t selected_end);
33void sb_unmark(View *view);
34bool sb_insert(View *view, const char *text);
35bool sb_replace(View *view, uint32_t start, uint32_t end, const char *text);
36bool sb_paste(View *view, const char *text, const char *language);
37bool sb_delete_backward(View *view);
38bool sb_caret_rect(const View *view, uint32_t offset, float rect[4]);
39size_t sb_range_rects(const View *view, uint32_t start, uint32_t end, float (*rects)[4], size_t capacity);
40uint32_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
9use 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};
16use draw::edit::{Key, NamedKey, SelectionUnit};
17use onestore::{RevisionIndex, Store, document::Document, page::Page};
18use parley::{Affinity, BoundingBox};
19use 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
30type 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.
33const POINT: f32 = 96.0 / 72.0;
34
35/// The platform's text interaction draws the caret and selection.
36const COLORS: TextColors = TextColors {
37 caret: [0.0; 4],
38 selection: [0.0; 4],
39 paper: Paper::WHITE,
40};
41
42pub struct Section {
43 pages: Vec<Page>,
44 titles: Vec<CString>,
45}
46
47impl 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)]
67struct Frame(AtomicBool);
68
69impl Wake for Frame {
70 fn wake(self: Arc<Self>) {
71 self.0.store(true, Ordering::Release);
72 }
73}
74
75pub struct View {
76 page: PageView,
77 surface: wgpu::Surface<'static>,
78 config: wgpu::SurfaceConfiguration,
79 renderer: draw::Renderer,
80 frame: Arc<Frame>,
81}
82
83fn report<T>(result: Result<T>) -> Option<T> {
84 result.map_err(|error| eprintln!("snowbound: {error}")).ok()
85}
86
87fn moved(response: Response) -> bool {
88 response.changed || response.moved
89}
90
91impl 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)]
397pub 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)]
410pub 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)]
416pub 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)]
423pub 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)]
433pub 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)]
453pub unsafe extern "C" fn sb_view_free(view: *mut View) {
454 drop(unsafe { Box::from_raw(view) });
455}
456
457#[unsafe(no_mangle)]
458pub 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)]
470pub 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)]
476pub 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)]
483pub 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)]
490pub 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)]
496pub 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)]
501pub 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)]
506pub 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)]
512pub 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)]
521pub unsafe extern "C" fn sb_string_free(text: *mut c_char) {
522 drop(unsafe { CString::from_raw(text) });
523}
524
525#[unsafe(no_mangle)]
526pub 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)]
533pub 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)]
538pub 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)]
548pub 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)]
561pub 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)]
570pub 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)]
582pub 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)]
603pub 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)]
619pub 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)]
630pub 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)]
641pub 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)]
656pub 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)]
661mod 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}
6060
61fn 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.
76pub 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.
62pub fn install_text_input(window: &Window) {89pub 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}
263290
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.
294pub 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.
265pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> {303pub 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}
313351
314pub 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.
354pub 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 = color370 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}
342392
...@@ -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")]
480unsafe 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.
490pub 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;
21use std::{21use 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};
27use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};27use 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}
231231
232/// A section or page read on a loader thread.
233enum Loaded {
234 Section(Session),
235 Page { space: ExGuid, page: Page },
236}
237
238/// A loader thread's request number and what it read.
239type 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.
243struct 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.
251const 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.
233enum Command {254enum Command {
234 OpenSection(usize),255 OpenSection(usize),
...@@ -240,6 +261,11 @@ enum Command {...@@ -240,6 +261,11 @@ enum Command {
240struct State {261struct 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 .fonts479 .fonts
452 .collection480 .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<_> = fonts938 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<_> = labels967 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::Subscript1022 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 ..spec1407 ..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 }
14581512
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 }
14671583
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 }
14771594
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}
20742208
2075/// Makes `section` the open one showing `space`, or its first page, and returns the2209/// The session for `section` showing `space`, or its first page.
2076/// page's scene and editor.2210fn read_session(
2077fn 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}
21042233
2105impl App {2234impl 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) {
601fn tags(scene: &mut Scene) {601fn 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.
41const SHADOW: [f32; 2] = [3.0, 0.0];41const SHADOW: [f32; 2] = [3.0, 0.0];
42const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];42const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];
43/// How far a popup slides in from its anchor as it fades in.
44const 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.
46const ICON: f32 = 16.0;44const ICON: f32 = 16.0;
47const ICON_GAP: f32 = 6.0;45const ICON_GAP: f32 = 6.0;
...@@ -182,9 +180,8 @@ pub enum Anchor {...@@ -182,9 +180,8 @@ pub enum Anchor {
182180
183impl Anchor {181impl Anchor {
184 /// Where a popup `size` long on `axis` starts in a window `room` long: past the182 /// 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 it183 /// 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 - slide198 first
202 } else if second >= 0.0 {199 } else if second >= 0.0 {
203 second + slide200 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 {
453452
454pub struct Ui {453pub 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}
484492
485impl Ui {493impl 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 }
515527
...@@ -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 }
525537
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.scale544 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 }
773789
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 = spec856 let label = spec
841 .text857 .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 0861 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 id871 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.texts904 self.texts
895 .label(text, self.theme.font_size, self.frame)905 .label(text, self.theme.font_size, None, self.frame)
896 .size906 .size
897 }907 }
898908
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 }
904931
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
7use std::collections::{HashMap, HashSet};7use std::collections::{HashMap, HashSet};
8use winit::keyboard::NamedKey;8use winit::keyboard::NamedKey;
99
10/// How far rows fade out under a scrolled list's edges.
11const 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.
11pub(crate) const GUTTER: f32 = 12.0;13pub(crate) const GUTTER: f32 = 12.0;
12/// Seconds rows take to reach their new places.14/// Seconds rows take to reach their new places.
13const DURATION: f32 = 0.15;15const DURATION: f32 = 0.25;
1416
15/// Items a list shows, in order.17/// Items a list shows, in order.
16pub trait Rows {18pub 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)]
36pub struct Item<'a> {36pub 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}
446449
450/// What is typed in open popup `id`'s filter field.
451pub 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
447fn state(ui: &mut Ui, id: Id) -> &mut Popup {458fn state(ui: &mut Ui, id: Id) -> &mut Popup {
448 ui.popups459 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}
221221
222impl Ui {222impl 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` and230 /// 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) {
341fn point_to(ui: &mut Ui, text: &str, index: usize) {341fn 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}
384384
385#[test]
386fn 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]
386fn text_fields_move_and_select_by_word() {433fn 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}
622669
623#[test]670#[test]
624fn popups_fade_in_except_at_a_point() {671fn 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.nodes678 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 .alpha682 .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}
655698
...@@ -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]
1211fn 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}
1313
14type 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)]
16pub(crate) struct Texts {18pub(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}
2326
24impl Texts {27impl 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 .context36 .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 }
102113
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 signal266 signal