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"
735735dependencies = [
736736 "bytemuck",
737737 "image",
738 "linebender_resource_handle",
738739 "parley",
739740 "png",
740741 "pollster",
......@@ -1550,6 +1551,18 @@ dependencies = [
15501551 "windows-sys 0.61.2",
15511552]
15521553
1554[[package]]
1555name = "mobile"
1556version = "0.1.0"
1557dependencies = [
1558 "canvas",
1559 "draw",
1560 "onestore",
1561 "parley",
1562 "pollster",
1563 "wgpu",
1564]
1565
15531566[[package]]
15541567name = "moxcms"
15551568version = "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>> {
3939 let margin_origin = page.margin_origin;
4040 let object_count = page.objects.len();
4141 let mut scene = PageScene::new(page, &mut engine)?;
42 while !scene.update_backgrounds(Paper::WHITE, 96.0 / 72.0, std::task::Waker::noop()) {
43 std::thread::sleep(Duration::from_millis(1));
44 }
42 scene.settle(None, 96.0 / 72.0, Paper::WHITE);
4543 let mut primitives = Vec::new();
4644 scene.append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)?;
4745 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> {
234234 Ok(())
235235}
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
237270/// Paragraph positions of each node's first text leaf, counting from `first`.
238271fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize> + '_ {
239272 nodes.iter().scan(first, |next, node| {
......@@ -610,8 +643,18 @@ impl TextDocument {
610643 return Err(EditError::UnsupportedContent);
611644 }
612645 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;
614647 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)?;
615658 let suffix =
616659 last_text.slice(range.end.offset..last_text.utf16_offset(last_text.text().len())?)?;
617660 let mut replacement = replacement.into_iter();
......@@ -666,28 +709,70 @@ impl TextDocument {
666709
667710 /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties
668711 /// 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.
670716 pub(crate) fn join(
671717 &self,
672718 upper: usize,
673719 lower: usize,
720 base: &Format,
674721 ) -> Result<Option<DocumentEdit>, EditError> {
675722 let (container, first, top) = self.leaf(upper).ok_or(EditError::InvalidRange)?;
676723 let (end_container, last, bottom) = self.leaf(lower).ok_or(EditError::InvalidRange)?;
677724 let nodes = self.container(container)?;
725 let (above, below) = (&top.text().unwrap().text, &bottom.text().unwrap().text);
678726 if container != end_container
679727 || last <= first
680728 || last > first + 1 && !(top.collapsed && subtree_end(nodes, first) == last)
729 || moves_object(above, above.utf16_offset(above.text().len())?, below, 0)?
681730 {
682731 return Ok(None);
683732 }
684733 let mut head = top.clone();
685734 let text = head.text_mut().unwrap();
686 // OneNote moves the lower text object into an emptied upper paragraph.
687 if text.text.text().is_empty() {
688 text.id = bottom.text().unwrap().id;
735 if above.text().is_empty() {
736 // OneNote moves the lower text object, with its style and any recording link, into
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 })))?;
689775 }
690 text.text.append(bottom.text().unwrap().text.clone())?;
691776 let moves = BTreeMap::from([(bottom.id, &head)]);
692777 let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?;
693778 Ok(Some(DocumentEdit {
crates/canvas/src/editor.rs+104-28
......@@ -8,7 +8,9 @@ use draw::edit::{self, Movement, SelectionUnit};
88use onestore::ExGuid;
99use onestore::document::{Format, Kind};
1010use 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};
1214use parley::{
1315 Affinity, BoundingBox,
1416 editing::{Cursor, Selection as ParagraphSelection},
......@@ -766,6 +768,35 @@ impl CanvasEditor {
766768 .filter(|outline| !self.has_page_outline(outline.id))
767769 .map(|outline| PageObject::Outline(outline.snapshot())),
768770 );
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));
769800 Ok(Page {
770801 title: self.header.title.clone(),
771802 identity: self.header.identity,
......@@ -776,7 +807,7 @@ impl CanvasEditor {
776807 .or(self.header.created),
777808 margin_origin: self.header.margin_origin,
778809 objects,
779 definitions: self.definitions.clone(),
810 definitions,
780811 })
781812 }
782813
......@@ -1099,10 +1130,14 @@ impl CanvasEditor {
10991130 })
11001131 }
11011132
1133 /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do.
11021134 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));
11041139 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())?;
11061141 node.level = base.level;
11071142 node.style = base.style;
11081143 Ok(node)
......@@ -1449,8 +1484,13 @@ impl CanvasEditor {
14491484 .iter()
14501485 .position(|object| matches!(object, page::Content::Image(image) if image.id == id))
14511486 .ok_or(EditError::InvalidRange)?;
1487 // An emptied outline keeps its slot for undo after leaving `outlines`.
14521488 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 }
14541494 _ => None,
14551495 };
14561496 let outline = if forward {
......@@ -2226,21 +2266,31 @@ impl CanvasEditor {
22262266
22272267 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style
22282268 /// 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> {
22302277 let lines = text
22312278 .split('\n')
22322279 .map(|line| line.strip_suffix('\r').unwrap_or(line))
22332280 .collect::<Vec<_>>();
22342281 let last = lines[lines.len() - 1];
2235 if lines.len() == 1 {
2236 return self.insert(engine, last);
2237 }
22382282 let [anchor, focus] = self.active_outline().selection.positions;
22392283 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 }
22402289 let edge = Paragraph::new(String::new(), self.typing_format(start)?);
22412290 let pasted = Format {
22422291 font: Some("Calibri".into()),
22432292 font_size: Some(11.0),
2293 language: Some(language),
22442294 ..Format::default()
22452295 };
22462296 let mut edit = self.active_outline().document.replace(
......@@ -2283,7 +2333,7 @@ impl CanvasEditor {
22832333 let format = self.typing_format(start)?;
22842334 let document = &self.active_outline().document;
22852335 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])?;
22872337 let nodes = document.container(edit.container)?;
22882338 let next = nodes
22892339 .get(crate::document::subtree_end(nodes, edit.range.start))
......@@ -2319,8 +2369,12 @@ impl CanvasEditor {
23192369 .keys()
23202370 .copied()
23212371 .find(|id| style(Some(*id)) == Some("p"));
2322 tail.text_mut().unwrap().text =
2323 Paragraph::new(String::new(), self.style_format(tail.style)?);
2372 // The heading's run formatting carries over under the body style (`c9-h1-*`).
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 );
23242378 }
23252379 let caret = TextPosition {
23262380 paragraph: start.paragraph + 1,
......@@ -2438,7 +2492,12 @@ impl CanvasEditor {
24382492 } else {
24392493 [focus.paragraph, neighbor]
24402494 };
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 {
24422501 return Ok(false);
24432502 };
24442503 let text = outline.document.paragraph(upper).unwrap();
......@@ -2982,23 +3041,33 @@ impl CanvasEditor {
29823041 /// Gives each paragraph an edit adds its own copy of the lists it carries, as OneNote keeps
29833042 /// a list node per paragraph.
29843043 fn own_lists(&mut self, edit: &mut DocumentEdit) -> Result<(), EditError> {
2985 let existing = self.active_outline().document.container(edit.container)?
2986 [edit.range.clone()]
2987 .iter()
2988 .map(|node| node.id)
3044 let existing = descendants(
3045 &self.active_outline().document.container(edit.container)?[edit.range.clone()],
3046 None,
3047 )
3048 .map(|(_, _, node)| node.id)
29893049 .collect::<BTreeSet<_>>();
2990 for node in &mut edit.replacement {
2991 if existing.contains(&node.id) {
2992 continue;
3050 let mut pending = edit.replacement.iter_mut().collect::<Vec<_>>();
3051 while let Some(node) = pending.pop() {
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 }
29933062 }
2994 for list in &mut node.lists {
2995 let definition = self
2996 .definitions
2997 .get(list)
2998 .ok_or(EditError::InvalidStructure)?
2999 .clone();
3000 *list = onestore::page::text::new_id()?;
3001 self.definitions.insert(*list, definition);
3063 if let ParagraphContent::Table(table) = &mut node.content {
3064 pending.extend(
3065 table
3066 .rows
3067 .iter_mut()
3068 .flat_map(|row| &mut row.cells)
3069 .flat_map(|cell| &mut cell.paragraphs),
3070 );
30023071 }
30033072 }
30043073 Ok(())
......@@ -4778,6 +4847,13 @@ mod tests {
47784847 assert!(editor.step_from_image(&mut engine, id, true).unwrap());
47794848 assert_eq!(editor.active_outline().id, after);
47804849 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);
47814857 }
47824858
47834859 #[test]
crates/canvas/src/editor/evidence.rs+59-4
......@@ -207,7 +207,7 @@ enum Key {
207207 Tab,
208208 ShiftTab,
209209 Type(&'static str),
210 Paste(&'static str),
210 Paste(&'static str, u32),
211211 Bullets,
212212}
213213
......@@ -238,7 +238,7 @@ fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) {
238238 Key::Tab => editor.tab(engine, false).unwrap(),
239239 Key::ShiftTab => editor.tab(engine, true).unwrap(),
240240 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(),
242242 Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(),
243243 }
244244}
......@@ -565,11 +565,12 @@ fn pasted_lines_are_plain_paragraphs_between_the_halves() {
565565 &mut engine,
566566 &target(kind),
567567 (1, Offset::Back(4)),
568 &[(Key::Paste(text), &format!("c7-{kind}-1"))],
568 &[(Key::Paste(text, 1033), &format!("c7-{kind}-1"))],
569569 );
570570 let document = &editor.active_outline().document;
571571 let format = Format {
572572 bold: None,
573 language: Some(1033),
573574 ..calibri(11.0, false)
574575 };
575576 assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format);
......@@ -586,10 +587,64 @@ fn pasted_lines_are_plain_paragraphs_between_the_halves() {
586587 &mut engine,
587588 " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second",
588589 (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")],
590591 );
591592}
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
593648#[test]
594649fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() {
595650 let mut engine = TextEngine::default();
crates/canvas/src/editor/format.rs+111-30
......@@ -560,36 +560,7 @@ impl CanvasEditor {
560560 }
561561 });
562562 }
563 Formatting::Check => {
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 }
563 Formatting::Check => self.check(&mut replacement, ranges, ends),
593564 Formatting::Toggle(_)
594565 | Formatting::Font(_)
595566 | Formatting::FontSize(_)
......@@ -668,6 +639,84 @@ impl CanvasEditor {
668639 }
669640}
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
671720/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote
672721/// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`,
673722/// `c8-tab-1.xml`), then to ■ as `corpus/private` nests bullets, and to i.; outdenting stops at
......@@ -1210,6 +1259,38 @@ mod tests {
12101259 .collect()
12111260 }
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
12131294 #[test]
12141295 fn tags_toggle_check_and_replace_tags_of_their_action_type() {
12151296 let mut engine = TextEngine::default();
crates/canvas/src/editor/table.rs+9-2
......@@ -311,8 +311,15 @@ impl CanvasEditor {
311311 };
312312 let (replacement, next) = if exit {
313313 table.rows.pop();
314 let first = self.blank_paragraph(source)?;
315 let second = self.blank_paragraph(source)?;
314 let blank = || {
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()?);
316323 (
317324 vec![wrapper, first, second],
318325 TextPosition {
crates/canvas/src/gpu/mod.rs+10-3
......@@ -32,6 +32,7 @@ impl Viewport {
3232 scale: self.scale,
3333 origin: self.origin,
3434 clip: None,
35 backdrop: None,
3536 primitives,
3637 }
3738 }
......@@ -53,7 +54,7 @@ impl Paper {
5354
5455 /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its
5556 /// 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] {
5758 let ink = 1.0 - (light[0] + light[1] + light[2]) / 3.0;
5859 let mut shade = light;
5960 for channel in 0..3 {
......@@ -78,8 +79,14 @@ impl Glyphs for TextLayout {
7879 bounds.baseline,
7980 run.style().brush.rise,
8081 [bounds.top, bounds.height],
81 // A highlight keeps its stored colour, so automatic text on it stays black.
82 |brush: &TextBrush| brush.color.or(brush.highlight.map(|_| 0)).map(colorref),
82 // Automatic text on a highlight stays black, but on a black highlight, which
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 },
8390 paint,
8491 )?;
8592 }
crates/canvas/src/gpu/page.rs+297-62
......@@ -1,4 +1,5 @@
11mod background;
2mod picture;
23
34use super::{Paper, colorref};
45use crate::editor::ReadOnlyObject;
......@@ -9,18 +10,68 @@ use crate::{
910 layout::{LayoutError, TextEngine},
1011};
1112use background::Background;
12use draw::{Primitive, RasterImage, RenderError};
13use draw::Primitive;
1314use 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
1623/// Retained drawing data in the source page's coordinate system.
1724pub struct PageScene {
1825 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>,
2028 /// Background pictures from OneNote's page templates, painted from their recreations.
2129 backgrounds: std::collections::BTreeMap<onestore::ExGuid, Background>,
2230}
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
2475#[derive(Debug, PartialEq)]
2576pub enum SceneHit<T> {
2677 Outline(T),
......@@ -106,7 +157,6 @@ pub enum SceneError {
106157 Layout(LayoutError),
107158 Editor(EditorError),
108159 MissingOutline,
109 Image(RenderError),
110160 MissingImage,
111161 InvalidGeometry,
112162}
......@@ -117,7 +167,6 @@ impl fmt::Display for SceneError {
117167 Self::Layout(error) => error.fmt(f),
118168 Self::Editor(error) => error.fmt(f),
119169 Self::MissingOutline => f.write_str("An editable outline is missing from this page."),
120 Self::Image(error) => write!(f, "Image rendering failed: {error:?}"),
121170 Self::MissingImage => f.write_str("An image is missing its data or dimensions."),
122171 Self::InvalidGeometry => {
123172 f.write_str("The page contains invalid object dimensions or positions.")
......@@ -146,7 +195,7 @@ impl PageScene {
146195 let mut objects = crate::editor::page::build(&mut page, engine, false)
147196 .map_err(SceneError::from)?
148197 .objects;
149 let pictures = Self::decode_images(&objects, None)?;
198 let pictures = Self::pictures(&objects, None)?;
150199 pictures.mark_unavailable(&mut objects, engine)?;
151200 Ok(Self {
152201 reference: Some(objects),
......@@ -159,12 +208,13 @@ impl PageScene {
159208 engine: &mut TextEngine,
160209 ) -> Result<(Self, CanvasEditor), SceneError> {
161210 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))?;
163212 scene.mark_unavailable(&mut editor.objects, engine)?;
164213 Ok((scene, editor))
165214 }
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.
168218 fn mark_unavailable(
169219 &self,
170220 objects: &mut Vec<Content>,
......@@ -174,7 +224,7 @@ impl PageScene {
174224 .into_iter()
175225 .map(|object| match object {
176226 Content::Image(source)
177 if !self.images.contains_key(&source.id)
227 if !self.pictures.contains_key(&source.id)
178228 && !self.backgrounds.contains_key(&source.id) =>
179229 {
180230 Content::unavailable(onestore::page::PageObject::Image(source), engine)
......@@ -185,24 +235,21 @@ impl PageScene {
185235 Ok(())
186236 }
187237
188 /// Decodes every picture the page draws, page-level ones and those inside outlines,
189 /// except the template backgrounds it recognises; one without decodable data is left out.
190 fn decode_images(
191 objects: &[Content],
192 editor: Option<&CanvasEditor>,
193 ) -> Result<Self, SceneError> {
238 /// Every picture the page draws, page-level ones and those inside outlines, except the
239 /// template backgrounds it recognises; one the renderer cannot decode is left out.
240 fn pictures(objects: &[Content], editor: Option<&CanvasEditor>) -> Result<Self, SceneError> {
194241 fn nested<'a>(
195242 nodes: &'a [onestore::page::PageParagraph],
196 payloads: &mut Vec<(onestore::ExGuid, Option<&'a [u8]>)>,
243 payloads: &mut Vec<(onestore::ExGuid, Option<&'a Arc<[u8]>>)>,
197244 ) {
198245 for node in nodes {
199246 match &node.content {
200247 onestore::page::ParagraphContent::Image(image) => {
201 payloads.push((image.id, image.bytes.as_deref()))
248 payloads.push((image.id, image.bytes.as_ref()))
202249 }
203250 // A file without the icon OneNote rendered for it keeps an empty slot.
204251 onestore::page::ParagraphContent::Attachment(file) => {
205 if let Some(icon) = file.preview.as_deref() {
252 if let Some(icon) = file.preview.as_ref() {
206253 payloads.push((file.id, Some(icon)))
207254 }
208255 }
......@@ -225,7 +272,7 @@ impl PageScene {
225272 {
226273 backgrounds.insert(source.id, art);
227274 } else {
228 payloads.push((source.id, source.bytes.as_deref()))
275 payloads.push((source.id, source.bytes.as_ref()))
229276 }
230277 }
231278 Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads),
......@@ -239,46 +286,112 @@ impl PageScene {
239286 Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {}
240287 }
241288 }
242 let mut images = std::collections::BTreeMap::new();
243 let mut bytes = 0_u64;
289 let mut pictures = std::collections::BTreeMap::new();
244290 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 {
246292 continue;
247293 };
248 bytes += image.pixels().len() as u64;
249 if bytes > draw::MAX_IMAGE_BYTES {
250 return Err(SceneError::Image(RenderError::ImageBudget));
251 }
252 if images.insert(id, image).is_some() {
294 if pictures.insert(id, picture).is_some() {
253295 return Err(SceneError::InvalidGeometry);
254296 }
255297 }
256298 Ok(Self {
257299 reference: None,
258 images,
300 pictures,
259301 backgrounds,
260302 })
261303 }
262304
263 pub fn image(&self, id: onestore::ExGuid) -> Option<&RasterImage> {
264 self.images.get(&id)
265 }
266
267 /// Brings template backgrounds to `paper` at `scale` device pixels per point, rasterizing
268 /// off this thread; `waker` is woken when a raster lands and the page should be drawn
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(
305 /// Brings template backgrounds to `paper` and the pictures near `view`, a rectangle in
306 /// scene coordinates, to `scale` device pixels per point, rasterizing and decoding off
307 /// this thread; `waker` is woken when one lands and the page should be drawn again.
308 /// Call before collecting each frame's primitives. True once everything in view shows
309 /// its raster for this paper and scale.
310 pub fn update_pictures(
272311 &mut self,
273 paper: Paper,
312 editor: Option<&CanvasEditor>,
313 view: [f32; 4],
274314 scale: f32,
275 waker: &std::task::Waker,
315 paper: Paper,
316 waker: &Waker,
276317 ) -> bool {
277318 let mut settled = true;
278319 for art in self.backgrounds.values_mut() {
279320 settled &= art.update(paper, scale, waker);
280321 }
281 settled
322 let rects = self.picture_rects(editor);
323 picture::update(&mut self.pictures, rects, view, scale, waker) && settled
324 }
325
326 /// Waits until the whole page shows its rasters for `paper` at `scale`, for drawing it
327 /// offscreen.
328 pub fn settle(&mut self, editor: Option<&CanvasEditor>, scale: f32, paper: Paper) {
329 let everything = [
330 f32::NEG_INFINITY,
331 f32::NEG_INFINITY,
332 f32::INFINITY,
333 f32::INFINITY,
334 ];
335 while !self.update_pictures(editor, everything, scale, paper, Waker::noop()) {
336 std::thread::sleep(Duration::from_millis(1));
337 }
338 }
339
340 /// The raster a picture or file icon shows, once decoded.
341 pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> {
342 self.pictures.get(&id)?.image()
343 }
344
345 /// Where pictures and file icons sit, in scene coordinates.
346 fn picture_rects(&self, editor: Option<&CanvasEditor>) -> Vec<(onestore::ExGuid, [f32; 4])> {
347 fn outline(
348 layout: &crate::outline::OutlineLayout,
349 [x, y]: [f32; 2],
350 rects: &mut Vec<(onestore::ExGuid, [f32; 4])>,
351 ) {
352 rects.extend(layout.objects.iter().map(|object| {
353 let [x0, y0, x1, y1] = object.rect;
354 (object.id, [x0 + x, y0 + y, x1 + x, y1 + y])
355 }));
356 }
357 let mut rects = Vec::new();
358 for content in self.objects(editor).into_iter().flatten() {
359 match content {
360 Content::Image(source) => {
361 let [x, y] = [
362 source.layout.x.unwrap_or(0.0),
363 source.layout.y.unwrap_or(0.0),
364 ];
365 if let (Some(width), Some(height)) =
366 (source.layout.max_width, source.layout.max_height)
367 {
368 rects.push((source.id, [x, y, x + width, y + height]));
369 }
370 }
371 Content::Outline {
372 source,
373 layout,
374 below_title,
375 } => {
376 let origin = [
377 source.layout.x.unwrap_or(0.0),
378 source.layout.y.unwrap_or(0.0),
379 ];
380 if let Ok(origin) = outline_origin(origin, *below_title, editor) {
381 outline(layout, origin, &mut rects);
382 }
383 }
384 Content::Editable(id) => {
385 if let Some(editable) =
386 editor.and_then(|editor| editor.visible_outlines().find(|o| o.id == *id))
387 {
388 outline(editable.shaped(), editable.origin(), &mut rects);
389 }
390 }
391 Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {}
392 }
393 }
394 rects
282395 }
283396
284397 /// Pictures, files and handwriting inside an outline whose origin is `origin`.
......@@ -302,7 +415,8 @@ impl PageScene {
302415 y1 + origin[1],
303416 ],
304417 };
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) {
306420 primitives.push(Primitive::Image { image, rect });
307421 }
308422 match &object.kind {
......@@ -312,7 +426,7 @@ impl PageScene {
312426 crate::outline::ObjectKind::Unsupported(_) => {
313427 append_placeholder(rect, paper, primitives)
314428 }
315 crate::outline::ObjectKind::Picture if !self.images.contains_key(&object.id) => {
429 crate::outline::ObjectKind::Picture if picture.is_none_or(Picture::failed) => {
316430 append_placeholder(rect, paper, primitives)
317431 }
318432 crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {}
......@@ -588,21 +702,35 @@ impl PageScene {
588702 let object_origin = [origin[0] + offset[0], origin[1] + offset[1]];
589703 match content {
590704 Content::Image(source) => {
705 // The paper shows until the worker's raster lands.
591706 let (image, [width, height]) = match self.backgrounds.get(&source.id) {
592 // The paper shows until the worker's raster lands.
593 Some(art) => match art.image(paper) {
594 Some(image) => (image, art.size),
595 None => continue,
596 },
597 None => (
598 self.images
707 Some(art) => (art.image(paper), art.size),
708 None => {
709 let picture = self
710 .pictures
599711 .get(&source.id)
600 .ok_or(SceneError::MissingImage)?,
601 [
712 .ok_or(SceneError::MissingImage)?;
713 let size = [
602714 source.layout.max_width.ok_or(SceneError::MissingImage)?,
603715 source.layout.max_height.ok_or(SceneError::MissingImage)?,
604 ],
605 ),
716 ];
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;
606734 };
607735 primitives.push(Primitive::Image {
608736 image,
......@@ -632,7 +760,7 @@ impl PageScene {
632760 };
633761 outline.append_table_primitives(primitives, object_origin, paper);
634762 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);
636764 self.append_outline_objects(outline, object_origin, None, paper, primitives);
637765 for (index, paragraph) in outline.visible(everything) {
638766 outline.append_paragraph_primitives(
......@@ -671,12 +799,14 @@ impl crate::outline::OutlineLayout {
671799 })
672800 }
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.
675804 pub fn append_background_primitives(
676805 &self,
677806 primitives: &mut Vec<Primitive<'_>>,
678807 origin: [f32; 2],
679808 rows: [f32; 2],
809 paper: Paper,
680810 ) {
681811 for (index, paragraph) in self.visible(rows) {
682812 for (mut rect, color) in paragraph.text.backgrounds() {
......@@ -697,7 +827,11 @@ impl crate::outline::OutlineLayout {
697827 rect.x1 as f32 + origin[0],
698828 rect.y1 as f32 + origin[1],
699829 ],
700 color: colorref(color),
830 color: if color == 0 {
831 paper.ink
832 } else {
833 colorref(color)
834 },
701835 });
702836 }
703837 }
......@@ -786,7 +920,7 @@ impl crate::outline::OutlineLayout {
786920 origin[0] + self.tag_column_offset() + tag.origin[0],
787921 y + tag.origin[1],
788922 ],
789 size: crate::outline::ParagraphTag::SIZE,
923 size: tag.size,
790924 tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],
791925 });
792926 }
......@@ -1253,7 +1387,8 @@ mod tests {
12531387 background,
12541388 }));
12551389 }
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);
12571392 let images = |editor: &CanvasEditor| {
12581393 let mut primitives = Vec::new();
12591394 scene
......@@ -1749,8 +1884,10 @@ mod tests {
17491884 ],
17501885 };
17511886 let mut engine = TextEngine::default();
1752 let reference = PageScene::new(page(), &mut engine).unwrap();
1753 let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap();
1887 let mut reference = PageScene::new(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);
17541891 let mut reference_primitives = Vec::new();
17551892 reference
17561893 .append_primitives(&mut reference_primitives, [0.0; 2], Paper::WHITE)
......@@ -1975,7 +2112,8 @@ mod tests {
19752112 })],
19762113 };
19772114 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);
19792117 for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] {
19802118 assert!(matches!(
19812119 PageScene::new(page(invalid), &mut engine),
......@@ -2046,7 +2184,8 @@ mod tests {
20462184 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
20472185 };
20482186 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);
20502189 let hit = |x| scene.hit_test::<()>([x, 10.0], Some(&editor), |_| None);
20512190 assert_eq!(hit(60.0), Some(SceneHit::Image(id)));
20522191 assert_eq!(hit(10.0), None);
......@@ -2070,4 +2209,100 @@ mod tests {
20702209 .collect();
20712210 assert_eq!(rects, [[0.0, 0.0, 100.0, 100.0], [70.0, 5.0, 120.0, 55.0]]);
20722211 }
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 }
20732308}
crates/canvas/src/gpu/page/background.rs+21-94
......@@ -1,12 +1,12 @@
11//! OneNote 2010's page template art, recognised by the SHA-256 of the embedded picture and
22//! painted from vector recreations that suit the paper.
33
4use super::{SETTLE, Slot, density, queue};
45use crate::gpu::Paper;
56use draw::RasterImage;
67use resvg::{tiny_skia, usvg};
78use sha2::{Digest, Sha256};
89use std::{
9 sync::{Arc, Mutex, OnceLock, Weak, mpsc},
1010 task::Waker,
1111 time::{Duration, Instant},
1212};
......@@ -311,8 +311,6 @@ const TEMPLATES: &[(&str, &str)] = &[
311311/// A raster holds at most this many bytes; page-sized art stops sharpening past about 2.9
312312/// device pixels per point.
313313const 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
317315/// Template art standing in for a page's background picture.
318316pub(super) struct Background {
......@@ -328,18 +326,7 @@ struct Variant {
328326 /// The raster painted and its device pixels per point.
329327 shown: Option<(RasterImage, f32)>,
330328 /// The one raster being made and its device pixels per point; replacing it abandons it.
331 pending: Option<(Arc<Mutex<Option<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,
329 pending: Option<(Slot<RasterImage>, f32)>,
343330}
344331
345332impl Background {
......@@ -375,8 +362,7 @@ impl Background {
375362 pub fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool {
376363 let dark = dark(paper);
377364 let [width, height] = self.size.map(|side| side.max(1.0));
378 let density = 2_f32
379 .powf((scale.max(0.25).log2() * 2.0).ceil() / 2.0)
365 let density = density(scale)
380366 .min((MAX_RASTER_BYTES / (4.0 * width * height)).sqrt())
381367 .min(4096.0 / width.max(height));
382368 let variant = &mut self.variants[usize::from(dark)];
......@@ -394,22 +380,16 @@ impl Background {
394380 if shown != Some(density)
395381 && variant.pending.as_ref().map(|(_, made)| *made) != Some(density)
396382 {
397 let slot = Arc::default();
398 worker()
399 .send(Job {
400 svg: self.svg,
401 dark: dark.then_some(paper),
402 size: [width, height].map(|side| (side * density).round().max(1.0) as u32),
403 start: Instant::now()
404 + if shown.is_some() {
405 SETTLE
406 } else {
407 Duration::ZERO
408 },
409 slot: Arc::downgrade(&slot),
410 waker: waker.clone(),
411 })
412 .expect("The background worker outlives its jobs");
383 let svg = self.svg;
384 let paper = dark.then_some(paper);
385 let size = [width, height].map(|side| (side * density).round().max(1.0) as u32);
386 let start = Instant::now()
387 + if shown.is_some() {
388 SETTLE
389 } else {
390 Duration::ZERO
391 };
392 let slot = queue(start, waker, move || rasterize(svg, paper, size));
413393 variant.pending = Some((slot, density));
414394 }
415395 shown == Some(density)
......@@ -417,34 +397,11 @@ impl Background {
417397}
418398
419399fn dark(paper: Paper) -> bool {
420 let [lightness, ..] = oklab(paper.color);
421 let [ink, ..] = oklab(paper.ink);
400 let [lightness, ..] = draw::oklab(paper.color);
401 let [ink, ..] = draw::oklab(paper.ink);
422402 ink > lightness
423403}
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
448405fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> RasterImage {
449406 let tree = match dark {
450407 Some(paper) => parse(&onto(svg, paper)),
......@@ -479,8 +436,8 @@ fn parse(svg: &str) -> usvg::Tree {
479436/// the paper towards its ink, easing off so the art stays behind the text, and hue and
480437/// chroma carry over. Keywords, such as a mask's `white`, stay as they are.
481438fn onto(svg: &str, paper: Paper) -> String {
482 let [lightness, a, b] = oklab(paper.color);
483 let [ink, ..] = oklab(paper.ink);
439 let [lightness, a, b] = draw::oklab(paper.color);
440 let [ink, ..] = draw::oklab(paper.ink);
484441 let mut pieces = svg.split('#');
485442 let mut recoloured = pieces.next().unwrap_or_default().to_owned();
486443 for piece in pieces {
......@@ -494,9 +451,9 @@ fn onto(svg: &str, paper: Paper) -> String {
494451 continue;
495452 };
496453 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));
498455 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| {
500457 let encoded = if value <= 0.003_130_8 {
501458 value * 12.92
502459 } else {
......@@ -510,36 +467,6 @@ fn onto(svg: &str, paper: Paper) -> String {
510467 recoloured
511468}
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
544471#[cfg(test)]
545472mod tests {
......@@ -550,7 +477,7 @@ mod tests {
550477 document::Layout,
551478 page::{Image, Page, PageObject},
552479 };
553 use std::collections::BTreeMap;
480 use std::{collections::BTreeMap, sync::Arc};
554481
555482 const DARK: Paper = Paper {
556483 color: [0.0137, 0.0144, 0.0159, 1.0],
......@@ -713,7 +640,7 @@ mod tests {
713640 .unwrap();
714641 assert!(before.is_empty(), "the paper shows until the raster lands");
715642 drop(before);
716 settle(|| scene.update_backgrounds(DARK, 1.0, Waker::noop()));
643 scene.settle(None, 1.0, DARK);
717644 let mut primitives = Vec::new();
718645 scene
719646 .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() {
323323 }
324324 let input = start.elapsed();
325325 let start = Instant::now();
326 view.update_pictures(colors.paper, std::task::Waker::noop());
326327 let primitives = view.primitives(colors).unwrap();
327328 let built = start.elapsed();
328329 let start = Instant::now();
crates/canvas/src/interaction/accessibility.rs+35
......@@ -1711,4 +1711,39 @@ pub(super) mod tests {
17111711 assert!(access.selection(second, selection).is_err());
17121712 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
17131713 }
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 }
17141749}
crates/canvas/src/interaction/mod.rs+191-69
......@@ -101,11 +101,11 @@ pub struct TextColors {
101101 pub paper: Paper,
102102}
103103
104/// How to paint an outline: whether the caret is in its blink-on phase, the view scale,
105/// document points per device pixel, and the text colours.
104/// How to paint an outline: the caret's opacity, the view scale, document points per
105/// logical pixel, and the text colours.
106106#[derive(Clone, Copy)]
107107struct Paint {
108 show_caret: bool,
108 caret: f32,
109109 scale: f32,
110110 pixel: f32,
111111 colors: TextColors,
......@@ -186,15 +186,35 @@ pub struct PageView {
186186 object_focus: Option<ObjectFocus>,
187187 modifiers: Modifiers,
188188 focused: bool,
189 caret: bool,
190 blink_at: Instant,
189 /// The caret's opacity in its blink.
190 caret: f32,
191 /// When the caret last moved, which restarts its blink.
192 blink_from: Instant,
191193}
192194
195/// Room, in OneNote pixels, the view leaves beyond content it scrolls to.
196const PAD: f32 = 11.0;
197
193198/// Where a page's origin sits in a view that has not scrolled, in device pixels.
194199fn home(display_scale: f32) -> [f32; 2] {
195200 [48.0 * display_scale; 2]
196201}
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
198218impl PageView {
199219 /// `size` is in device pixels; `double_click` is the platform's double-click interval.
200220 pub fn new(
......@@ -205,7 +225,7 @@ impl PageView {
205225 display_scale: f32,
206226 double_click: Duration,
207227 ) -> Self {
208 Self {
228 let mut view = Self {
209229 editor,
210230 engine,
211231 scene,
......@@ -222,9 +242,11 @@ impl PageView {
222242 object_focus: None,
223243 modifiers: Modifiers::default(),
224244 focused: true,
225 caret: true,
226 blink_at: Instant::now() + Duration::from_millis(500),
227 }
245 caret: 1.0,
246 blink_from: Instant::now(),
247 };
248 view.place_opened();
249 view
228250 }
229251
230252 /// Shows a page reloaded from storage in place of the edited one.
......@@ -235,11 +257,28 @@ impl PageView {
235257 self.object_focus = None;
236258 }
237259
238 /// Shows another page from its top-left corner, keeping the zoom.
260 /// Shows another page as OneNote opens one, keeping the zoom.
239261 pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {
240262 self.replace(editor, scene);
241 self.viewport.origin = home(self.display_scale);
242 self.scroll().clamp(&mut self.viewport);
263 self.place_opened();
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);
243282 }
244283
245284 pub fn modifiers(&self) -> Modifiers {
......@@ -364,8 +403,8 @@ impl PageView {
364403 /// Keeps the view in bounds and restarts the caret blink after a change.
365404 fn changed(&mut self) -> Result<Response> {
366405 self.scroll().clamp(&mut self.viewport);
367 self.caret = true;
368 self.blink_at = Instant::now() + Duration::from_millis(500);
406 self.caret = 1.0;
407 self.blink_from = Instant::now();
369408 Ok(Response::changed())
370409 }
371410
......@@ -492,9 +531,10 @@ impl PageView {
492531 .filter(|_| self.editor.has_page_outline(outline.id))
493532 .map(|(_, offset)| *offset)
494533 .unwrap_or([0.0; 2]);
534 let [left, top] = reach(outline);
495535 [
496 rect.x0 as f32 + offset[0],
497 rect.y0 as f32 + offset[1],
536 left + offset[0],
537 top + offset[1],
498538 rect.x1 as f32 + offset[0],
499539 rect.y1 as f32 + offset[1],
500540 ]
......@@ -509,7 +549,11 @@ impl PageView {
509549 ]
510550 })
511551 });
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 )
513557 }
514558
515559 /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`,
......@@ -519,15 +563,31 @@ impl PageView {
519563 self.moved()
520564 }
521565
522 /// Brings the page's template backgrounds to `paper` and the current zoom before a
523 /// frame's primitives; `waker` is woken off the main thread when the view should be
524 /// drawn again because a background raster landed.
525 pub fn update_backgrounds(&mut self, paper: Paper, waker: &std::task::Waker) {
526 if let Some((scene, _)) = &mut self.scene {
527 scene.update_backgrounds(paper, self.viewport.scale, waker);
566 /// Brings the page's template backgrounds to `paper` and its pictures to the view and
567 /// zoom before a frame's primitives; `waker` is woken off the main thread when the view
568 /// should be drawn again because a raster landed.
569 pub fn update_pictures(&mut self, paper: Paper, waker: &std::task::Waker) {
570 if let Some((scene, offset)) = &mut self.scene {
571 update_pictures(scene, *offset, &self.editor, self.viewport, paper, waker);
528572 }
529573 }
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
531591 /// Everything to draw this frame.
532592 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {
533593 let preview = match &self.drag {
......@@ -565,12 +625,15 @@ impl PageView {
565625 preview,
566626 self.object_focus,
567627 Paint {
568 show_caret: self.caret
569 && self.focused
628 caret: if self.focused
570629 && !matches!(
571630 self.drag,
572631 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
573 ),
632 ) {
633 self.caret
634 } else {
635 0.0
636 },
574637 scale: self.viewport.scale,
575638 pixel: self.pixel(),
576639 colors,
......@@ -589,7 +652,7 @@ impl PageView {
589652 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,
590653 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
591654 (None, Some(Hit::Date(_))) => Cursor::Pointer,
592 (None, Some(Hit::ReadOnly(_))) => Cursor::Default,
655 (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default,
593656 _ => Cursor::Text,
594657 }
595658 }
......@@ -600,12 +663,10 @@ impl PageView {
600663 if !(self.focused && self.object_focus.is_none() && anchor == focus) {
601664 return (false, None);
602665 }
603 let repaint = now >= self.blink_at;
604 if repaint {
605 self.caret = !self.caret;
606 self.blink_at = now + Duration::from_millis(500);
607 }
608 (repaint, Some(self.blink_at))
666 let (caret, hold) = edit::caret_blink(now.saturating_duration_since(self.blink_from));
667 let repaint = caret != self.caret;
668 self.caret = caret;
669 (repaint, Some(now + hold))
609670 }
610671
611672 /// `size` in device pixels.
......@@ -710,6 +771,13 @@ impl PageView {
710771 return Ok(Response::request(Request::EditDate(field)));
711772 }
712773 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 }
713781 Some(Hit::Image { id, handle }) => {
714782 self.set_object_focus(Some(ObjectFocus::Image(id)));
715783 self.drag = Some(Drag::Image {
......@@ -828,12 +896,12 @@ impl PageView {
828896 self.edited()
829897 }
830898
831 /// Clipboard text; see [`CanvasEditor::paste`].
832 pub fn paste(&mut self, text: &str) -> Result<Response> {
899 /// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`].
900 pub fn paste(&mut self, text: &str, language: u32) -> Result<Response> {
833901 if !self.accepts_text() {
834902 return Ok(Response::default());
835903 }
836 self.editor.paste(&mut self.engine, text)?;
904 self.editor.paste(&mut self.engine, text, language)?;
837905 self.edited()
838906 }
839907
......@@ -905,6 +973,9 @@ impl PageView {
905973 option,
906974 command,
907975 } = self.modifiers;
976 if key == &Key::Named(NamedKey::Modifier) {
977 return Ok(Response::default());
978 }
908979 if let Some(ObjectFocus::Image(id)) = self.object_focus
909980 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))
910981 {
......@@ -946,9 +1017,6 @@ impl PageView {
9461017 self.drag,
9471018 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
9481019 ) {
949 if key == &Key::Named(NamedKey::Modifier) {
950 return Ok(Response::default());
951 }
9521020 self.drag = None;
9531021 if key == &Key::Named(NamedKey::Escape) {
9541022 return self.changed();
......@@ -1148,6 +1216,11 @@ pub enum Hit {
11481216 grab: [f32; 2],
11491217 },
11501218 ReadOnly(usize),
1219 /// A check box tag on paragraph `paragraph`.
1220 Check {
1221 outline: onestore::ExGuid,
1222 paragraph: onestore::ExGuid,
1223 },
11511224 /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles.
11521225 Image {
11531226 id: onestore::ExGuid,
......@@ -1211,6 +1284,23 @@ pub fn page_hit_test(
12111284 ],
12121285 });
12131286 }
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 }
12141304 let text = outline.bounds();
12151305 if !(text.x0..=text.x1).contains(&f64::from(x))
12161306 || !(text.y0..=text.y1).contains(&f64::from(y))
......@@ -1229,17 +1319,7 @@ pub fn page_hit_test(
12291319 handle: [0, 0],
12301320 });
12311321 }
1232 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 {
1322 if x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3] {
12431323 return Some(Hit::Text {
12441324 id: outline.id,
12451325 point: [
......@@ -1290,10 +1370,10 @@ fn page_primitives<'a>(
12901370) -> Result<Vec<Primitive<'a>>> {
12911371 let mut primitives = Vec::new();
12921372 let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| {
1293 let outline = editor
1294 .visible_outlines()
1295 .find(|outline| outline.id == id)
1296 .ok_or(crate::gpu::page::SceneError::MissingOutline)?;
1373 // An emptied page outline leaves the editor but keeps its paint slot for undo.
1374 let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else {
1375 return Ok(());
1376 };
12971377 let outline = match preview {
12981378 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,
12991379 _ => outline,
......@@ -1310,6 +1390,7 @@ fn page_primitives<'a>(
13101390 outline,
13111391 [origin[0] + offset[0], origin[1] + offset[1]],
13121392 paint.pixel,
1393 paint.colors.paper,
13131394 primitives,
13141395 );
13151396 }
......@@ -1531,6 +1612,29 @@ fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec<Primitiv
15311612 }
15321613}
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
15341638fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {
15351639 std::array::from_fn(|axis| {
15361640 let offset = margin[axis];
......@@ -1563,9 +1667,23 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
15631667 body_top,
15641668 );
15651669 }
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);
15661684 (
15671685 [
1568 bounds.x0 as f32 - 7.5 - inset,
1686 bounds.x0 as f32 + left - 7.5 - inset,
15691687 body_top - HANDLE_HEIGHT,
15701688 bounds.x1 as f32 + inset,
15711689 bounds.y1 as f32 + HANDLE_HEIGHT + inset,
......@@ -1578,6 +1696,7 @@ fn append_outline_chrome(
15781696 outline: &TextOutline,
15791697 origin: [f32; 2],
15801698 pixel: f32,
1699 paper: crate::gpu::Paper,
15811700 primitives: &mut Vec<Primitive<'_>>,
15821701) {
15831702 let [x, y] = origin;
......@@ -1595,7 +1714,7 @@ fn append_outline_chrome(
15951714 rect: [left, top, right, bottom],
15961715 radius: [6.0 * pixel, (bottom - top) * 0.5],
15971716 stroke: Some(Stroke::Dashed(pixel)),
1598 color: crate::gpu::colorref(0x007f7f7f),
1717 color: paper.shade(crate::gpu::colorref(0x007f7f7f)),
15991718 });
16001719 return;
16011720 }
......@@ -1603,19 +1722,19 @@ fn append_outline_chrome(
16031722 rect: [left, top, right, body_top],
16041723 radius: [3.0 * pixel; 2],
16051724 stroke: None,
1606 color: crate::gpu::colorref(0x00e8ebed),
1725 color: paper.shade(crate::gpu::colorref(0x00e8ebed)),
16071726 });
16081727 primitives.push(Primitive::RoundedRect {
16091728 rect: [right - 9.0 * pixel, top, right, body_top],
16101729 radius: [3.0 * pixel; 2],
16111730 stroke: None,
1612 color: crate::gpu::colorref(0x00e5dee7),
1731 color: paper.shade(crate::gpu::colorref(0x00e5dee7)),
16131732 });
16141733 primitives.push(Primitive::RoundedRect {
16151734 rect: [left, top, right, bottom],
16161735 radius: [3.0 * pixel; 2],
16171736 stroke: Some(Stroke::Solid(pixel)),
1618 color: crate::gpu::colorref(0x00d9cfd8),
1737 color: paper.shade(crate::gpu::colorref(0x00d9cfd8)),
16191738 });
16201739 let middle = (top + body_top) * 0.5;
16211740 for offset in [-3.0, 0.0, 3.0] {
......@@ -1629,7 +1748,7 @@ fn append_outline_chrome(
16291748 ],
16301749 radius: [pixel * 0.5; 2],
16311750 stroke: None,
1632 color: crate::gpu::colorref(0x00b4a5b4),
1751 color: paper.shade(crate::gpu::colorref(0x00b4a5b4)),
16331752 });
16341753 }
16351754 for column in [0.0, 1.0, 2.0] {
......@@ -1640,7 +1759,7 @@ fn append_outline_chrome(
16401759 ] {
16411760 primitives.push(Primitive::Rect {
16421761 rect: [x, middle - half, x + pixel, middle + half],
1643 color: crate::gpu::colorref(0x00b4a5b4),
1762 color: paper.shade(crate::gpu::colorref(0x00b4a5b4)),
16441763 });
16451764 }
16461765 }
......@@ -1655,7 +1774,7 @@ fn append_outline<'a>(
16551774) -> Result<()> {
16561775 let [x, y] = origin;
16571776 let Paint {
1658 show_caret,
1777 caret,
16591778 scale,
16601779 pixel,
16611780 colors,
......@@ -1667,7 +1786,7 @@ fn append_outline<'a>(
16671786 .append_table_primitives(primitives, origin, colors.paper);
16681787 outline
16691788 .shaped()
1670 .append_background_primitives(primitives, origin, rows);
1789 .append_background_primitives(primitives, origin, rows, colors.paper);
16711790 if let Some(editor) = editor {
16721791 for rect in editor.selection_rects()? {
16731792 primitives.push(Primitive::Rect {
......@@ -1703,18 +1822,21 @@ fn append_outline<'a>(
17031822 });
17041823 }
17051824 let [anchor, focus] = editor.selection().positions;
1706 if show_caret && anchor == focus {
1707 let rect = editor.caret(2.0 * pixel)?;
1825 if caret > 0.0 && anchor == focus {
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;
17081830 primitives.push(Primitive::RoundedRect {
17091831 rect: [
1710 rect.x0 as f32 + x,
1832 middle - half,
17111833 rect.y0 as f32 + y,
1712 rect.x1 as f32 + x,
1834 middle + half,
17131835 rect.y1 as f32 + y,
17141836 ],
1715 radius: [pixel; 2],
1837 radius: [half; 2],
17161838 stroke: None,
1717 color: colors.caret,
1839 color: edit::caret_color(colors.caret, colors.paper.color, caret),
17181840 });
17191841 }
17201842 }
crates/canvas/src/interaction/scroll.rs+7-6
......@@ -9,8 +9,9 @@ pub struct Scroll {
99}
1010
1111impl Scroll {
12 pub fn new(viewport: Viewport, bounds: impl Iterator<Item = [f32; 4]>) -> Self {
13 let mut min = [0.0_f32; 2];
12 /// `pad` is the room, in device pixels, OneNote leaves beyond content it scrolls to.
13 pub fn new(viewport: Viewport, pad: f32, bounds: impl Iterator<Item = [f32; 4]>) -> Self {
14 let mut min = [f32::INFINITY; 2];
1415 let mut max = [0.0_f32; 2];
1516 for rect in bounds {
1617 for axis in 0..2 {
......@@ -18,9 +19,9 @@ impl Scroll {
1819 max[axis] = max[axis].max(rect[axis + 2]);
1920 }
2021 }
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.
2223 for axis in 0..2 {
23 min[axis] *= viewport.scale;
24 min[axis] = (min[axis] * viewport.scale - pad).min(0.0);
2425 max[axis] =
2526 ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]);
2627 }
......@@ -45,9 +46,9 @@ mod tests {
4546 scale: 2.0,
4647 origin: [9999.0; 2],
4748 };
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());
4950 scroll.clamp(&mut viewport);
50 assert_eq!(viewport.origin, [160.0, 40.0]);
51 assert_eq!(viewport.origin, [171.0, 51.0]);
5152 viewport.origin = [-9999.0; 2];
5253 scroll.clamp(&mut viewport);
5354 assert_eq!(viewport.origin, [-1272.0, -1872.0]);
crates/canvas/src/interaction/tests.rs+234-3
......@@ -15,7 +15,7 @@ const COLORS: TextColors = TextColors {
1515/// Paint for a view at `scale` device pixels per point on a display of `display_scale`.
1616pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint {
1717 Paint {
18 show_caret,
18 caret: f32::from(u8::from(show_caret)),
1919 scale,
2020 pixel: display_scale / scale,
2121 colors: COLORS,
......@@ -823,7 +823,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
823823 editor.active_outline(),
824824 [24.0, 48.0],
825825 Paint {
826 show_caret: false,
826 caret: 0.0,
827827 scale: 1.0,
828828 pixel: 1.0,
829829 colors: COLORS,
......@@ -894,7 +894,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
894894 editor.active_outline(),
895895 [24.0, 48.0],
896896 Paint {
897 show_caret: false,
897 caret: 0.0,
898898 scale: 1.0,
899899 pixel: 1.0,
900900 colors: COLORS,
......@@ -1522,6 +1522,49 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
15221522 (view, id)
15231523}
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
15251568fn click(view: &mut PageView, point: [f32; 2], now: Instant) {
15261569 let _ = view.pointer_moved(point).unwrap();
15271570 assert!(view.pointer_pressed(now).unwrap().changed);
......@@ -1653,3 +1696,191 @@ fn toolbar_commands_wait_for_composition_and_object_focus() {
16531696 assert!(view.format(bold).unwrap().changed);
16541697 assert_eq!(view.editor.format_state().unwrap().toggles, [Toggle::Bold]);
16551698}
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 {
414414
415415 pub fn caret(&self, cursor: Cursor, width: f32) -> BoundingBox {
416416 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 }
417438 let index = self
418439 .shaped
419440 .lines()
crates/canvas/src/lib.rs+1
......@@ -1,5 +1,6 @@
11pub mod document;
22pub mod editor;
3pub mod language;
34pub mod layout;
45pub mod math;
56pub mod outline;
crates/canvas/src/outline.rs+350-34
......@@ -198,6 +198,9 @@ pub struct ParagraphLayout {
198198 /// continuing from the previous sibling's.
199199 pub(crate) number: Option<(u32, bool)>,
200200 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>,
201204 /// An equation draws in two dimensions in place of its linear text.
202205 pub math: Option<crate::math::MathLayout>,
203206}
......@@ -223,14 +226,48 @@ pub struct ParagraphTag {
223226 pub icon: TagIcon,
224227 /// Coordinates are outline-local in x and paragraph-local in y.
225228 pub origin: [f32; 2],
229 /// The icon's side, which follows its paragraph's first run.
230 pub size: f32,
226231 pub label: String,
227232 pub disabled: bool,
228233}
229234
230235impl ParagraphTag {
236 /// The icon's side beside 10 to 17.5 pt text.
231237 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 }
232266}
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
234271/// A one-run paragraph standing in for an object's caption, so it lays out through the same
235272/// shaping (and caching) as the outline's text.
236273fn 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]> {
264301 .then_some(size)
265302}
266303
267/// The table OneNote writes on every outline it creates, standing in for a missing one.
268const DEFAULT_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0];
304/// The step OneNote gives each level past the end of an indentation table.
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.
270309pub(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 };
276310 if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {
277311 return Err(LayoutError::InvalidIndentation);
278312 }
279 let known = (level as usize).min(indents.len() - 1);
280 let indent = (indents[1..=known]
313 let known = (level as usize).min(indents.len().saturating_sub(1));
314 let indent = (indents
315 .get(1..=known)
316 .unwrap_or_default()
281317 .iter()
282318 .map(|v| f64::from(*v))
283319 .sum::<f64>()
284 + f64::from(level - known as u32) * f64::from(*indents.last().unwrap()))
285 as f32;
320 + f64::from(level - known as u32) * MISSING_INDENT) as f32;
286321 if !indent.is_finite() || indent >= width {
287322 return Err(LayoutError::InvalidIndentation);
288323 }
......@@ -486,7 +521,7 @@ impl ParagraphLayout {
486521 )?
487522 };
488523 let mut markers = Vec::new();
489 let mut marker_x = indent;
524 let mut marker_x = indent - MARKER_OFFSET;
490525 let mut number = None;
491526 for id in paragraph.lists.iter().rev() {
492527 let definition = definitions.get(id).ok_or(LayoutError::InvalidList)?;
......@@ -523,7 +558,11 @@ impl ParagraphLayout {
523558 let marker = Paragraph::new(
524559 value,
525560 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()),
527566 font_size: definition.format.font_size.or(format.font_size),
528567 color,
529568 ..Format::default()
......@@ -542,6 +581,7 @@ impl ParagraphLayout {
542581 markers.push((layout, [marker_x, y]));
543582 }
544583 text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?;
584 let baseline = text.lines().next().unwrap().1.baseline;
545585 let mut tags = Vec::new();
546586 for tag in &tag_definitions {
547587 let (tag, shape, label) = (tag.0, tag.1, tag.2);
......@@ -563,11 +603,12 @@ impl ParagraphLayout {
563603 Some(121) => TagIcon::Music,
564604 _ => return Err(LayoutError::UnsupportedContent),
565605 };
566 // Later tags follow the first to its right, toward the text.
567 let x = marker_x - 20.25 + 12.0 * tags.len() as f32;
606 let size = format.font_size.unwrap_or(11.0);
607 let side = ParagraphTag::side(size);
568608 tags.push(ParagraphTag {
569609 icon,
570 origin: [x, 0.0],
610 origin: [0.0, ParagraphTag::top(baseline, size, side)],
611 size: side,
571612 label: label.clone().unwrap_or_default(),
572613 disabled: tag.status & 2 != 0,
573614 });
......@@ -575,7 +616,7 @@ impl ParagraphLayout {
575616 let math = equation
576617 .then(|| crate::math::layout(engine, &source.text))
577618 .transpose()?;
578 Ok(Self {
619 let mut result = Self {
579620 id: paragraph.id,
580621 origin: [indent, 0.0],
581622 projection,
......@@ -584,7 +625,23 @@ impl ParagraphLayout {
584625 number,
585626 tags,
586627 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 }
588645 }
589646}
590647
......@@ -607,13 +664,11 @@ impl OutlineLayout {
607664 /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each
608665 /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column.
609666 pub fn tag_column_offset(&self) -> f32 {
610 let widest = self
667 -self
611668 .paragraphs
612669 .iter()
613 .map(|p| p.tags.len())
614 .max()
615 .unwrap_or(0);
616 -12.0 * widest.saturating_sub(1) as f32
670 .map(|p| p.tags.iter().skip(1).map(|tag| tag.size).sum::<f32>())
671 .fold(0.0, f32::max)
617672 }
618673
619674 /// Innermost table cell containing a visible paragraph index.
......@@ -690,9 +745,25 @@ impl OutlineLayout {
690745 shape,
691746 )?;
692747 result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width());
748 result.place_tags();
693749 Ok(result)
694750 }
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
696767 /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of
697768 /// `hiding`; at the root, keeps a block for each node.
698769 #[allow(clippy::too_many_arguments)]
......@@ -804,7 +875,7 @@ impl OutlineLayout {
804875 &caption(node.id, file.id, name, format),
805876 None,
806877 ATTACHMENT_WIDTH,
807 &[0.0],
878 &[0.0, 0.0],
808879 )?;
809880 label.reset_origin(x);
810881 let object = ObjectLayout {
......@@ -841,7 +912,7 @@ impl OutlineLayout {
841912 ),
842913 None,
843914 w - 16.0,
844 &[0.0],
915 &[0.0, 0.0],
845916 )?;
846917 label.reset_origin(x + 8.0);
847918 let h = unsupported
......@@ -1187,6 +1258,7 @@ impl OutlineLayout {
11871258 }
11881259 self.size = size;
11891260 self.widths = widths;
1261 self.place_tags();
11901262 }
11911263
11921264 pub(crate) fn table_width(&self) -> f32 {
......@@ -1251,7 +1323,7 @@ impl OutlineLayout {
12511323 if cell.paragraphs.is_empty() || !cell.unsupported.is_empty() {
12521324 return Err(LayoutError::UnsupportedContent);
12531325 }
1254 let child = Self::flow(
1326 let mut child = Self::flow(
12551327 edited_nodes(&cell.paragraphs, Some(cell.id), edit),
12561328 &cell.indents,
12571329 width,
......@@ -1261,6 +1333,9 @@ impl OutlineLayout {
12611333 shape,
12621334 )?;
12631335 height = height.max(child.size[1]);
1336 for paragraph in &mut child.paragraphs {
1337 paragraph.parent.get_or_insert(cell.id);
1338 }
12641339 let paragraph_start = result.paragraphs.len();
12651340 result.append(child, [x, y + 3.54]);
12661341 result.tables[0].cells.push(CellLayout {
......@@ -1754,7 +1829,11 @@ mod tests {
17541829 )
17551830 .unwrap();
17561831 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 );
17581837 assert_eq!(tagged.tags[0].label, "Label");
17591838 assert!(tagged.tags[0].disabled);
17601839 assert_eq!(tagged.text.height(), plain.text.height());
......@@ -1797,8 +1876,12 @@ mod tests {
17971876 &definitions,
17981877 )
17991878 .unwrap();
1800 assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 20.25);
1801 assert_eq!(tagged.tags[0].origin[1], 0.0);
1879 assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 12.9);
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 );
18021885 let Kind::TagDefinition { shape, .. } = &mut definitions.get_mut(&id).unwrap().kind else {
18031886 unreachable!()
18041887 };
......@@ -1816,6 +1899,210 @@ mod tests {
18161899 ));
18171900 }
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
18192106 #[test]
18202107 fn files_and_ink_take_their_own_paragraph_in_the_flow() {
18212108 use onestore::page::{Attachment, Ink, InkStroke};
......@@ -1855,7 +2142,7 @@ mod tests {
18552142 let mut engine = TextEngine::default();
18562143 let layout = OutlineLayout::flow(
18572144 [&before, &file, &ink, &after].into_iter(),
1858 &[0.0],
2145 &[0.0, 0.0],
18592146 468.0,
18602147 false,
18612148 0,
......@@ -2082,11 +2369,40 @@ mod tests {
20822369 outline.layout(&mut engine, &BTreeMap::new()),
20832370 Err(LayoutError::InvalidIndentation)
20842371 ));
2085 // OneNote stores only the level-zero indent for an outline of top-level paragraphs.
2086 outline.indents = vec![0.0];
20872372 outline.paragraphs.truncate(1);
2088 let single = outline.layout(&mut engine, &BTreeMap::new()).unwrap();
2089 assert_eq!(single.paragraphs[0].origin[0], 0.0);
2373 for (indents, x) in [(vec![18.0, 0.0, 27.0, 27.0], 0.0), (vec![0.0], 27.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 }
20902406 }
20912407
20922408 #[test]
crates/canvas/tests/reopen.rs+8-3
......@@ -1,6 +1,9 @@
11#![cfg(feature = "gpu")]
22
3use canvas::{gpu::page::PageScene, layout::TextEngine};
3use canvas::{
4 gpu::{Paper, page::PageScene},
5 layout::TextEngine,
6};
47use onestore::{
58 ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store,
69 document::Document,
......@@ -149,7 +152,8 @@ fn undecodable_pictures_show_placeholders_and_keep_their_data() {
149152 _ => None,
150153 })
151154 .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);
153157 assert!(scene.image(picture).is_none());
154158 assert_eq!(editor.page().unwrap(), page);
155159
......@@ -158,7 +162,8 @@ fn undecodable_pictures_show_placeholders_and_keep_their_data() {
158162 .into_iter()
159163 .find(|page| page.title == "Image tiff")
160164 .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);
162167 let PageObject::Image(tiff) = page
163168 .objects
164169 .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
77[features]
88default = ["render"]
99# 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
1212[dependencies]
1313parley.workspace = true
1414
1515bytemuck = { version = "1.25", features = ["derive"], optional = true }
1616image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }
17linebender_resource_handle = { version = "0.1.1", optional = true }
1718roxmltree = { version = "0.21", optional = true }
1819swash = { version = "0.2.10", optional = true }
1920wgpu = { workspace = true, optional = true }
crates/draw/src/edit.rs+104
......@@ -183,6 +183,69 @@ impl Clicks {
183183 }
184184}
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
186249/// The selection a drag makes from the selection its press made to the one under the
187250/// pointer, both by `unit`, ordered by `position`. Words and paragraphs stay whole, so
188251/// the press's selection stays covered when the drag reverses past it.
......@@ -454,4 +517,45 @@ mod tests {
454517 "extending keeps the anchor"
455518 );
456519 }
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 }
457561}
crates/draw/src/render.rs+198-48
......@@ -5,6 +5,7 @@ pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};
55
66use bytemuck::{Pod, Zeroable};
77use image::ImageDecoder;
8use linebender_resource_handle::WeakBlob;
89use parley::fontique::Blob;
910use std::{
1011 collections::{HashMap, HashSet},
......@@ -22,6 +23,8 @@ const MAX_VERTICES: usize = 65_536;
2223/// Decoded bytes of all images one frame may paint.
2324pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
2425const 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
2629#[derive(Clone)]
2730pub struct RasterImage {
......@@ -29,39 +32,84 @@ pub struct RasterImage {
2932 pixels: Blob<u8>,
3033}
3134
32impl RasterImage {
33 pub fn decode(encoded: &[u8]) -> Result<Self, RenderError> {
34 if encoded.len() as u64 > MAX_IMAGE_BYTES {
35 return Err(RenderError::ImageBudget);
35/// A PNG, JPEG, GIF or TIFF decoder for `encoded`, refusing pictures past the decode limits.
36fn decoder(encoded: &[u8]) -> Result<impl ImageDecoder + '_, RenderError> {
37 if encoded.len() as u64 > MAX_DECODE_BYTES {
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;
3670 }
37 let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?;
38 let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format);
39 let mut limits = image::Limits::default();
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);
71 let linear = srgb(pixel[0], pixel[1], pixel[2]);
72 for (byte, value) in pixel[..3].iter_mut().zip(linear) {
73 *byte = srgb_byte(value * f32::from(alpha) / 255.0);
5074 }
51 limits
52 .reserve(decoder.total_bytes())
53 .map_err(RenderError::ImageDecode)?;
54 decoder
55 .set_limits(limits)
56 .map_err(RenderError::ImageDecode)?;
57 let pixels = image::DynamicImage::from_decoder(decoder)
75 }
76}
77
78impl RasterImage {
79 /// The pixel size of an encoded picture, read from its header, or why `decode` would
80 /// refuse it.
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)
5890 .map_err(RenderError::ImageDecode)?
5991 .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())
61104 }
62105
63106 /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones.
64107 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> {
65113 let bytes = u64::from(size[0])
66114 .checked_mul(u64::from(size[1]))
67115 .and_then(|v| v.checked_mul(4))
......@@ -72,24 +120,16 @@ impl RasterImage {
72120 if bytes > MAX_IMAGE_BYTES {
73121 return Err(RenderError::ImageBudget);
74122 }
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 }
87123 Ok(Self {
88124 size,
89125 pixels: pixels.into(),
90126 })
91127 }
92128
129 pub fn size(&self) -> [u32; 2] {
130 self.size
131 }
132
93133 /// The identity caches and uploads share across clones.
94134 pub fn id(&self) -> u64 {
95135 self.pixels.id()
......@@ -104,6 +144,8 @@ impl RasterImage {
104144struct CachedImage {
105145 binding: wgpu::BindGroup,
106146 bytes: u64,
147 /// The texture goes when every copy of its image has.
148 pixels: WeakBlob<u8>,
107149}
108150
109151struct Batch {
......@@ -168,6 +210,9 @@ pub struct Layer<'a> {
168210 pub origin: [f32; 2],
169211 /// Device bounds `[left, top, right, bottom]` the layer paints within.
170212 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]>,
171216 pub primitives: &'a [Primitive<'a>],
172217}
173218
......@@ -177,6 +222,7 @@ struct Space {
177222 size: [u32; 2],
178223 scale: f32,
179224 origin: [f32; 2],
225 backdrop: Option<[f32; 4]>,
180226}
181227
182228impl Space {
......@@ -545,6 +591,8 @@ impl Renderer {
545591 {
546592 return Err(RenderError::InvalidLayer);
547593 }
594 self.images
595 .retain(|_, image| image.pixels.upgrade().is_some());
548596 let mut active_images = HashSet::new();
549597 let mut image_bytes = 0;
550598 for layer in layers {
......@@ -552,6 +600,7 @@ impl Renderer {
552600 size,
553601 scale: layer.scale,
554602 origin: layer.origin,
603 backdrop: layer.backdrop,
555604 };
556605 for primitive in layer.primitives {
557606 if let Primitive::Image { image, rect } = primitive
......@@ -626,6 +675,7 @@ impl Renderer {
626675 size,
627676 scale: layer.scale,
628677 origin: layer.origin,
678 backdrop: layer.backdrop,
629679 };
630680 let bounds = match layer.clip {
631681 Some(clip) => space.scissor(clip),
......@@ -816,8 +866,14 @@ impl Renderer {
816866 },
817867 ],
818868 });
819 self.images
820 .insert(image.id(), CachedImage { binding, bytes });
869 self.images.insert(
870 image.id(),
871 CachedImage {
872 binding,
873 bytes,
874 pixels: image.pixels.downgrade(),
875 },
876 );
821877 Ok(())
822878 }
823879
......@@ -929,7 +985,7 @@ impl Renderer {
929985 if glyph.color {
930986 [1.0; 4]
931987 } else {
932 run.color.unwrap_or(ink)
988 run.color.map_or(ink, |color| legible(color, space.backdrop))
933989 },
934990 )?;
935991 }
......@@ -946,7 +1002,9 @@ impl Renderer {
9461002 y + (decoration.thickness * scale).max(1.0),
9471003 ],
9481004 [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)),
9501008 )?;
9511009 }
9521010 Ok(())
......@@ -1410,6 +1468,51 @@ fn srgb_byte(value: f32) -> u8 {
14101468}
14111469
14121470/// 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
14131516pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {
14141517 let linear = |byte: u8| {
14151518 let value = f32::from(byte) / 255.0;
......@@ -1491,18 +1594,21 @@ mod tests {
14911594 .unwrap()
14921595 .write_image_data(&rgba)
14931596 .unwrap();
1494 let decoded = RasterImage::decode(&encoded).unwrap();
1597 let decoded = RasterImage::decode(&encoded, [2, 1]).unwrap();
14951598 assert_eq!(decoded.size, [2, 1]);
14961599 assert_eq!(decoded.pixels(), [255, 64, 0, 255, 0, 93, 188, 128]);
14971600 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 );
14991605 }
15001606
15011607 let mut jpeg = Vec::new();
15021608 image::codecs::jpeg::JpegEncoder::new(&mut jpeg)
15031609 .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8)
15041610 .unwrap();
1505 let decoded = RasterImage::decode(&jpeg).unwrap();
1611 let decoded = RasterImage::decode(&jpeg, [4, 4]).unwrap();
15061612 assert_eq!(decoded.size, [4, 4]);
15071613 assert_eq!(decoded.pixels(), [255; 4 * 4 * 4]);
15081614
......@@ -1510,11 +1616,11 @@ mod tests {
15101616 image::codecs::gif::GifEncoder::new(&mut gif)
15111617 .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8)
15121618 .unwrap();
1513 let decoded = RasterImage::decode(&gif).unwrap();
1619 let decoded = RasterImage::decode(&gif, [2, 2]).unwrap();
15141620 assert_eq!(decoded.size, [2, 2]);
15151621 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]] {
15181624 let mut header = Vec::new();
15191625 let mut encoder = png::Encoder::new(&mut header, size[0], size[1]);
15201626 encoder.set_color(png::ColorType::Rgba);
......@@ -1522,8 +1628,8 @@ mod tests {
15221628 let mut writer = encoder.write_header().unwrap();
15231629 writer.write_chunk(png::chunk::IDAT, &[]).unwrap();
15241630 drop(writer);
1525 let error = RasterImage::decode(&header).err().unwrap();
1526 if size[0] == 8192 {
1631 let error = RasterImage::measure(&header).err().unwrap();
1632 if size[0] == 8193 {
15271633 assert!(matches!(error, RenderError::ImageBudget), "{error:?}");
15281634 } else {
15291635 assert!(
......@@ -1537,6 +1643,39 @@ mod tests {
15371643 }
15381644 }
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
15401679 #[test]
15411680 fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() {
15421681 assert!(matches!(
......@@ -1641,6 +1780,7 @@ mod tests {
16411780 scale: 2.0,
16421781 origin: [24.0; 2],
16431782 clip: None,
1783 backdrop: None,
16441784 primitives,
16451785 }]
16461786 }
......@@ -1980,6 +2120,12 @@ mod tests {
19802120 }
19812121 assert!(renderer.images.len() <= MAX_IMAGES);
19822122 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 );
19832129 renderer.images.clear();
19842130 target
19852131 .draw(
......@@ -2035,6 +2181,7 @@ mod tests {
20352181 scale: 2.0,
20362182 origin: [0.0; 2],
20372183 clip: None,
2184 backdrop: None,
20382185 primitives: &primitives,
20392186 }],
20402187 )
......@@ -2096,12 +2243,14 @@ mod tests {
20962243 scale: 1.0,
20972244 origin: [0.0; 2],
20982245 clip: Some([100.0, 50.0, 200.5, 80.0]),
2246 backdrop: None,
20992247 primitives: &fill,
21002248 },
21012249 Layer {
21022250 scale: 2.0,
21032251 origin: [300.0, 10.0],
21042252 clip: None,
2253 backdrop: None,
21052254 primitives: &chrome,
21062255 },
21072256 ],
......@@ -2128,6 +2277,7 @@ mod tests {
21282277 scale: 0.0,
21292278 origin: [0.0; 2],
21302279 clip: None,
2280 backdrop: None,
21312281 primitives: &fill,
21322282 }]
21332283 ),
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
5858 }
5959}
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
6188/// Install before AccessKit subclasses the same view, preserving its restoration chain.
6289pub fn install_text_input(window: &Window) {
6390 MainThreadMarker::new().expect("Text input belongs to the main thread");
......@@ -261,6 +288,17 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
261288 }
262289}
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
264302/// The application's icon as the Dock shows it, `pixels` square.
265303pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> {
266304 let side = pixels as usize;
......@@ -311,12 +349,22 @@ pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> {
311349 draw::RasterImage::new([pixels; 2], rgba).ok()
312350}
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");
315356 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];
316363 let space = NSColorSpace::sRGBColorSpace();
317 [
364 let colors = [
318365 NSColor::textInsertionPointColor(),
319366 NSColor::selectedTextBackgroundColor(),
367 NSColor::unemphasizedSelectedTextBackgroundColor(),
320368 ]
321369 .map(|color| {
322370 let color = color
......@@ -336,7 +384,9 @@ pub fn text_colors() -> [[f32; 4]; 2] {
336384 }
337385 });
338386 [r, g, b, color.alphaComponent() as f32]
339 })
387 });
388 let _: () = msg_send![class, setCurrentAppearance: previous.as_deref()];
389 colors
340390 }
341391}
342392
......@@ -425,3 +475,28 @@ pub fn clear_marked_text(window: &Window) {
425475 }
426476 }
427477}
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;
2121use std::{
2222 error::Error,
2323 path::{Path, PathBuf},
24 sync::Arc,
24 sync::{Arc, mpsc},
2525 time::Instant,
2626};
2727use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
......@@ -229,6 +229,27 @@ impl Session {
229229 }
230230}
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
232253/// Work the interface asked for, done after the frame is built.
233254enum Command {
234255 OpenSection(usize),
......@@ -240,6 +261,11 @@ enum Command {
240261struct State {
241262 window: Arc<Window>,
242263 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>,
243269 /// Asks for a frame when a worker thread finishes something the page shows.
244270 redraw: std::task::Waker,
245271 instance: wgpu::Instance,
......@@ -248,7 +274,7 @@ struct State {
248274 renderer: Renderer,
249275 ui: Ui,
250276 view: PageView,
251 library: Option<Library>,
277 library: Option<Arc<Library>>,
252278 session: Option<Session>,
253279 /// Text filtering the page list.
254280 filter: String,
......@@ -265,7 +291,6 @@ struct State {
265291 moved: bool,
266292 /// Whether the page last heard it had keyboard focus.
267293 page_focused: bool,
268 window_focused: bool,
269294 /// The pointer in logical pixels, for window drags from the strip.
270295 pointer: [f32; 2],
271296 /// When the strip was last pressed, to zoom on a double click.
......@@ -399,9 +424,10 @@ impl State {
399424 .find(|(_, candidate, _)| *candidate == title)
400425 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?
401426 .0;
402 library = Some(opened);
403 let (scene, editor) =
404 open_session(section, 0, Some(space), &mut engine, &mut session)?;
427 library = Some(Arc::new(opened));
428 let opened = read_session(section, 0, Some(space))?;
429 let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?;
430 session = Some(opened);
405431 (editor, Some((scene, [0.0; 2])))
406432 }
407433 Input::Notebook { root, cache } => {
......@@ -417,8 +443,10 @@ impl State {
417443 cache,
418444 };
419445 let section = opened.open(0, proxy.clone())?;
420 library = Some(opened);
421 let (scene, editor) = open_session(section, 0, None, &mut engine, &mut session)?;
446 library = Some(Arc::new(opened));
447 let opened = read_session(section, 0, None)?;
448 let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?;
449 session = Some(opened);
422450 (editor, Some((scene, [0.0; 2])))
423451 }
424452 };
......@@ -451,6 +479,8 @@ impl State {
451479 .fonts
452480 .collection
453481 .family_names()
482 // Dot-named families are the system's private ones, hidden from font menus.
483 .filter(|name| !name.starts_with('.'))
454484 .map(String::from)
455485 .collect();
456486 fonts.sort_unstable_by_key(|name| name.to_lowercase());
......@@ -482,7 +512,6 @@ impl State {
482512 changed: false,
483513 moved: false,
484514 page_focused: true,
485 window_focused: true,
486515 pointer: [0.0; 2],
487516 strip_press: None,
488517 snapshot: None,
......@@ -492,6 +521,9 @@ impl State {
492521 occluded: false,
493522 ime_allowed: true,
494523 clipboard: arboard::Clipboard::new()?,
524 loads: mpsc::channel(),
525 loading: 0,
526 opening: None,
495527 access_adapter,
496528 accessibility: accessibility::Accessibility::default(),
497529 };
......@@ -503,6 +535,10 @@ impl State {
503535 /// frame that shows the result.
504536 fn frame(&mut self) -> Result<(), Box<dyn Error>> {
505537 let start = Instant::now();
538 if let Err(error) = self.open_loaded() {
539 eprintln!("{error}");
540 }
541 lap("open", start);
506542 let size = self.window.inner_size();
507543 let scale = self.window.scale_factor() as f32;
508544 self.ui.begin(
......@@ -541,7 +577,7 @@ impl State {
541577 self.draw()?;
542578 lap("drawn", start);
543579 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();
545581 for command in commands {
546582 self.apply(command)?;
547583 }
......@@ -582,6 +618,11 @@ impl State {
582618 accent: ease("accent", target.accent),
583619 };
584620 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);
585626 let theme = self.ui.theme.clone();
586627 self.title_bar(&theme);
587628 self.toolbar(&theme)?;
......@@ -685,7 +726,7 @@ impl State {
685726 }
686727 self.ui.close();
687728 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());
689730 if focused != self.page_focused {
690731 self.page_focused = focused;
691732 let response = self.view.focus_changed(focused)?;
......@@ -891,27 +932,38 @@ impl State {
891932 group(ui, "text", |ui| {
892933 row(ui, 0, |ui| {
893934 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 {
895936 ui.open_popup(popup("font"));
896937 }
897938 let items: Vec<_> = fonts
898939 .iter()
899940 .map(|name| ui::popup::Item {
900941 text: name,
942 font: Some(name),
901943 checked: *name == font,
902944 ..Default::default()
903945 })
904946 .collect();
905947 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"))
907949 {
908950 command = Some(Formatting::Font(fonts[index].clone()));
909951 }
910952 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 {
912954 ui.open_popup(popup("size"));
913955 }
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();
915967 let items: Vec<_> = labels
916968 .iter()
917969 .map(|label| ui::popup::Item {
......@@ -923,7 +975,7 @@ impl State {
923975 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
924976 if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size))
925977 {
926 command = Some(Formatting::FontSize(SIZES[index]));
978 command = Some(Formatting::FontSize(sizes[index]));
927979 }
928980 let bullets =
929981 ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets);
......@@ -970,7 +1022,7 @@ impl State {
9701022 Toggle::Subscript
9711023 }));
9721024 }
973 if menu.clicked {
1025 if menu.pressed {
9741026 ui.open_popup(popup("script"));
9751027 }
9761028 let items = [
......@@ -1026,7 +1078,7 @@ impl State {
10261078 if button.clicked {
10271079 command = Some(paint(Some(default)));
10281080 }
1029 if menu.clicked {
1081 if menu.pressed {
10301082 ui.open_popup(popup(part));
10311083 }
10321084 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();
......@@ -1060,7 +1112,7 @@ impl State {
10601112 .find(|(alignment, ..)| Some(*alignment) == state.alignment)
10611113 .unwrap_or(&alignments[0]);
10621114 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 {
10641116 ui.open_popup(popup("align"));
10651117 }
10661118 let items = alignments.map(|(alignment, name, icon)| ui::popup::Item {
......@@ -1350,6 +1402,8 @@ impl State {
13501402 hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)),
13511403 radius: ROUNDING,
13521404 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],
13531407 ..spec
13541408 }
13551409 };
......@@ -1425,30 +1479,30 @@ impl State {
14251479 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
14261480 match command {
14271481 Command::OpenSection(tab) => {
1428 let library = self.library.as_ref().ok_or("No sections to open")?;
1429 let section = library.open(tab, self.proxy.clone())?;
1430 let (scene, editor) =
1431 open_session(section, tab, None, &mut self.view.engine, &mut self.session)?;
1432 self.view.open(editor, Some((scene, [0.0; 2])));
1433 self.filter.clear();
1434 self.opened()?;
1482 let library = Arc::clone(self.library.as_ref().ok_or("No sections to open")?);
1483 let proxy = self.proxy.clone();
1484 self.load(move || {
1485 let section = library.open(tab, proxy)?;
1486 Ok(Loaded::Section(read_session(section, tab, None)?))
1487 });
14351488 }
14361489 Command::OpenPage(space) => {
1437 let session = self.session.as_mut().ok_or("No section is open")?;
1438 let page = session.section.page(space)?;
1439 let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?;
1440 session.space = space;
1441 session.before = page;
1442 session.refresh_conflict()?;
1443 self.view.open(editor, Some((scene, [0.0; 2])));
1444 self.opened()?;
1490 let session = self.session.as_ref().ok_or("No section is open")?;
1491 let replica = Arc::clone(session.section.replica());
1492 self.load(move || {
1493 Ok(Loaded::Page {
1494 space,
1495 page: replica.page(space)?,
1496 })
1497 });
14451498 }
14461499 Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?,
14471500 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
14481501 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
14491502 Command::Page(Request::Paste) => {
14501503 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)?;
14521506 self.respond(response);
14531507 }
14541508 Command::Page(Request::CharacterPalette) => macos::show_character_palette(),
......@@ -1456,6 +1510,68 @@ impl State {
14561510 Ok(())
14571511 }
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
14591575 /// Follows a page shown in place of another.
14601576 fn opened(&mut self) -> Result<(), Box<dyn Error>> {
14611577 self.ui.set_focus(Some(page()));
......@@ -1465,14 +1581,15 @@ impl State {
14651581 Ok(())
14661582 }
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.
14691586 fn after_edit(&mut self) -> Result<(), Box<dyn Error>> {
1470 self.after_move()?;
14711587 let start = Instant::now();
1472 self.persist()?;
1588 let saved = self.persist();
14731589 lap("save", start);
14741590 self.title();
1475 Ok(())
1591 self.after_move()?;
1592 saved
14761593 }
14771594
14781595 /// Follows the view moving: the input method's position and accessibility.
......@@ -1625,6 +1742,7 @@ impl State {
16251742 let shown = (session.status, session.conflict);
16261743 let mut refreshed = false;
16271744 let mut stale = false;
1745 let mut conflicted = session.conflict;
16281746 for (space, outcome) in session.section.saved() {
16291747 match outcome {
16301748 Ok(notebook::session::Save::Queued(_)) => session.status = "Saving",
......@@ -1650,7 +1768,10 @@ impl State {
16501768 Event::Attempt {
16511769 status: notebook::EditStatus::Conflict(_),
16521770 ..
1653 } => "Conflict",
1771 } => {
1772 conflicted = true;
1773 "Conflict"
1774 }
16541775 Event::Attempt { .. } => "Saving",
16551776 Event::Unreachable(_) => "Offline",
16561777 Event::Failed(error) => {
......@@ -1659,7 +1780,11 @@ impl State {
16591780 }
16601781 };
16611782 }
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 }
16631788 if refreshed || shown != (session.status, session.conflict) {
16641789 self.window.request_redraw();
16651790 }
......@@ -1672,7 +1797,9 @@ impl State {
16721797 {
16731798 let first = session.pages.first().ok_or("The section has no pages")?.0;
16741799 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 {
16761803 self.reload()?;
16771804 }
16781805 } else if stale {
......@@ -1817,6 +1944,7 @@ impl State {
18171944 self.window.pre_present_notify();
18181945 let start = Instant::now();
18191946 self.renderer.queue.present(frame);
1947 macos::commit_presentation(&self.window);
18201948 lap("present", start);
18211949 trace_input(&"Present submitted");
18221950 if reconfigure {
......@@ -1828,15 +1956,19 @@ impl State {
18281956 /// Paints the interface with the page in its box.
18291957 fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> {
18301958 let start = Instant::now();
1831 let [caret, selection] = macos::text_colors();
18321959 let paper = canvas::gpu::Paper {
18331960 color: self.ui.theme.paper,
18341961 ink: self.ui.theme.paper_ink,
18351962 };
1836 self.view.update_backgrounds(paper, &self.redraw);
1963 self.view.update_pictures(paper, &self.redraw);
1964 let theme = &self.ui.theme;
18371965 let page_primitives = self.view.primitives(TextColors {
1838 caret,
1839 selection,
1966 caret: theme.caret,
1967 selection: if self.page_focused {
1968 theme.selection
1969 } else {
1970 theme.inactive_selection
1971 },
18401972 paper,
18411973 })?;
18421974 lap("page primitives", start);
......@@ -1851,6 +1983,7 @@ impl State {
18511983 scale,
18521984 origin: [0.0; 2],
18531985 clip: clip.map(|clip| clip.map(|value| value * scale)),
1986 backdrop: None,
18541987 primitives,
18551988 },
18561989 ui::Layer::Custom { rect, .. } => draw::Layer {
......@@ -1860,6 +1993,7 @@ impl State {
18601993 viewport.origin[1] + corner[1] * scale,
18611994 ],
18621995 clip: Some(rect.map(|value| value * scale)),
1996 backdrop: Some(self.ui.theme.paper),
18631997 primitives: &page_primitives,
18641998 },
18651999 })
......@@ -1969,7 +2103,7 @@ impl State {
19692103 });
19702104 self.strip_press = (!double).then_some(at);
19712105 if double {
1972 self.window.set_maximized(!self.window.is_maximized());
2106 macos::zoom(&self.window);
19732107 } else {
19742108 let _ = self.window.drag_window();
19752109 }
......@@ -2072,23 +2206,19 @@ fn tabs(notebook: &notebook::session::Notebook) -> Vec<Tab> {
20722206 .collect()
20732207}
20742208
2075/// Makes `section` the open one showing `space`, or its first page, and returns the
2076/// page's scene and editor.
2077fn open_session(
2209/// The session for `section` showing `space`, or its first page.
2210fn read_session(
20782211 section: notebook::session::Section,
20792212 tab: usize,
20802213 space: Option<ExGuid>,
2081 engine: &mut TextEngine,
2082 session: &mut Option<Session>,
2083) -> Result<(PageScene, CanvasEditor), Box<dyn Error>> {
2214) -> Result<Session, Box<dyn Error>> {
20842215 let pages = section.pages()?;
20852216 let space = match space {
20862217 Some(space) => space,
20872218 None => pages.first().ok_or("The section has no pages")?.0,
20882219 };
20892220 let before = section.page(space)?;
2090 let (scene, editor) = PageScene::from_page(before.clone(), engine)?;
2091 let mut opened = Session {
2221 let mut session = Session {
20922222 section,
20932223 tab,
20942224 pages,
......@@ -2097,9 +2227,8 @@ fn open_session(
20972227 status: "",
20982228 conflict: false,
20992229 };
2100 opened.refresh_conflict()?;
2101 *session = Some(opened);
2102 Ok((scene, editor))
2230 session.refresh_conflict()?;
2231 Ok(session)
21032232}
21042233
21052234impl App {
......@@ -2170,7 +2299,10 @@ impl ApplicationHandler<UserEvent> for App {
21702299 Replay::Input(event) => state.input(event),
21712300 Replay::Snapshot(path) => state.snapshot = Some(path),
21722301 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 }
21742306 }
21752307 // A covered window gets no redraws, so each step draws its own frame.
21762308 if let Err(error) = state.frame() {
......@@ -2276,7 +2408,7 @@ impl ApplicationHandler<UserEvent> for App {
22762408 state.window.request_redraw();
22772409 }
22782410 WindowEvent::Focused(focused) => {
2279 state.window_focused = focused;
2411 state.ui.window_focused = focused;
22802412 state.window.request_redraw();
22812413 }
22822414 WindowEvent::Occluded(occluded) => {
......@@ -2334,14 +2466,21 @@ impl ApplicationHandler<UserEvent> for App {
23342466 let Some(state) = &mut self.state else {
23352467 return;
23362468 };
2337 let (repaint, next) = if state.occluded {
2338 (false, None)
2339 } else {
2340 state.view.blink(Instant::now())
2341 };
2342 if repaint {
2469 if state.occluded {
2470 event_loop.set_control_flow(ControlFlow::Wait);
2471 return;
2472 }
2473 let now = Instant::now();
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) {
23432478 state.window.request_redraw();
23442479 }
2480 let next = [blink, wake.filter(|wake| *wake > now)]
2481 .into_iter()
2482 .flatten()
2483 .min();
23452484 event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil));
23462485 }
23472486}
crates/ui/examples/popups.rs+6-5
......@@ -601,14 +601,14 @@ fn palette(scene: &mut Scene) {
601601fn tags(scene: &mut Scene) {
602602 let tags = [
603603 ("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"),
605605 ("Question", None, "⌘3"),
606606 ("Remember for later", Some(art!("tags/remember")), "⌘4"),
607607 ("Definition", Some(art!("tags/definition")), "⌘5"),
608 ("Highlight", Some(art!("tags/highlight")), "⌘6"),
609 ("Contact", Some(art!("tags/contact")), "⌘7"),
610 ("Address", Some(art!("tags/address")), "⌘8"),
611 ("Phone number", Some(art!("tags/phone")), "⌘9"),
608 ("Highlight", Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]), "⌘6"),
609 ("Contact", Some(&[include_str!("../../canvas/assets/tags/contact.svg")]), "⌘7"),
610 ("Address", Some(&[include_str!("../../canvas/assets/tags/address.svg")]), "⌘8"),
611 ("Phone number", Some(&[include_str!("../../canvas/assets/tags/phone.svg")]), "⌘9"),
612612 ("Web site to visit", None, ""),
613613 ("Idea", None, ""),
614614 ("Password", None, ""),
......@@ -690,6 +690,7 @@ fn paint(
690690 scale: SCALE,
691691 origin: [0.0; 2],
692692 clip: clip.map(|clip| clip.map(|value| value * SCALE)),
693 backdrop: None,
693694 primitives,
694695 }),
695696 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
7979 let mut cursor = nodes[index].pad[axis];
8080 for child in nodes[index].children.clone() {
8181 nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor {
82 let slide = (1.0 - nodes[child].alpha) * crate::SLIDE;
83 anchor.place(axis, nodes[child].computed[axis], window, slide)
82 anchor.place(axis, nodes[child].computed[axis], window)
8483 } else if nodes[child].flags.contains(Flags::FLOAT) {
8584 nodes[child].position[axis]
8685 } else if along(&nodes[index], axis) {
crates/ui/src/lib.rs+58-30
......@@ -40,8 +40,6 @@ const HALF_LIFE: f32 = 0.03;
4040/// falls; a popup's, floating higher, spreads and falls further.
4141const SHADOW: [f32; 2] = [3.0, 0.0];
4242const 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;
4543/// Logical size of a box's icon, and its distance from the label.
4644const ICON: f32 = 16.0;
4745const ICON_GAP: f32 = 6.0;
......@@ -182,9 +180,8 @@ pub enum Anchor {
182180
183181impl Anchor {
184182 /// Where a popup `size` long on `axis` starts in a window `room` long: past the
185 /// anchor on the axis it opens along, level with it otherwise, `slide` nearer to it
186 /// along the way it opens.
187 fn place(self, axis: usize, size: f32, room: f32, slide: f32) -> f32 {
183 /// anchor on the axis it opens along, level with it otherwise.
184 fn place(self, axis: usize, size: f32, room: f32) -> f32 {
188185 let (rect, along) = match self {
189186 Anchor::Below(rect) => (rect, Some(1)),
190187 Anchor::Right(rect) => (rect, Some(0)),
......@@ -192,15 +189,15 @@ impl Anchor {
192189 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
193190 };
194191 let [low, high] = [rect[axis], rect[axis + 2]];
195 let (first, second, slide) = if along == Some(axis) {
196 (high, low - size, slide)
192 let (first, second) = if along == Some(axis) {
193 (high, low - size)
197194 } else {
198 (low, high - size, 0.0)
195 (low, high - size)
199196 };
200197 if first + size <= room {
201 first - slide
198 first
202199 } else if second >= 0.0 {
203 second + slide
200 second
204201 } else {
205202 first.min(room - size).max(0.0)
206203 }
......@@ -215,6 +212,8 @@ pub struct Spec<'a> {
215212 /// The axis its children flow along.
216213 pub axis: Axis,
217214 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>,
218217 /// The label's colour; the theme's text colour otherwise.
219218 pub color: Option<[f32; 4]>,
220219 pub fill: Option<[f32; 4]>,
......@@ -334,7 +333,7 @@ struct Built {
334333 alpha: f32,
335334 fade_into: Option<[f32; 4]>,
336335 /// Rectangles relative to the box, painted over its fill.
337 marks: Vec<([f32; 4], [f32; 4])>,
336 marks: Vec<([f32; 4], [f32; 4], f32)>,
338337 computed: [f32; 2],
339338 relative: [f32; 2],
340339 rect: [f32; 4],
......@@ -453,6 +452,9 @@ impl Display {
453452
454453pub struct Ui {
455454 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,
456458 frame: u64,
457459 now: Instant,
458460 scale: f32,
......@@ -479,7 +481,13 @@ pub struct Ui {
479481 modifiers: ModifiersState,
480482 clicks: Clicks,
481483 display: Vec<Display>,
484 /// Where the popups' painting starts in `display`, for edges drawn beneath them.
485 popups_painted: usize,
482486 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>,
483491}
484492
485493impl Ui {
......@@ -487,6 +495,7 @@ impl Ui {
487495 pub fn new(theme: Theme, double_click: Duration) -> Self {
488496 Self {
489497 theme,
498 window_focused: true,
490499 frame: 0,
491500 now: Instant::now(),
492501 scale: 1.0,
......@@ -509,7 +518,10 @@ impl Ui {
509518 modifiers: ModifiersState::empty(),
510519 clicks: Clicks::new(double_click),
511520 display: Vec::new(),
521 popups_painted: 0,
512522 animating: false,
523 caret: None,
524 wake: None,
513525 }
514526 }
515527
......@@ -523,6 +535,11 @@ impl Ui {
523535 self.animating || !self.queue.is_empty()
524536 }
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
526543 pub fn scale(&self) -> f32 {
527544 self.scale
528545 }
......@@ -591,6 +608,7 @@ impl Ui {
591608 self.stack.push(0);
592609 self.signals.clear();
593610 self.moved = false;
611 self.wake = None;
594612 for event in std::mem::take(&mut self.queue) {
595613 self.route(event);
596614 }
......@@ -766,9 +784,7 @@ impl Ui {
766784 highlight: None,
767785 });
768786 self.focus = Some(id);
769 let state = self.states.entry(id).or_default();
770 state.touched = self.frame;
771 state.tween = Some([0.0, 1.0]);
787 self.states.entry(id).or_default().touched = self.frame;
772788 }
773789
774790 pub fn popup_open(&self, id: Id) -> bool {
......@@ -839,7 +855,7 @@ impl Ui {
839855 pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id {
840856 let label = spec
841857 .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));
843859 // Popups hang from the root, outside the clips and flow of where they are built.
844860 let parent = if spec.anchor.is_some() {
845861 0
......@@ -847,15 +863,9 @@ impl Ui {
847863 *self.stack.last().unwrap()
848864 };
849865 let index = self.nodes.len();
850 let state = self.states.entry(id).or_default();
851 state.touched = self.frame;
852 let reveal = match spec.anchor {
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);
866 self.states.entry(id).or_default().touched = self.frame;
867 self.nodes
868 .push(Built::new(id, parent, spec, label, self.theme.text));
859869 self.nodes[parent].children.push(index);
860870 self.stack.push(index);
861871 id
......@@ -892,14 +902,31 @@ impl Ui {
892902 /// The size of `text` as a label, in logical pixels.
893903 pub fn measure(&mut self, text: &str) -> [f32; 2] {
894904 self.texts
895 .label(text, self.theme.font_size, self.frame)
905 .label(text, self.theme.font_size, None, self.frame)
896906 .size
897907 }
898908
899 /// Paints `color` over the current box at `rect`, relative to its corner.
900 pub fn mark(&mut self, rect: [f32; 4], color: [f32; 4]) {
909 /// Paints `color` over the current box at `rect`, relative to its corner, with corners
910 /// of `radius`.
911 pub fn mark(&mut self, rect: [f32; 4], color: [f32; 4], radius: f32) {
901912 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
903930 }
904931
905932 /// How the user acted on the box this frame; a box's routed events are taken once.
......@@ -940,6 +967,7 @@ impl Ui {
940967 self.display.clear();
941968 self.hits.clear();
942969 self.paint(0, None);
970 self.popups_painted = self.display.len();
943971 let beneath = self.hits.len();
944972 for index in self.nodes[0].children.clone() {
945973 if self.nodes[index].anchor.is_some() {
......@@ -1025,7 +1053,7 @@ impl Ui {
10251053 });
10261054 }
10271055 }
1028 for (mark, color) in &node.marks {
1056 for (mark, color, radius) in &node.marks {
10291057 self.display.push(Display::Rect {
10301058 rect: [
10311059 rect[0] + mark[0],
......@@ -1036,7 +1064,7 @@ impl Ui {
10361064 fill: *color,
10371065 shade: None,
10381066 border: None,
1039 radius: 0.0,
1067 radius: *radius,
10401068 });
10411069 }
10421070 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
77use std::collections::{HashMap, HashSet};
88use winit::keyboard::NamedKey;
99
10/// How far rows fade out under a scrolled list's edges.
11const EDGE: f64 = 10.0;
1012/// Room beside a scrolling list's rows for its scrollbar.
1113pub(crate) const GUTTER: f32 = 12.0;
1214/// Seconds rows take to reach their new places.
13const DURATION: f32 = 0.15;
15const DURATION: f32 = 0.25;
1416
1517/// Items a list shows, in order.
1618pub trait Rows {
......@@ -502,6 +504,27 @@ pub fn list<R: Rows>(
502504 );
503505 ui.close();
504506 }
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 }
505528 let thumb = mix(theme.text_dim, theme.chip, 0.5);
506529 if let Some(offset) = scrollbar(
507530 ui,
......@@ -512,6 +535,12 @@ pub fn list<R: Rows>(
512535 view as f32,
513536 thumb,
514537 ) {
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 }
515544 state.scroll = f64::from(offset);
516545 state.target = state.scroll;
517546 }
crates/ui/src/popup.rs+11
......@@ -35,6 +35,8 @@ const KEYS: [NamedKey; 7] = [
3535#[derive(Clone, Copy, Debug, Default)]
3636pub struct Item<'a> {
3737 pub text: &'a str,
38 /// The family the text previews, as in a font menu.
39 pub font: Option<&'a str>,
3840 pub icon: Option<&'static [&'static str]>,
3941 /// The icon has colours of its own, so the text's colour does not tint it.
4042 pub colored: bool,
......@@ -280,6 +282,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
280282 Spec {
281283 size: [fill(), px(ROW)],
282284 text: Some(item.text),
285 font: item.font,
283286 color: Some(color),
284287 ..Spec::default()
285288 },
......@@ -444,6 +447,14 @@ fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
444447 ui.open_as(id, spec);
445448}
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
447458fn state(ui: &mut Ui, id: Id) -> &mut Popup {
448459 ui.popups
449460 .iter_mut()
crates/ui/src/shell.rs+10-4
......@@ -161,7 +161,7 @@ pub fn split_button(
161161 if let Some(color) = bar {
162162 // The icon leaves its lowest three units for the bar.
163163 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);
165165 }
166166 ui.close();
167167 let menu = ui.leaf(
......@@ -220,6 +220,12 @@ pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: f32) -> Signal {
220220}
221221
222222impl 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
223229 /// Draws a 1 px border along `points`, each with the radius its corner rounds by,
224230 /// joining the last back to the first when `closed`. Called between `end` and
225231 /// `layers`, it paints over the frame's boxes where they were just laid out.
......@@ -239,14 +245,14 @@ impl Ui {
239245 let to = corners[next]
240246 .as_ref()
241247 .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 });
243249 }
244250 for corner in corners.into_iter().flatten() {
245251 let start = [
246252 corner.start[0] - corner.point[0],
247253 corner.start[1] - corner.point[1],
248254 ];
249 self.display.push(Display::Path {
255 self.beneath_popups(Display::Path {
250256 data: format!("M{} {}{}", start[0], start[1], corner.curve(corner.point)),
251257 origin: corner.point,
252258 style: PathStyle::Stroke(1.0),
......@@ -273,7 +279,7 @@ impl Ui {
273279 corner.start[0] - corner.point[0],
274280 corner.start[1] - corner.point[1],
275281 ];
276 self.display.push(Display::Path {
282 self.beneath_popups(Display::Path {
277283 data: format!(
278284 "M{} {}{}L0 0Z",
279285 start[0],
crates/ui/src/tests.rs+101-17
......@@ -341,7 +341,7 @@ fn focused_field(text: &str) -> (Ui, String) {
341341fn point_to(ui: &mut Ui, text: &str, index: usize) {
342342 let size = ui.theme.font_size;
343343 let (texts, frame) = ui.texts();
344 let layout = &texts.label(text, size, frame).layout;
344 let layout = &texts.label(text, size, None, frame).layout;
345345 let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream)
346346 .geometry(layout, 1.0)
347347 .x0 as f32;
......@@ -382,6 +382,53 @@ fn text_fields_edit_with_keys_and_selection() {
382382 assert_eq!(text, "");
383383}
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
385432#[test]
386433fn text_fields_move_and_select_by_word() {
387434 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() {
621668}
622669
623670#[test]
624fn popups_fade_in_except_at_a_point() {
671fn popups_show_at_once_by_their_anchor() {
625672 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
626673 menu_frame(&mut ui, BELOW, None);
627674 ui.open_popup(menu_id());
628675 menu_frame(&mut ui, BELOW, None);
629676 menu_frame(&mut ui, BELOW, None);
630 let reveal = |ui: &Ui| {
677 let alpha = |ui: &Ui| {
631678 ui.nodes
632679 .iter()
633680 .find(|node| node.id == menu_id())
634681 .unwrap()
635682 .alpha
636683 };
637 assert!(reveal(&ui) > 0.0 && reveal(&ui) < 1.0);
638 assert!(
639 ui.rect(menu_id()).unwrap()[1] < 44.0,
640 "and slide from the anchor"
684 assert_eq!(alpha(&ui), 1.0);
685 assert_eq!(
686 ui.rect(menu_id()).unwrap()[1],
687 44.0,
688 "right under the anchor"
641689 );
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");
647690 ui.close_popup(menu_id());
648691 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
649692 ui.open_popup(menu_id());
650693 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
651694 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);
653696 assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]);
654697}
655698
......@@ -1053,16 +1096,17 @@ fn rows_slide_while_the_rest_fold_away_and_unfold_at_once() {
10531096 assert!(alpha > 0.0 && alpha < 1.0, "{key} folds where it was");
10541097 }
10551098 assert!(row_top(&ui, 5).unwrap() < 0.0, "out of the seam at the top");
1056 list_frame(&mut ui, &rows, &mut selected, &[]);
1057 list_frame(&mut ui, &rows, &mut selected, &[]);
1099 for _ in 0..4 {
1100 list_frame(&mut ui, &rows, &mut selected, &[]);
1101 }
10581102 for key in [5, 100] {
10591103 let alpha = alpha(&ui, key).unwrap();
10601104 assert!(alpha > 0.0 && alpha < 1.0, "{key} unfolds where it goes");
10611105 }
1062 for _ in 0..7 {
1106 for _ in 0..11 {
10631107 list_frame(&mut ui, &rows, &mut selected, &[]);
10641108 }
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");
10661110 assert_eq!(row_top(&ui, 0), Some(LIST_ROW));
10671111 assert_eq!(row_top(&ui, 100), Some(5.0 * LIST_ROW));
10681112 assert_eq!(alpha(&ui, 100), Some(1.0));
......@@ -1162,3 +1206,43 @@ fn keys_move_the_selection_and_the_view_eases_after_it() {
11621206 settle_list(&mut ui, &rows, &mut selected);
11631207 assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0)));
11641208}
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 {
1111 pub size: [f32; 2],
1212}
1313
14type LabelKey = (String, u32, Option<String>);
15
1416/// Labels shaped this frame or the previous one, keyed by their text and size.
1517#[derive(Default)]
1618pub(crate) struct Texts {
......@@ -18,13 +20,14 @@ pub(crate) struct Texts {
1820 context: LayoutContext<()>,
1921 /// A family registered in place of the system's interface font.
2022 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)>,
2225}
2326
2427impl Texts {
25 /// `size` is in logical pixels.
26 pub fn label(&mut self, text: &str, size: f32, frame: u64) -> Rc<Label> {
27 let key = (text.to_owned(), size.to_bits());
28 /// `size` is in logical pixels; `font` names a family to prefer over the interface's.
29 pub fn label(&mut self, text: &str, size: f32, font: Option<&str>, frame: u64) -> Rc<Label> {
30 let key = (text.to_owned(), size.to_bits(), font.map(str::to_owned));
2831 if let Some((label, touched)) = self.cache.get_mut(&key) {
2932 *touched = frame;
3033 return label.clone();
......@@ -33,12 +36,15 @@ impl Texts {
3336 .context
3437 .ranged_builder(&mut self.fonts, text, 1.0, false);
3538 let system = FontFamilyName::Generic(GenericFamily::SystemUi);
36 builder.push_default(StyleProperty::FontFamily(match &self.family {
37 Some(family) => {
38 FontFamily::List(Cow::Owned(vec![FontFamilyName::named(family), system]))
39 }
40 None => FontFamily::Single(system),
41 }));
39 let families: Vec<_> = font
40 .into_iter()
41 .chain(self.family.as_deref())
42 .map(FontFamilyName::named)
43 .chain([system])
44 .collect();
45 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(
46 families,
47 ))));
4248 builder.push_default(StyleProperty::FontSize(size));
4349 let mut layout = builder.build(text);
4450 layout.break_all_lines(None);
crates/ui/src/theme.rs+12-1
......@@ -11,6 +11,11 @@ pub struct Theme {
1111 /// The window's title bar, toolbar and tab row.
1212 pub strip: [f32; 4],
1313 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],
1419 pub text: [f32; 4],
1520 pub text_dim: [f32; 4],
1621 /// Text on section colours.
......@@ -59,6 +64,9 @@ impl Theme {
5964 panel: srgb(0x1f, 0x22, 0x23),
6065 strip: srgb(0x27, 0x2a, 0x2b),
6166 accent: srgb(0x00, 0x79, 0xa6),
67 caret: srgb(0x00, 0x7a, 0xff),
68 selection: srgb(0x3f, 0x63, 0x8b),
69 inactive_selection: srgb(0x46, 0x46, 0x46),
6270 text: srgb(0xdd, 0xde, 0xe0),
6371 text_dim: srgb(0x6b, 0x70, 0x78),
6472 ink: srgb(0xe8, 0xe9, 0xeb),
......@@ -83,6 +91,9 @@ impl Theme {
8391 panel: srgb(0xf4, 0xf5, 0xf7),
8492 strip: srgb(0xeb, 0xed, 0xf0),
8593 accent: srgb(0x00, 0x79, 0xa6),
94 caret: srgb(0x00, 0x7a, 0xff),
95 selection: srgb(0xb3, 0xd7, 0xff),
96 inactive_selection: srgb(0xdc, 0xdc, 0xdc),
8697 text: srgb(0x1f, 0x23, 0x28),
8798 text_dim: srgb(0x7b, 0x82, 0x8c),
8899 ink: srgb(0x1d, 0x1e, 0x20),
......@@ -100,7 +111,7 @@ impl Theme {
100111 }
101112 }
102113
103 /// Hovered controls and selected text: the accent over the base.
114 /// Hovered controls: the accent over the base.
104115 pub fn hover(&self) -> [f32; 4] {
105116 crate::mix(self.base, self.accent, 0.35)
106117 }
crates/ui/src/widgets.rs+22-5
......@@ -110,8 +110,9 @@ pub fn text_field(
110110 let (selection, press) = ui.field(id);
111111 (*selection, *press)
112112 };
113 let before = [selection.anchor(), selection.focus()];
113114 let (texts, frame) = ui.texts();
114 let mut label = texts.label(text, size, frame);
115 let mut label = texts.label(text, size, None, frame);
115116 if let Some(x) = pointer {
116117 let layout = &label.layout;
117118 let unit = if signal.pressed { signal.unit } else { press.1 };
......@@ -197,13 +198,14 @@ pub fn text_field(
197198 _ => continue,
198199 };
199200 text.replace_range(range.clone(), inserted);
200 label = texts.label(text, size, frame);
201 label = texts.label(text, size, None, frame);
201202 selection = Selection::from_byte_index(
202203 &label.layout,
203204 range.start + inserted.len(),
204205 Affinity::Downstream,
205206 );
206207 }
208 let moved = signal.pressed || before != [selection.anchor(), selection.focus()];
207209 {
208210 let (stored, stored_press) = ui.field(id);
209211 *stored = selection;
......@@ -216,6 +218,7 @@ pub fn text_field(
216218 text.as_str()
217219 };
218220 let height = spec.size[1];
221 let backdrop = spec.fill.unwrap_or(theme.base);
219222 ui.open_as(
220223 id,
221224 Spec {
......@@ -236,14 +239,28 @@ pub fn text_field(
236239 crate::Size::Pixels(height) => (height - line) / 2.0,
237240 _ => 0.0,
238241 };
242 let fill = if ui.window_focused {
243 theme.selection
244 } else {
245 theme.inactive_selection
246 };
239247 for (rect, _) in selection.geometry(&label.layout) {
240248 ui.mark(
241249 [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,
243262 );
244263 }
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);
247264 }
248265 ui.close();
249266 signal