authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:22-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log46b2c3bf9ebd9f51be378aaf1835936a82f4ddb9
tree0babd16604271ed28e63defcec7335a0e1b46448
parentb305654ab5ef9281fe63f30857a5ae23fa611558
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat(ios): native selection, loupe, moving boxes, navigation and dark mode

UIKit text interaction and the loupe over canvas geometry, box drag and resize on the margin grid, Done/undo, zoom defaults and double-tap zoom, a split-view section and page navigator, light/dark, and native undo. Edits are still drained until the iOS app gets a notebook session. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

9 files changed, 1356 insertions(+), 387 deletions(-)

apps/ios/Snowbound.xcodeproj/project.pbxproj+4-4
......@@ -7,12 +7,12 @@
77 objects = {
88
99/* Begin PBXBuildFile section */
10 5B0000000000000000000101 /* Features.one in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Features.one */; };
10 5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; };
1111/* End PBXBuildFile section */
1212
1313/* Begin PBXFileReference section */
1414 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; };
15 5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; };
1616 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1717 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; };
1818 5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; };
......@@ -41,7 +41,7 @@
4141 isa = PBXGroup;
4242 children = (
4343 5B0000000000000000000300 /* Snowbound */,
44 5B0000000000000000000201 /* Features.one */,
44 5B0000000000000000000201 /* Sample */,
4545 5B0000000000000000000202 /* Info.plist */,
4646 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */,
4747 5B0000000000000000000204 /* build-rust.sh */,
......@@ -120,7 +120,7 @@
120120 isa = PBXResourcesBuildPhase;
121121 buildActionMask = 2147483647;
122122 files = (
123 5B0000000000000000000101 /* Features.one in Resources */,
123 5B0000000000000000000101 /* Sample in Resources */,
124124 );
125125 runOnlyForDeploymentPostprocessing = 0;
126126 };
apps/ios/Snowbound/App.swift+254-42
......@@ -14,117 +14,329 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
1414 }
1515}
1616
17final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
17/// Notebook, section and page columns as Notes lays out folders, notes and a note: side by
18/// side on a wide screen, a navigation stack on a phone.
19final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate {
1820 var window: UIWindow?
21 private let split = UISplitViewController(style: .tripleColumn)
22 private let sections = SectionsViewController()
23 private let pages = PagesViewController()
24 private var showingPage = false
1925
2026 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
2127 guard let scene = scene as? UIWindowScene else { return }
2228 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)
29 split.delegate = self
30 split.preferredDisplayMode = .oneBesideSecondary
31 split.preferredSplitBehavior = .tile
32 split.setViewController(sections, for: .primary)
33 split.setViewController(pages, for: .supplementary)
34 split.setViewController(UIViewController(), for: .secondary)
35 sections.onOpen = { [weak self] section in self?.show(section: section) }
36 pages.onOpen = { [weak self] section, page in self?.show(section: section, page: page) }
37 window.rootViewController = split
2738 window.makeKeyAndVisible()
2839 self.window = window
29 if let page = ProcessInfo.processInfo.environment["SNOWBOUND_PAGE"].flatMap(Int.init) {
30 pages.show(page: page, animated: false)
40 sections.load(Library.opening())
41 let environment = ProcessInfo.processInfo.environment
42 // The split view collapses into a stack once the window is on screen.
43 DispatchQueue.main.async { [self] in
44 if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init), let notebook = sections.notebook,
45 index < notebook.sections.count
46 {
47 show(section: notebook.sections[index])
48 if let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), let section = pages.section,
49 page < section.headings.count
50 {
51 show(section: section, page: page)
52 }
53 }
3154 }
3255 }
56
57 private func show(section tab: Tab) {
58 pages.load(tab)
59 split.show(.supplementary)
60 }
61
62 private func show(section: Section, page: Int) {
63 showingPage = true
64 let controller = PageViewController(section: section, page: page)
65 split.setViewController(UINavigationController(rootViewController: controller), for: .secondary)
66 split.show(.secondary)
67 }
68
69 /// A phone opens on the notebook's sections, as Notes opens on its folders.
70 func splitViewController(
71 _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
72 ) -> UISplitViewController.Column {
73 showingPage ? proposedTopColumn : .primary
74 }
75}
76
77/// A notebook's listing from `sb_notebook`.
78struct Notebook: Decodable {
79 let name: String
80 let sections: [Tab]
81}
82
83struct Tab: Decodable {
84 let name: String
85 let path: String
86 /// The section group holding it, `/`-separated; empty at the notebook's top.
87 let group: String
88 let color: [UInt8]
89 let readable: Bool
90
91 var uiColor: UIColor {
92 UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1)
93 }
94}
95
96/// Where the notebook shown comes from: the one last opened from Files, kept as a bookmark,
97/// or the bundled sample.
98enum Library {
99 private static let bookmarkKey = "notebook"
100 /// The Files location being read, held open while it is shown.
101 private static var accessed: URL?
102
103 static func opening() -> Notebook? {
104 if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] { return read(path) }
105 var stale = false
106 if let data = UserDefaults.standard.data(forKey: bookmarkKey),
107 let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale),
108 let notebook = open(url)
109 {
110 return notebook
111 }
112 // The bundled folder keeps its corpus name.
113 return Bundle.main.path(forResource: "notebook", ofType: nil).flatMap(read).map {
114 Notebook(name: "Sample", sections: $0.sections)
115 }
116 }
117
118 /// A notebook folder or section file chosen in Files, remembered for the next launch.
119 static func open(_ url: URL) -> Notebook? {
120 accessed?.stopAccessingSecurityScopedResource()
121 accessed = url.startAccessingSecurityScopedResource() ? url : nil
122 guard let notebook = read(url.path) else { return nil }
123 if let data = try? url.bookmarkData() { UserDefaults.standard.set(data, forKey: bookmarkKey) }
124 return notebook
125 }
126
127 private static func read(_ path: String) -> Notebook? {
128 guard let json = sb_notebook(path) else { return nil }
129 defer { sb_string_free(json) }
130 return try? JSONDecoder().decode(Notebook.self, from: Data(String(cString: json).utf8))
131 }
33132}
34133
35134/// A parsed .one section; pages are read-only copies until saving lands.
36135final class Section {
37136 let handle: OpaquePointer?
38137 let name: String
138 let color: UIColor
139 /// Each page's title and level in the page list, 1 at the top.
140 let headings: [(title: String, level: Int)]
39141
40 init(path: String) {
41 handle = sb_section_open(path)
42 name = (path as NSString).lastPathComponent.replacingOccurrences(of: ".one", with: "")
142 init(_ tab: Tab) {
143 handle = sb_section_open(tab.path)
144 name = tab.name
145 color = tab.uiColor
146 headings = handle.map { handle in
147 (0..<sb_section_count(handle)).map {
148 (String(cString: sb_section_title(handle, $0)), Int(sb_section_level(handle, $0)))
149 }
150 } ?? []
43151 }
44152
45153 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 }
51154}
52155
53final class PagesViewController: UITableViewController, UIDocumentPickerDelegate {
54 private var section: Section
55 private var titles: [String]
156final class SectionsViewController: UITableViewController, UIDocumentPickerDelegate {
157 private(set) var notebook: Notebook?
158 /// Sections by group, in the notebook's order.
159 private var groups: [(name: String, sections: [Tab])] = []
160 var onOpen: ((Tab) -> Void)?
56161
57 init(section: Section) {
58 self.section = section
59 titles = section.titles
162 init() {
60163 super.init(style: .insetGrouped)
61 title = section.name
164 navigationItem.largeTitleDisplayMode = .always
62165 }
63166
64167 required init?(coder: NSCoder) { fatalError() }
65168
66169 override func viewDidLoad() {
67170 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))
171 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "section")
172 let open = UIBarButtonItem(
173 title: "Open Notebook", image: UIImage(systemName: "folder"), target: self, action: #selector(open))
174 navigationItem.rightBarButtonItem = open
175 }
176
177 override func viewWillAppear(_ animated: Bool) {
178 super.viewWillAppear(animated)
179 navigationController?.navigationBar.prefersLargeTitles = true
180 }
181
182 func load(_ notebook: Notebook?) {
183 self.notebook = notebook
184 title = notebook?.name
185 var groups: [(name: String, sections: [Tab])] = []
186 for tab in notebook?.sections ?? [] {
187 if groups.last?.name == tab.group {
188 groups[groups.count - 1].sections.append(tab)
189 } else {
190 groups.append((tab.group, [tab]))
191 }
192 }
193 self.groups = groups
194 tableView.reloadData()
195 var empty = UIContentUnavailableConfiguration.empty()
196 empty.text = notebook == nil ? "No Notebook Open" : "No Sections"
197 empty.secondaryText = notebook == nil ? "Open a notebook folder or section from Files." : nil
198 contentUnavailableConfiguration = groups.isEmpty ? empty : nil
71199 }
72200
73201 @objc private func open() {
74202 let picker = UIDocumentPickerViewController(
75 forOpeningContentTypes: [UTType(filenameExtension: "one") ?? .data], asCopy: true)
203 forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])
76204 picker.delegate = self
77205 present(picker, animated: true)
78206 }
79207
80208 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
81209 guard let url = urls.first else { return }
82 section = Section(path: url.path)
83 titles = section.titles
84 title = section.name
85 tableView.reloadData()
210 guard let notebook = Library.open(url) else {
211 let alert = UIAlertController(
212 title: "Can’t Open Notebook", message: "Choose a OneNote notebook folder or a section file.",
213 preferredStyle: .alert)
214 alert.addAction(UIAlertAction(title: "OK", style: .default))
215 present(alert, animated: true)
216 return
217 }
218 load(notebook)
86219 }
87220
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)
221 override func numberOfSections(in tableView: UITableView) -> Int { groups.count }
222
223 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
224 groups[section].name.isEmpty ? nil : groups[section].name.replacingOccurrences(of: "/", with: " › ")
92225 }
93226
94227 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
95 titles.count
228 groups[section].sections.count
96229 }
97230
98231 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
232 let tab = groups[indexPath.section].sections[indexPath.row]
233 let cell = tableView.dequeueReusableCell(withIdentifier: "section", for: indexPath)
234 var content = cell.defaultContentConfiguration()
235 content.text = tab.name
236 content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill")
237 content.imageProperties.tintColor = tab.uiColor
238 if !tab.readable {
239 content.secondaryText = "Can’t be opened here"
240 content.textProperties.color = .secondaryLabel
241 }
242 cell.contentConfiguration = content
243 cell.accessoryType = tab.readable ? .disclosureIndicator : .none
244 cell.selectionStyle = tab.readable ? .default : .none
245 return cell
246 }
247
248 override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
249 groups[indexPath.section].sections[indexPath.row].readable ? indexPath : nil
250 }
251
252 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
253 onOpen?(groups[indexPath.section].sections[indexPath.row])
254 }
255}
256
257final class PagesViewController: UITableViewController {
258 private(set) var section: Section?
259 var onOpen: ((Section, Int) -> Void)?
260
261 init() {
262 super.init(style: .plain)
263 }
264
265 required init?(coder: NSCoder) { fatalError() }
266
267 override func viewDidLoad() {
268 super.viewDidLoad()
269 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
270 }
271
272 func load(_ tab: Tab) {
273 let section = Section(tab)
274 self.section = section
275 title = section.name
276 tableView.reloadData()
277 var empty = UIContentUnavailableConfiguration.empty()
278 empty.text = section.handle == nil ? "Can’t Open Section" : "No Pages"
279 contentUnavailableConfiguration = section.headings.isEmpty ? empty : nil
280 }
281
282 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
283 self.section?.headings.count ?? 0
284 }
285
286 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
287 let heading = section!.headings[indexPath.row]
99288 let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath)
100289 var content = cell.defaultContentConfiguration()
101 content.text = titles[indexPath.row].isEmpty ? "Untitled page" : titles[indexPath.row]
290 content.text = heading.title.isEmpty ? "Untitled Page" : heading.title
291 if heading.level > 1 { content.textProperties.color = .secondaryLabel }
292 // Subpages sit under their page, indented as in OneNote's page list.
293 content.directionalLayoutMargins.leading += CGFloat(heading.level - 1) * 20
102294 cell.contentConfiguration = content
103 cell.accessoryType = .disclosureIndicator
104295 return cell
105296 }
106297
107298 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
108 tableView.deselectRow(at: indexPath, animated: true)
109 show(page: indexPath.row, animated: true)
299 guard let section else { return }
300 onOpen?(section, indexPath.row)
110301 }
111302}
112303
113304final class PageViewController: UIViewController {
114305 private let section: Section
115306 private let page: Int
307 private lazy var canvas = CanvasView(section: section, page: page)
308 private lazy var done = UIBarButtonItem(
309 systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() })
310 private lazy var undo = UIBarButtonItem(
311 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
312 private lazy var redo = UIBarButtonItem(
313 systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
116314
117 init(section: Section, page: Int, title: String) {
315 init(section: Section, page: Int) {
118316 self.section = section
119317 self.page = page
120318 super.init(nibName: nil, bundle: nil)
121 self.title = title
319 title = section.headings[page].title
122320 navigationItem.largeTitleDisplayMode = .never
123321 }
124322
125323 required init?(coder: NSCoder) { fatalError() }
126324
127325 override func loadView() {
128 view = CanvasView(section: section, page: page)
326 view = canvas
327 canvas.onChange = { [weak self] in self?.editingChanged() }
328 }
329
330 /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it.
331 private func editingChanged() {
332 guard canvas.isFirstResponder else {
333 navigationItem.rightBarButtonItems = nil
334 return
335 }
336 undo.isEnabled = canvas.canUndo(redo: false)
337 redo.isEnabled = canvas.canUndo(redo: true)
338 if navigationItem.rightBarButtonItems?.first !== done {
339 navigationItem.rightBarButtonItems = [done, redo, undo]
340 }
129341 }
130342}
apps/ios/Snowbound/CanvasView.swift+402-35
......@@ -4,7 +4,8 @@ private final class MetalView: UIView {
44 override class var layerClass: AnyClass { CAMetalLayer.self }
55}
66
7/// An offset into the active outline's text in UTF-16 units, paragraphs joined by a newline.
7/// An offset into the active outline's shown text in UTF-16 units, paragraphs joined by a
8/// newline; collapsed paragraphs are not part of it.
89private final class Position: UITextPosition {
910 let value: Int
1011 init(_ value: Int) { self.value = value }
......@@ -37,13 +38,31 @@ private extension UITextRange {
3738 var upper: Int { (self as! Range).hi }
3839}
3940
41/// What a touch lands on; see `sb_view_target`.
42private enum Target: UInt8 {
43 case page, activeText, text, grip
44}
45
46/// Routes the system's undo (shake, three-finger swipe, the keyboard's undo key) to the
47/// canvas's history.
48private final class CanvasUndoManager: UndoManager {
49 weak var canvas: CanvasView?
50 override var canUndo: Bool { canvas?.canUndo(redo: false) ?? false }
51 override var canRedo: Bool { canvas?.canUndo(redo: true) ?? false }
52 override func undo() { canvas?.undo(redo: false) }
53 override func redo() { canvas?.undo(redo: true) }
54}
55
4056/// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page
4157/// 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.
58/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text
59/// interaction, loupe and edit menu work on it.
4460final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
45 UITextSelectionDisplayInteractionDelegate
61 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate
4662{
63 /// The zoom a page opens at once the reader has pinched one.
64 private static let zoomKey = "zoom"
65
4766 private let section: Section
4867 private let page: Int
4968 private let metal = MetalView()
......@@ -56,38 +75,74 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
5675 private var origin = CGPoint.zero
5776 private var syncing = false
5877 private var scrolled = false
78 /// How far right of the content's corner a page opens.
79 private var left: CGFloat = 0
80 /// The zoom a double tap returns to.
81 private var resting: CGFloat = 1
82 private var tapZooming = false
5983 private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
84 private lazy var doubleTap: UITapGestureRecognizer = {
85 let recognizer = UITapGestureRecognizer(target: self, action: #selector(doubleTapped))
86 recognizer.numberOfTapsRequired = 2
87 return recognizer
88 }()
89 /// Moves or widens the focused outline from its grips.
90 private lazy var grip = UIPanGestureRecognizer(target: self, action: #selector(gripped))
91 /// Where the touch the grip and handle drags look at came down.
92 private var touchStart = CGPoint.zero
93 /// Drags a selection handle, which the text interaction leaves to the view drawing it.
94 private lazy var handleDrag = UIPanGestureRecognizer(target: self, action: #selector(handleDragged))
95 /// The selection end a handle drag keeps, and the loupe following the other.
96 private var handleAnchor = 0
97 private var loupe: UITextLoupeSession?
98 private lazy var editMenu = UIEditMenuInteraction(delegate: nil)
6099 private let interaction = UITextInteraction(for: .editable)
61 /// Draws the system caret, selection highlight and handles; the canvas paints neither.
100 /// Draws the caret, selection highlight and handles, which the text interaction's
101 /// gestures move; the canvas paints neither.
62102 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)
103 private let history = CanvasUndoManager()
63104
64105 weak var inputDelegate: UITextInputDelegate?
65 lazy var tokenizer: UITextInputTokenizer = UITextInputStringTokenizer(textInput: self)
106 lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self)
107 /// Called when editing starts or stops and after each change, for the bar buttons.
108 var onChange: (() -> Void)?
66109
67110 init(section: Section, page: Int) {
68111 self.section = section
69112 self.page = page
70113 super.init(frame: .zero)
71 backgroundColor = .white
114 backgroundColor = .systemBackground
72115 delegate = self
73116 minimumZoomScale = 0.25
74117 maximumZoomScale = 4
75118 keyboardDismissMode = .interactive
76119 addSubview(content)
77120 insertSubview(metal, at: 0)
121 // Touches land on the scroll view itself, where the text interaction looks for them.
78122 metal.isUserInteractionEnabled = false
123 content.isUserInteractionEnabled = false
79124 let layer = metal.layer as! CAMetalLayer
80125 layer.isOpaque = true
81126 // Frames reach the screen with the transaction that moves UIKit's caret and handles.
82127 layer.presentsWithTransaction = true
83 addGestureRecognizer(tap)
128 for recognizer in [tap, doubleTap, grip, handleDrag] {
129 recognizer.delegate = self
130 addGestureRecognizer(recognizer)
131 }
132 tap.require(toFail: doubleTap)
133 panGestureRecognizer.require(toFail: grip)
134 panGestureRecognizer.require(toFail: handleDrag)
84135 interaction.textInput = self
85136 interaction.delegate = self
86137 addInteraction(interaction)
87138 addInteraction(display)
139 display.isActivated = false
140 addInteraction(editMenu)
141 history.canvas = self
88142 NotificationCenter.default.addObserver(
89143 self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification,
90144 object: nil)
145 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() }
91146 }
92147
93148 required init?(coder: NSCoder) { fatalError() }
......@@ -102,6 +157,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
102157 override func layoutSubviews() {
103158 super.layoutSubviews()
104159 metal.frame = bounds
160 // Selection views go in at the back, where the opaque page would hide them.
161 sendSubviewToBack(metal)
105162 let scale = window?.screen.scale ?? 3
106163 metal.layer.contentsScale = scale
107164 guard bounds.width > 0, bounds.height > 0 else { return }
......@@ -116,9 +173,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
116173 Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page,
117174 Float(bounds.width), Float(bounds.height), Float(scale))
118175 guard let handle else { return }
176 sb_view_focus(handle, false)
177 paper()
119178 sync()
120 let fit = (bounds.width - safeAreaInsets.left - safeAreaInsets.right) / content.bounds.width
121 zoomScale = min(1, max(0.5, fit))
179 let skip = margin
180 resting = openingZoom()
181 zoomScale = resting
182 left = skip * resting
122183 home()
123184 #if DEBUG
124185 runScript()
......@@ -145,6 +206,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
145206 sb_view_render(handle)
146207 }
147208
209 /// The page follows the system's appearance, on the desktop's dark paper in dark mode.
210 private func paper() {
211 guard let handle else { return }
212 sb_view_set_dark(handle, traitCollection.userInterfaceStyle == .dark)
213 dirty = true
214 }
215
148216 // MARK: Scrolling and zoom
149217
150218 /// Sizes the scroll content to the page's after an edit, keeping the page point at the
......@@ -173,7 +241,25 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
173241 Float(origin.y + contentOffset.y / zoomScale))
174242 metal.frame = bounds
175243 dirty = true
176 display.setNeedsSelectionUpdate()
244 }
245
246 /// The zoom the reader last pinched to, within reason; before that, OneNote's 100%, where its 11 pt
247 /// body text shows at 15 points, or up to 140% where the whole page fits across.
248 private func openingZoom() -> CGFloat {
249 if let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double {
250 // A page opens readable however far the last one was pinched.
251 return min(2, max(0.75, zoom))
252 }
253 let width = bounds.inset(by: safeAreaInsets).width
254 return min(1.4, max(1, width / (content.bounds.width - margin)))
255 }
256
257 /// The empty page left of its first outline, which a phone skips on opening.
258 private var margin: CGFloat {
259 guard let handle else { return 0 }
260 var block: [Float] = [0, 0, 0, 0]
261 guard sb_view_block(handle, 0, 0, &block) else { return 0 }
262 return max(0, CGFloat(block[0]) - origin.x - 12)
177263 }
178264
179265 /// Until the reader scrolls, the page's corner stays below the navigation bar as its
......@@ -184,13 +270,54 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
184270 }
185271
186272 private func home() {
187 if !scrolled { contentOffset = CGPoint(x: -adjustedContentInset.left, y: -adjustedContentInset.top) }
273 if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) }
188274 }
189275
190276 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true }
277 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrolled = true }
191278 func viewForZooming(in scrollView: UIScrollView) -> UIView? { content }
192279 func scrollViewDidScroll(_ scrollView: UIScrollView) { transform() }
193 func scrollViewDidZoom(_ scrollView: UIScrollView) { transform() }
280 func scrollViewDidZoom(_ scrollView: UIScrollView) {
281 transform()
282 // Scrolling carries the caret and handles along; zooming moves them within the page.
283 display.setNeedsSelectionUpdate()
284 }
285
286 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
287 if tapZooming {
288 tapZooming = false
289 } else {
290 resting = scale
291 UserDefaults.standard.set(Double(scale), forKey: Self.zoomKey)
292 }
293 }
294
295 @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) {
296 zoom(at: recognizer.location(in: self))
297 }
298
299 /// Zooms to fit the outline nearest `point` across the view, or back out, as a double
300 /// tap does in Safari.
301 func zoom(at point: CGPoint) {
302 guard let handle else { return }
303 tapZooming = true
304 var block: [Float] = [0, 0, 0, 0]
305 let local = visible(point)
306 guard zoomScale <= resting * 1.05, sb_view_block(handle, Float(local.x), Float(local.y), &block) else {
307 setZoomScale(resting, animated: true)
308 return
309 }
310 let width = bounds.inset(by: safeAreaInsets).width
311 let margin: CGFloat = 12
312 let scale = min(maximumZoomScale, (width - 2 * margin) / (CGFloat(block[2] - block[0])))
313 guard scale > zoomScale * 1.05 else {
314 setZoomScale(resting, animated: true)
315 return
316 }
317 let x = CGFloat(block[0]) - origin.x - margin / scale
318 let y = point.y / zoomScale - bounds.height / scale / 2
319 zoom(to: CGRect(x: x, y: y, width: width / scale, height: bounds.height / scale), animated: true)
320 }
194321
195322 @objc private func keyboardChanged(_ notification: Notification) {
196323 guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
......@@ -205,33 +332,125 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
205332
206333 // MARK: Touch
207334
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 }
335 private func target(_ point: CGPoint) -> Target {
336 guard let handle else { return .page }
212337 let local = visible(point)
213 return isFirstResponder && sb_view_in_active_text(handle, Float(local.x), Float(local.y))
338 return Target(rawValue: sb_view_target(handle, Float(local.x), Float(local.y))) ?? .page
339 }
340
341 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
342 if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) }
343 return true
214344 }
215345
216346 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
217 recognizer === tap
218 ? !inActiveText(recognizer.location(in: self)) : super.gestureRecognizerShouldBegin(recognizer)
347 switch recognizer {
348 case tap:
349 [.page, .grip].contains(target(recognizer.location(in: self))) && !onHandle(recognizer.location(in: self))
350 case doubleTap: target(recognizer.location(in: self)) == .page
351 case grip: target(touchStart) == .grip
352 case handleDrag: onHandle(touchStart)
353 default: super.gestureRecognizerShouldBegin(recognizer)
354 }
219355 }
220356
357 /// The system's text interaction works in the outline taking input; a touch on another
358 /// outline's text focuses it first, as a tap there would.
221359 func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {
222 inActiveText(point)
360 if onHandle(point) { return true }
361 switch target(point) {
362 case .activeText: break
363 case .text: press(at: point)
364 case .page, .grip: return false
365 }
366 if !isFirstResponder { _ = becomeFirstResponder() }
367 return true
368 }
369
370 /// Whether `point` is on a selection handle, whose knob hangs off the text.
371 private func onHandle(_ point: CGPoint) -> Bool {
372 guard isFirstResponder, let range = selectedTextRange, !range.isEmpty else { return false }
373 return [range.start, range.end].contains {
374 caretRect(for: $0).insetBy(dx: -22, dy: -22).contains(point)
375 }
223376 }
224377
225378 @objc private func tapped(_ recognizer: UITapGestureRecognizer) {
226379 tap(at: recognizer.location(in: self))
227380 }
228381
229 /// A tap at `point` in the scroll view's bounds.
382 /// A tap at `point` in the scroll view's bounds: places the caret, focuses an outline or
383 /// starts a new one.
230384 func tap(at point: CGPoint) {
385 press(at: point)
386 if !isFirstResponder { _ = becomeFirstResponder() }
387 }
388
389 private func press(at point: CGPoint) {
231390 guard let handle else { return }
232391 let point = visible(point)
233 edit(external: true) { sb_view_tap(handle, Float(point.x), Float(point.y)) }
234 if !isFirstResponder { _ = becomeFirstResponder() }
392 edit(external: true) {
393 let pressed = sb_view_press(handle, Float(point.x), Float(point.y))
394 return sb_view_release(handle) || pressed
395 }
396 }
397
398 @objc private func handleDragged(_ recognizer: UIPanGestureRecognizer) {
399 guard let range = selectedTextRange else { return }
400 // The finger sits below the knob; aim at the text line above it.
401 let point = recognizer.location(in: self)
402 let aim = CGPoint(x: point.x, y: point.y - 16)
403 switch recognizer.state {
404 case .began:
405 let start = caretRect(for: range.start)
406 let end = caretRect(for: range.end)
407 let nearStart = hypot(start.midX - touchStart.x, start.midY - touchStart.y)
408 < hypot(end.midX - touchStart.x, end.midY - touchStart.y)
409 handleAnchor = nearStart ? range.upper : range.lower
410 let handle = display.handleViews.min {
411 hypot($0.center.x - touchStart.x, $0.center.y - touchStart.y)
412 < hypot($1.center.x - touchStart.x, $1.center.y - touchStart.y)
413 }
414 loupe = UITextLoupeSession.begin(at: aim, fromSelectionWidgetView: handle, in: self)
415 editMenu.dismissMenu()
416 fallthrough
417 case .changed:
418 guard let position = closestPosition(to: aim) else { return }
419 // A handle stops one character short of the other.
420 let step = position.offset < handleAnchor || (position.offset == handleAnchor && handleAnchor > 0) ? -1 : 1
421 let end = position.offset == handleAnchor ? handleAnchor + step : position.offset
422 let moved = Range(handleAnchor, min(max(end, 0), endOfDocument.offset))
423 edit(external: true) { choose(moved) }
424 let caret = caretRect(for: position)
425 loupe?.move(to: CGPoint(x: caret.midX, y: caret.midY), withCaretRect: caret, trackingCaret: false)
426 default:
427 loupe?.invalidate()
428 loupe = nil
429 let end = caretRect(for: range.end)
430 editMenu.presentEditMenu(with: UIEditMenuConfiguration(
431 identifier: nil, sourcePoint: CGPoint(x: end.midX, y: caretRect(for: range.start).minY)))
432 }
433 }
434
435 @objc private func gripped(_ recognizer: UIPanGestureRecognizer) {
436 guard let handle else { return }
437 let point = visible(recognizer.location(in: self))
438 switch recognizer.state {
439 case .began:
440 let start = visible(touchStart)
441 _ = sb_view_press(handle, Float(start.x), Float(start.y))
442 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
443 // The caret and handles stay behind until the outline lands.
444 display.isActivated = false
445 editMenu.dismissMenu()
446 dirty = true
447 case .changed:
448 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
449 dirty = true
450 default:
451 display.isActivated = isFirstResponder
452 edit(external: true) { sb_view_release(handle) }
453 }
235454 }
236455
237456 /// Runs a change to the page, telling the system when it did not ask for it.
......@@ -246,10 +465,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
246465 inputDelegate?.textDidChange(self)
247466 inputDelegate?.selectionDidChange(self)
248467 }
468 dirty = true
249469 if changed {
250470 sync()
251471 revealCaret()
252472 }
473 display.setNeedsSelectionUpdate()
474 onChange?()
253475 }
254476
255477 /// Takes the edit the page recorded, so its ops do not pile up; the host drops it
......@@ -260,33 +482,102 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
260482 }
261483
262484 private func revealCaret() {
263 guard let handle, isFirstResponder else { return }
264 var range: [UInt32] = [0, 0]
265 sb_selection(handle, &range)
266 let caret = caretRect(for: Position(Int(range[1])))
485 guard handle != nil, isFirstResponder, let range = selectedTextRange else { return }
486 let caret = caretRect(for: range.end)
267487 scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false)
268488 }
269489
490 // MARK: Responder
491
270492 override var canBecomeFirstResponder: Bool { handle != nil }
271493
272494 override func becomeFirstResponder() -> Bool {
273495 guard super.becomeFirstResponder() else { return false }
496 if let handle { sb_view_focus(handle, true) }
274497 display.isActivated = true
498 dirty = true
499 onChange?()
275500 return true
276501 }
277502
278503 override func resignFirstResponder() -> Bool {
279504 guard super.resignFirstResponder() else { return false }
505 if let handle { sb_view_focus(handle, false) }
280506 display.isActivated = false
507 dirty = true
508 onChange?()
281509 return true
282510 }
283511
512 override var undoManager: UndoManager? { history }
513
514 func canUndo(redo: Bool) -> Bool { handle.map { sb_view_can_undo($0, redo) } ?? false }
515
516 func undo(redo: Bool) {
517 guard let handle else { return }
518 edit(external: true) { sb_view_undo(handle, redo) }
519 }
520
521 private var selection: (lo: Int, hi: Int) {
522 guard let range = selectedTextRange else { return (0, 0) }
523 return (range.lower, range.upper)
524 }
525
526 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
527 let (lo, hi) = selection
528 switch action {
529 case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)),
530 #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)):
531 return lo < hi
532 case #selector(paste(_:)): return UIPasteboard.general.hasStrings
533 case #selector(select(_:)): return lo == hi && hasText
534 case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset)
535 default: return super.canPerformAction(action, withSender: sender)
536 }
537 }
538
539 override func copy(_ sender: Any?) {
540 guard let range = selectedTextRange, let text = text(in: range) else { return }
541 UIPasteboard.general.string = text
542 }
543
544 override func cut(_ sender: Any?) {
545 copy(sender)
546 delete(sender)
547 }
548
549 override func delete(_ sender: Any?) {
550 guard let range = selectedTextRange else { return }
551 edit(external: true) { replacing(range, with: "") }
552 }
553
284554 /// Pasted text takes the keyboard's language, as Windows gives the clipboard.
285555 override func paste(_ sender: Any?) {
286556 guard let handle, let text = UIPasteboard.general.string else { return }
287557 edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }
288558 }
289559
560 override func select(_ sender: Any?) {
561 guard let caret = selectedTextRange?.start,
562 let word = tokenizer.rangeEnclosingPosition(caret, with: .word, inDirection: .storage(.backward))
563 ?? tokenizer.rangeEnclosingPosition(caret, with: .word, inDirection: .storage(.forward))
564 else { return }
565 edit(external: true) { choose(word) }
566 }
567
568 override func selectAll(_ sender: Any?) {
569 edit(external: true) { choose(Range(0, endOfDocument.offset)) }
570 }
571
572 override func toggleBoldface(_ sender: Any?) { toggle(0) }
573 override func toggleItalics(_ sender: Any?) { toggle(1) }
574 override func toggleUnderline(_ sender: Any?) { toggle(2) }
575
576 private func toggle(_ style: UInt8) {
577 guard let handle else { return }
578 edit(external: true) { sb_view_toggle(handle, style) }
579 }
580
290581 // MARK: Coordinates
291582
292583 /// A point in the scroll view's bounds as the canvas view's points from its corner.
......@@ -322,9 +613,18 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
322613 return String(cString: text)
323614 }
324615
616 private func replacing(_ range: UITextRange, with text: String) -> Bool {
617 guard let handle else { return false }
618 return sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text)
619 }
620
325621 func replace(_ range: UITextRange, withText text: String) {
326 guard let handle else { return }
327 edit { sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text) }
622 edit { replacing(range, with: text) }
623 }
624
625 private func choose(_ range: UITextRange) -> Bool {
626 guard let handle else { return false }
627 return sb_select(handle, UInt32(range.lower), UInt32(range.upper))
328628 }
329629
330630 var selectedTextRange: UITextRange? {
......@@ -335,10 +635,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
335635 return Range(Int(range[0]), Int(range[1]))
336636 }
337637 set {
338 guard let handle, let newValue else { return }
339 _ = sb_select(handle, UInt32(newValue.lower), UInt32(newValue.upper))
340 dirty = true
638 guard let newValue else { return }
639 _ = choose(newValue)
341640 display.setNeedsSelectionUpdate()
641 dirty = true
642 onChange?()
342643 }
343644 }
344645
......@@ -425,14 +726,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
425726 var rect: [Float] = [0, 0, 0, 0]
426727 guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero }
427728 var caret = bounded(rect)
729 caret.origin.x -= 1
428730 caret.size.width = 2
429731 return caret
430732 }
431733
432734 func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
433735 guard let handle else { return [] }
434 var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: 64)
435 let count = min(64, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, 64))
736 let capacity = 256
737 var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: capacity)
738 let count = min(capacity, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, capacity))
436739 return (0..<count).map {
437740 let rect = rects[$0]
438741 return SelectionRect(
......@@ -456,3 +759,67 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
456759 return Range(position.offset, min(position.offset + 1, endOfDocument.offset))
457760 }
458761}
762
763/// Words and sentences come from the text; lines come from the canvas's layout, which the
764/// string tokenizer cannot see, so a tap after a line's last word stays on that line.
765private final class LineTokenizer: UITextInputStringTokenizer {
766 private unowned let canvas: CanvasView
767
768 init(canvas: CanvasView) {
769 self.canvas = canvas
770 super.init(textInput: canvas)
771 }
772
773 private func forward(_ direction: UITextDirection) -> Bool {
774 [UITextStorageDirection.forward.rawValue, UITextLayoutDirection.right.rawValue,
775 UITextLayoutDirection.down.rawValue].contains(direction.rawValue)
776 }
777
778 private func line(_ offset: Int) -> CGFloat { canvas.caretRect(for: Position(offset)).minY.rounded() }
779
780 private func atEdge(_ offset: Int, forward: Bool) -> Bool {
781 let end = canvas.endOfDocument.offset
782 return forward
783 ? offset >= end || line(offset + 1) != line(offset)
784 : offset <= 0 || line(offset - 1) != line(offset)
785 }
786
787 private func edge(_ offset: Int, forward: Bool) -> Int {
788 var offset = offset
789 while !atEdge(offset, forward: forward) { offset += forward ? 1 : -1 }
790 return offset
791 }
792
793 override func isPosition(
794 _ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection
795 ) -> Bool {
796 guard granularity == .line else {
797 return super.isPosition(position, atBoundary: granularity, inDirection: direction)
798 }
799 return atEdge(position.offset, forward: forward(direction))
800 }
801
802 override func position(
803 from position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection
804 ) -> UITextPosition? {
805 guard granularity == .line else {
806 return super.position(from: position, toBoundary: granularity, inDirection: direction)
807 }
808 return Position(edge(position.offset, forward: forward(direction)))
809 }
810
811 override func rangeEnclosingPosition(
812 _ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection
813 ) -> UITextRange? {
814 guard granularity == .line else {
815 return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction)
816 }
817 return Range(edge(position.offset, forward: false), edge(position.offset, forward: true))
818 }
819
820 override func isPosition(
821 _ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection
822 ) -> Bool {
823 granularity == .line || super.isPosition(position, withinTextUnit: granularity, inDirection: direction)
824 }
825}
apps/ios/Snowbound/Script.swift+15-2
......@@ -5,8 +5,10 @@ extension CanvasView {
55 /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check
66 /// input without a finger: steps joined by `|`, such as
77 /// `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.
8 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
9 /// `scroll` a content offset; `done` ends editing, `tree` saves the view hierarchy to
10 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
11 /// screenshot command.
1012 func runScript() {
1113 guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }
1214 for (index, step) in script.split(separator: "|").enumerated() {
......@@ -32,6 +34,9 @@ extension CanvasView {
3234 case "unmark": unmarkText()
3335 case "select":
3436 let values = argument.split(separator: ",").compactMap { Int($0) }
37 // As a gesture would, the change reaches the system's text interaction.
38 inputDelegate?.selectionWillChange(self)
39 defer { inputDelegate?.selectionDidChange(self) }
3540 selectedTextRange = textRange(
3641 from: position(from: beginningOfDocument, offset: values[0])!,
3742 to: position(from: beginningOfDocument, offset: values[1])!)
......@@ -39,6 +44,14 @@ extension CanvasView {
3944 let values = argument.split(separator: ",").compactMap { Double($0) }
4045 setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true)
4146 case "zoom": setZoomScale(Double(argument) ?? 1, animated: false)
47 case "doubletap":
48 let values = argument.split(separator: ",").compactMap { Double($0) }
49 zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
50 case "done": _ = resignFirstResponder()
51 case "tree":
52 let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
53 try? "\(value(forKey: "recursiveDescription") ?? "")".write(
54 to: documents.appendingPathComponent("tree.txt"), atomically: true, encoding: .utf8)
4255 case "shot":
4356 guard let window else { break }
4457 let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
apps/ios/build-rust.sh+1-1
......@@ -10,5 +10,5 @@ release=
1010root="$SRCROOT/../.."
1111# Xcode's SDK variables would point host build scripts at the iOS SDK.
1212exec 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" \
13 CARGO_TARGET_DIR="$root/target/ios" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" \
1414 cargo build --manifest-path "$root/Cargo.toml" -p mobile --target "$target" $release
crates/mobile/Cargo.toml+2
......@@ -11,8 +11,10 @@ crate-type = ["staticlib"]
1111[dependencies]
1212canvas = { path = "../canvas", features = ["interaction"] }
1313draw = { path = "../draw" }
14notebook = { path = "../notebook" }
1415onestore = { path = "../onestore" }
1516wgpu.workspace = true
1617parley.workspace = true
1718pollster = "0.4"
19serde = { version = "1", features = ["derive"] }
1820serde_json = "1"
crates/mobile/include/snowbound.h+14-3
......@@ -8,9 +8,12 @@
88typedef struct Section Section;
99typedef struct View View;
1010
11char *sb_notebook(const char *path);
12
1113Section *sb_section_open(const char *path);
1214size_t sb_section_count(const Section *section);
1315const char *sb_section_title(const Section *section, size_t index);
16uint32_t sb_section_level(const Section *section, size_t index);
1417void sb_section_free(Section *section);
1518
1619View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale);
......@@ -18,15 +21,23 @@ void sb_view_free(View *view);
1821void sb_view_resize(View *view, float width, float height, float scale);
1922bool sb_view_render(View *view);
2023bool sb_view_frame_pending(const View *view);
24void sb_view_set_dark(View *view, bool dark);
25void sb_view_focus(View *view, bool focused);
2126void sb_view_content(View *view, float bounds[4]);
27bool sb_view_block(const View *view, float x, float y, float rect[4]);
2228void 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);
29uint8_t sb_view_target(const View *view, float x, float y);
30bool sb_view_press(View *view, float x, float y);
31bool sb_view_drag(View *view, float x, float y);
32bool sb_view_release(View *view);
33bool sb_view_undo(View *view, bool redo);
34bool sb_view_can_undo(const View *view, bool redo);
35bool sb_view_toggle(View *view, uint8_t toggle);
2536char *sb_view_edit(View *view);
37void sb_string_free(char *text);
2638
2739uint32_t sb_text_length(const View *view);
2840char *sb_text(const View *view, uint32_t start, uint32_t end);
29void sb_string_free(char *text);
3041void sb_selection(const View *view, uint32_t range[2]);
3142bool sb_select(View *view, uint32_t start, uint32_t end);
3243bool sb_marked(const View *view, uint32_t range[2]);
crates/mobile/src/lib.rs+504-300
......@@ -1,6 +1,6 @@
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.
1//! A canvas page behind a C surface for native mobile shells: a notebook's sections, a
2//! section's pages, one page drawn into a `CAMetalLayer`, and the active outline's text as
3//! the flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`).
44//!
55//! Lengths and positions are in the host's points; the view converts through its display
66//! scale. Calls returning `bool` report whether the page or selection changed, after which
......@@ -8,12 +8,13 @@
88
99use canvas::{
1010 document::TextPosition,
11 editor::Selection,
11 editor::{Formatting, Selection, TextOutline, Toggle},
1212 gpu::{Paper, Viewport, page::PageScene},
13 interaction::{Hit, PageView, Response, TextColors, page_hit_test},
13 interaction::{Hit, PageView, Response, TextColors},
1414 layout::TextEngine,
1515};
1616use draw::edit::{Key, NamedKey, SelectionUnit};
17use notebook::discover::{self, Folder, SectionState};
1718use onestore::{
1819 ExGuid, RevisionIndex, Store,
1920 document::Document,
......@@ -24,6 +25,7 @@ use parley::{Affinity, BoundingBox};
2425use std::{
2526 error::Error,
2627 ffi::{CStr, CString, c_char, c_void},
28 path::Path,
2729 sync::{
2830 Arc,
2931 atomic::{AtomicBool, Ordering},
......@@ -37,17 +39,99 @@ type Result<T> = std::result::Result<T, Box<dyn Error>>;
3739/// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch.
3840const POINT: f32 = 96.0 / 72.0;
3941
40/// The platform's text interaction draws the caret and selection.
41const COLORS: TextColors = TextColors {
42 caret: [0.0; 4],
43 selection: [0.0; 4],
44 paper: Paper::WHITE,
45};
42/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
43const SECTION_COLOR: u32 = 0x00e4_a88a;
44
45#[derive(serde::Serialize)]
46struct Listing {
47 name: String,
48 sections: Vec<Tab>,
49}
50
51#[derive(serde::Serialize)]
52struct Tab {
53 name: String,
54 /// The section file's absolute path, for `sb_section_open`.
55 path: String,
56 /// The section group holding it, `/`-separated; empty at the notebook's top.
57 group: String,
58 /// The tab colour in sRGB.
59 color: [u8; 3],
60 /// Password-protected or unreadable sections list but do not open.
61 readable: bool,
62}
63
64/// A notebook folder's sections in its order, or a lone section file as a notebook of one.
65fn listing(path: &Path) -> Result<Listing> {
66 let stem = |path: &Path| {
67 path.file_stem()
68 .map(|stem| stem.to_string_lossy().into_owned())
69 .unwrap_or_default()
70 };
71 if path.is_file() {
72 return Ok(Listing {
73 name: stem(path),
74 sections: vec![Tab {
75 name: stem(path),
76 path: path.to_string_lossy().into_owned(),
77 group: String::new(),
78 color: rgb(SECTION_COLOR),
79 readable: true,
80 }],
81 });
82 }
83 let folder = discover::discover(
84 &mut discover::Local::open(path)?,
85 discover::Limits {
86 entries: 10_000,
87 bytes_per_file: 1 << 30,
88 depth: 16,
89 },
90 )?;
91 let mut sections = Vec::new();
92 fn walk(folder: &Folder, root: &Path, sections: &mut Vec<Tab>) {
93 for section in &folder.sections {
94 let file = root.join(&section.path);
95 let (name, color, readable) = match &section.state {
96 SectionState::Readable { name, color } => (name.clone(), *color, true),
97 SectionState::Locked | SectionState::Unreadable(_) => (None, None, false),
98 };
99 sections.push(Tab {
100 name: name.unwrap_or_else(|| {
101 file.file_stem()
102 .map(|stem| stem.to_string_lossy().into_owned())
103 .unwrap_or_default()
104 }),
105 path: file.to_string_lossy().into_owned(),
106 group: folder.path.clone(),
107 color: rgb(color.unwrap_or(SECTION_COLOR)),
108 readable,
109 });
110 }
111 for group in &folder.groups {
112 // OneNote keeps deleted sections and pages here, out of the notebook's view.
113 if !group.path.ends_with("OneNote_RecycleBin") {
114 walk(group, root, sections);
115 }
116 }
117 }
118 walk(&folder, path, &mut sections);
119 Ok(Listing {
120 name: stem(path),
121 sections,
122 })
123}
124
125fn rgb(colorref: u32) -> [u8; 3] {
126 let [red, green, blue, _] = colorref.to_le_bytes();
127 [red, green, blue]
128}
46129
47130pub struct Section {
48131 /// Each page with the object space holding it.
49132 pages: Vec<(ExGuid, Page)>,
50 titles: Vec<CString>,
133 /// The page list's title and outline level (1 at the top) for each page.
134 headings: Vec<(CString, u32)>,
51135}
52136
53137impl Section {
......@@ -55,16 +139,17 @@ impl Section {
55139 let store = Store::parse(bytes)?;
56140 let index = RevisionIndex::parse(&store)?;
57141 let document = Document::parse(&index)?;
58 let pages = document
59 .pages()?
60 .into_iter()
61 .map(|(space, id)| Ok((space, Page::from_revision(document.active(space)?, id)?)))
62 .collect::<std::result::Result<Vec<_>, onestore::Error>>()?;
63 let titles = pages
64 .iter()
65 .map(|(_, page)| CString::new(page.title.replace('\0', "")))
66 .collect::<std::result::Result<_, _>>()?;
67 Ok(Self { pages, titles })
142 let mut pages = Vec::new();
143 let mut headings = Vec::new();
144 for (space, id) in document.pages()? {
145 let revision = document.active(space)?;
146 let (title, level) = Page::heading(revision, id);
147 pages.push((space, Page::from_revision(revision, id)?));
148 // Titles keep OneNote's line breaks, which a one-line list shows as spaces.
149 let title = title.replace(|char: char| char.is_control(), " ");
150 headings.push((CString::new(title)?, level));
151 }
152 Ok(Self { pages, headings })
68153 }
69154}
70155
......@@ -78,16 +163,6 @@ impl Wake for Frame {
78163 }
79164}
80165
81pub struct View {
82 page: PageView,
83 /// The object space of the page shown, which its edits name.
84 space: ExGuid,
85 surface: wgpu::Surface<'static>,
86 config: wgpu::SurfaceConfiguration,
87 renderer: draw::Renderer,
88 frame: Arc<Frame>,
89}
90
91166fn report<T>(result: Result<T>) -> Option<T> {
92167 result.map_err(|error| eprintln!("snowbound: {error}")).ok()
93168}
......@@ -96,53 +171,70 @@ fn moved(response: Response) -> bool {
96171 response.changed || response.moved
97172}
98173
99impl View {
100 fn new(
101 layer: *mut c_void,
102 (space, page): (ExGuid, Page),
103 size: [f32; 2],
104 scale: f32,
105 ) -> Result<Self> {
106 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
107 // SAFETY: the host passes a live CAMetalLayer that outlives the view.
108 let surface = unsafe {
109 instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer))
110 }?;
111 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
112 compatible_surface: Some(&surface),
113 ..Default::default()
114 }))?;
115 // The simulator's adapter falls short of wgpu's default limits.
116 let (device, queue) =
117 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
118 required_limits: adapter.limits(),
119 ..Default::default()
120 }))?;
121 let pixels = size.map(|side| (side * scale).round().max(1.0) as u32);
122 let config = surface
123 .get_default_config(&adapter, pixels[0], pixels[1])
124 .ok_or("No supported surface configuration")?;
125 surface.configure(&device, &config);
126 let renderer = draw::Renderer::new(device, queue, config.format);
174/// What a touch lands on, so the host routes it before its gesture starts.
175#[repr(u8)]
176#[derive(Debug, PartialEq)]
177enum Target {
178 /// Empty page, a picture, a check box or the date: the canvas takes a tap.
179 Page = 0,
180 /// The text taking input, where the platform's text interaction works.
181 ActiveText = 1,
182 /// Another outline's text, which a touch focuses before the platform's text interaction
183 /// takes over.
184 Text = 2,
185 /// The focused outline's move or width grip.
186 Grip = 3,
187}
188
189/// The page and its text model, apart from the surface it is drawn on.
190struct Canvas {
191 page: PageView,
192 /// The object space of the page shown, which its edits name.
193 space: ExGuid,
194 paper: Paper,
195}
196
197impl Canvas {
198 fn new((space, page): (ExGuid, Page), pixels: [u32; 2], scale: f32) -> Result<Self> {
127199 let mut engine = TextEngine::default();
128200 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
201 let mut page = PageView::new(
202 editor,
203 engine,
204 Some((scene, [0.0; 2])),
205 pixels,
206 scale,
207 Duration::from_millis(350),
208 );
209 page.touch = true;
129210 Ok(Self {
130 page: PageView::new(
131 editor,
132 engine,
133 Some((scene, [0.0; 2])),
134 pixels,
135 scale,
136 Duration::from_millis(350),
137 ),
211 page,
138212 space,
139 surface,
140 config,
141 renderer,
142 frame: Arc::default(),
213 paper: Paper::WHITE,
143214 })
144215 }
145216
217 /// The platform's text interaction draws the caret and selection.
218 fn colors(&self) -> TextColors {
219 TextColors {
220 caret: [0.0; 4],
221 selection: [0.0; 4],
222 paper: self.paper,
223 }
224 }
225
226 /// The desktop's dark page, or white paper.
227 fn set_dark(&mut self, dark: bool) {
228 self.paper = if dark {
229 Paper {
230 color: draw::srgb(0x1f, 0x20, 0x22),
231 ink: draw::srgb(0xe6, 0xe6, 0xe6),
232 }
233 } else {
234 Paper::WHITE
235 };
236 }
237
146238 /// What the page took since the last call as one edit, or none when it took nothing.
147239 fn edit(&mut self) -> Result<Option<Edit>> {
148240 let ops = self.page.editor.take_ops()?;
......@@ -173,31 +265,6 @@ impl View {
173265 point.map(|value| value * self.display_scale())
174266 }
175267
176 fn render(&mut self) -> Result<()> {
177 let frame = match self.surface.get_current_texture() {
178 wgpu::CurrentSurfaceTexture::Success(frame)
179 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
180 wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
181 self.surface.configure(&self.renderer.device, &self.config);
182 return Ok(());
183 }
184 _ => return Ok(()),
185 };
186 self.page
187 .update_pictures(COLORS.paper, &Waker::from(self.frame.clone()));
188 let primitives = self.page.primitives(COLORS)?;
189 self.renderer
190 .draw(
191 &frame.texture.create_view(&Default::default()),
192 [self.config.width, self.config.height],
193 COLORS.paper.color,
194 &[self.page.viewport.layer(&primitives)],
195 )
196 .map_err(|error| format!("Page drawing failed: {error:?}"))?;
197 self.renderer.queue.present(frame);
198 Ok(())
199 }
200
201268 /// The page content's extent in document points: the scroll limits of a view with no
202269 /// size at scale 1.
203270 fn content(&mut self) -> [f32; 4] {
......@@ -218,97 +285,134 @@ impl View {
218285 self.page.viewport.origin = corner.map(|value| -value * scale * zoom);
219286 }
220287
221 fn tap(&mut self, point: [f32; 2]) -> Result<bool> {
222 let mut changed = moved(self.page.pointer_moved(self.device(point))?);
223 changed |= moved(self.page.pointer_pressed(Instant::now())?);
224 changed |= moved(self.page.pointer_released()?);
225 // A finger leaves no hover behind.
226 let _ = self.page.pointer_left();
227 Ok(changed)
288 fn outline(&self, id: ExGuid) -> Option<&TextOutline> {
289 let editor = &self.page.editor;
290 editor
291 .visible_outlines()
292 .chain(editor.caret_outline())
293 .find(|outline| outline.id == id)
228294 }
229295
230 /// Whether `point` lands on the text of the outline taking input, where the platform's
231 /// own text interaction places the caret.
232 fn in_active_text(&self, point: [f32; 2]) -> bool {
233 let point = self.page.viewport.document_point(self.device(point));
234 let pixel = self.display_scale() / self.page.viewport.scale;
235 matches!(
236 page_hit_test(&self.page.editor, self.page.scene.as_ref(), point, pixel),
237 Some(Hit::Text { id, .. }) if id == self.page.editor.active_outline().id
238 )
239 }
240
241 /// UTF-16 lengths of the active outline's text paragraphs.
242 fn lengths(&self) -> Result<Vec<u32>> {
243 self.page
244 .editor
245 .active_outline()
246 .document()
247 .paragraphs()
248 .map(|paragraph| Ok(paragraph.utf16_offset(paragraph.text().len())?))
249 .collect()
250 }
251
252 fn position(&self, flat: u32) -> Result<TextPosition> {
253 let lengths = self.lengths()?;
254 let mut rest = flat;
255 for (paragraph, length) in lengths.iter().enumerate() {
256 if rest <= *length {
257 return Ok(TextPosition {
258 paragraph,
259 offset: rest,
260 });
296 fn target(&self, point: [f32; 2]) -> Target {
297 match self.page.hit(self.device(point)) {
298 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,
299 Some(Hit::Text { id, point })
300 if !self
301 .outline(id)
302 .is_some_and(|outline| outline.contains_extension(point)) =>
303 {
304 if id == self.page.editor.active_outline().id {
305 Target::ActiveText
306 } else {
307 Target::Text
308 }
261309 }
262 rest -= length + 1;
310 _ => Target::Page,
263311 }
264 Ok(TextPosition {
265 paragraph: lengths.len() - 1,
266 offset: lengths[lengths.len() - 1],
312 }
313
314 /// Runs `change` on the page, then puts the view back where the host's scroll view has
315 /// it: the canvas clamps and reveals knowing neither the navigation bar nor the keyboard,
316 /// and the host reveals the caret itself.
317 fn hold<T>(&mut self, change: impl FnOnce(&mut PageView) -> Result<T>) -> Result<T> {
318 let Viewport { scale, origin, .. } = self.page.viewport;
319 let result = change(&mut self.page);
320 self.page.viewport.scale = scale;
321 self.page.viewport.origin = origin;
322 result
323 }
324
325 /// A finger down at `point`: the start of a tap or of a grip drag.
326 fn press(&mut self, point: [f32; 2]) -> Result<bool> {
327 let point = self.device(point);
328 self.hold(|page| {
329 let changed = moved(page.pointer_moved(point)?);
330 Ok(moved(page.pointer_pressed(Instant::now())?) || changed)
267331 })
268332 }
269333
270 fn flat(&self, position: TextPosition) -> Result<u32> {
271 let before: u32 = self.lengths()?[..position.paragraph]
272 .iter()
273 .map(|length| length + 1)
274 .sum();
275 Ok(before + position.offset)
334 fn drag(&mut self, point: [f32; 2]) -> Result<bool> {
335 let point = self.device(point);
336 self.hold(|page| Ok(moved(page.pointer_moved(point)?)))
276337 }
277338
278 fn length(&self) -> Result<u32> {
279 let lengths = self.lengths()?;
280 Ok(lengths.iter().sum::<u32>() + lengths.len() as u32 - 1)
339 fn release(&mut self) -> Result<bool> {
340 self.hold(|page| {
341 let changed = moved(page.pointer_released()?);
342 // A finger leaves no hover behind.
343 let _ = page.pointer_left();
344 Ok(changed)
345 })
281346 }
282347
283 fn text(&self, range: [u32; 2]) -> Result<String> {
284 let range = self.position(range[0])?..self.position(range[1])?;
285 Ok(self
348 /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom.
349 fn block(&self, point: [f32; 2]) -> Option<[f32; 4]> {
350 let [x, y] = self
351 .page
352 .viewport
353 .document_point(self.device(point))
354 .map(f64::from);
355 let distance = |rect: &BoundingBox| {
356 let dx = (rect.x0 - x).max(x - rect.x1).max(0.0);
357 let dy = (rect.y0 - y).max(y - rect.y1).max(0.0);
358 dx.hypot(dy)
359 };
360 let rect = self
286361 .page
287362 .editor
288 .active_outline()
289 .document()
290 .slice(range)?
291 .iter()
292 .map(|paragraph| paragraph.text())
293 .collect::<Vec<_>>()
294 .join("\n"))
363 .visible_outlines()
364 .map(TextOutline::bounds)
365 .min_by(|a, b| distance(a).total_cmp(&distance(b)))?;
366 Some([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32 * POINT))
367 }
368
369 fn active(&self) -> &TextOutline {
370 self.page.editor.active_outline()
371 }
372
373 fn text(&self, range: [u32; 2]) -> String {
374 let units: Vec<u16> = self.active().shown_text().encode_utf16().collect();
375 let [start, end] = range.map(|offset| (offset as usize).min(units.len()));
376 String::from_utf16_lossy(&units[start..end.max(start)])
377 }
378
379 fn length(&self) -> u32 {
380 self.active().shown_text().encode_utf16().count() as u32
295381 }
296382
297383 fn selection(&self) -> Result<[u32; 2]> {
298384 let [anchor, focus] = self.page.editor.selection().positions;
299 Ok([self.flat(anchor.min(focus))?, self.flat(anchor.max(focus))?])
385 let outline = self.active();
386 Ok([
387 outline.utf16_offset(anchor.min(focus))?,
388 outline.utf16_offset(anchor.max(focus))?,
389 ])
390 }
391
392 fn positions(&self, range: [u32; 2]) -> Result<[TextPosition; 2]> {
393 let outline = self.active();
394 Ok([
395 outline.utf16_position(range[0])?,
396 outline.utf16_position(range[1])?,
397 ])
300398 }
301399
302400 fn select(&mut self, range: [u32; 2]) -> Result<()> {
303 let positions = [self.position(range[0])?, self.position(range[1])?];
401 let positions = self.positions(range)?;
304402 Ok(self.page.editor.select(Selection::from(positions))?)
305403 }
306404
307405 fn marked(&self) -> Result<Option<[u32; 2]>> {
406 let outline = self.active();
308407 self.page
309408 .editor
310409 .marked_range()
311 .map(|range| Ok([self.flat(range.start)?, self.flat(range.end)?]))
410 .map(|range| {
411 Ok([
412 outline.utf16_offset(range.start)?,
413 outline.utf16_offset(range.end)?,
414 ])
415 })
312416 .transpose()
313417 }
314418
......@@ -325,23 +429,25 @@ impl View {
325429 .map_or(text.len(), |(byte, _)| byte)
326430 };
327431 let cursor = (byte(selected[0]), byte(selected[1]));
328 Ok(moved(self.page.compose(text, Some(cursor))?))
432 self.hold(|page| Ok(moved(page.compose(text, Some(cursor))?)))
329433 }
330434
331435 fn insert(&mut self, text: String) -> Result<bool> {
332 let response = if text == "\n" {
333 self.page.key(&Key::Named(NamedKey::Enter), None)?
334 } else {
335 self.page.commit_text(text)?
336 };
337 Ok(moved(response))
436 self.hold(|page| {
437 Ok(moved(if text == "\n" {
438 page.key(&Key::Named(NamedKey::Enter), None)?
439 } else {
440 page.commit_text(text)?
441 }))
442 })
338443 }
339444
340 /// A document rectangle in the active outline, in the view's points as `[x, y, width,
341 /// height]`.
342 fn view_rect(&self, rect: BoundingBox, origin: [f32; 2]) -> [f32; 4] {
445 /// A rectangle in the active outline's coordinates, in the view's points as `[x, y,
446 /// width, height]`.
447 fn view_rect(&self, rect: BoundingBox) -> [f32; 4] {
343448 let viewport = self.page.viewport;
344449 let points = self.display_scale();
450 let origin = self.active().origin();
345451 let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]];
346452 [
347453 (x * viewport.scale + viewport.origin[0]) / points,
......@@ -351,88 +457,135 @@ impl View {
351457 ]
352458 }
353459
354 fn caret_rect(&self, flat: u32) -> Result<[f32; 4]> {
355 let position = self.position(flat)?;
356 let outline = self.page.editor.active_outline();
357 let paragraph = outline.paragraph_layout(position.paragraph)?;
358 let visible = paragraph.projection.visible_offset(position.offset)?;
359 let byte = paragraph.projection.text().byte_offset(visible)?;
360 let caret = paragraph
361 .text
362 .caret(paragraph.text.cursor(byte, Affinity::Downstream), 1.0);
363 let origin = outline.origin();
364 Ok(self.view_rect(
365 caret,
366 [
367 origin[0] + paragraph.origin[0],
368 origin[1] + paragraph.origin[1],
369 ],
370 ))
460 fn caret_rect(&self, offset: u32) -> Result<[f32; 4]> {
461 let position = self.active().utf16_position(offset)?;
462 let caret = self
463 .page
464 .editor
465 .caret_at(position, Affinity::Downstream, 1.0)?;
466 Ok(self.view_rect(caret))
371467 }
372468
373469 /// The selection rectangles of a range, one or more per line.
374470 fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> {
375 let [start, end] = [self.position(range[0])?, self.position(range[1])?];
376 let outline = self.page.editor.active_outline();
377 let origin = outline.origin();
378 let mut rects = Vec::new();
379 for (index, paragraph) in outline.layouts() {
380 if index < start.paragraph || index > end.paragraph {
381 continue;
382 }
383 let cursor = |offset: u32, affinity| -> Result<_> {
384 let visible = paragraph.projection.visible_offset(offset)?;
385 let byte = paragraph.projection.text().byte_offset(visible)?;
386 Ok(paragraph.text.cursor(byte, affinity))
387 };
388 let first = cursor(
389 if index == start.paragraph {
390 start.offset
391 } else {
392 0
393 },
394 Affinity::Downstream,
395 )?;
396 let last = if index == end.paragraph {
397 cursor(end.offset, Affinity::Upstream)?
398 } else {
399 paragraph
400 .text
401 .cursor(paragraph.projection.text().text().len(), Affinity::Upstream)
402 };
403 let origin = [
404 origin[0] + paragraph.origin[0],
405 origin[1] + paragraph.origin[1],
406 ];
407 for rect in paragraph
408 .text
409 .selection(parley::editing::Selection::new(first, last))
410 {
411 rects.push(self.view_rect(rect, origin));
412 }
413 }
414 Ok(rects)
471 let rects = self.page.editor.range_rects(Selection {
472 positions: self.positions(range)?,
473 affinities: [Affinity::Downstream, Affinity::Upstream],
474 })?;
475 Ok(rects.into_iter().map(|rect| self.view_rect(rect)).collect())
415476 }
416477
417478 fn closest(&self, point: [f32; 2]) -> Result<u32> {
418479 let point = self.page.viewport.document_point(self.device(point));
419 let origin = self.page.editor.active_outline().origin();
480 let origin = self.active().origin();
420481 let selection = self.page.editor.selection_at(
421482 point[0] - origin[0],
422483 point[1] - origin[1],
423484 SelectionUnit::Grapheme,
424485 )?;
425 self.flat(selection.positions[1])
486 Ok(self.active().utf16_offset(selection.positions[1])?)
487 }
488}
489
490pub struct View {
491 canvas: Canvas,
492 surface: wgpu::Surface<'static>,
493 config: wgpu::SurfaceConfiguration,
494 renderer: draw::Renderer,
495 frame: Arc<Frame>,
496}
497
498impl View {
499 fn new(layer: *mut c_void, page: (ExGuid, Page), size: [f32; 2], scale: f32) -> Result<Self> {
500 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
501 // SAFETY: the host passes a live CAMetalLayer that outlives the view.
502 let surface = unsafe {
503 instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer))
504 }?;
505 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
506 compatible_surface: Some(&surface),
507 ..Default::default()
508 }))?;
509 // The simulator's adapter falls short of wgpu's default limits.
510 let (device, queue) =
511 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
512 required_limits: adapter.limits(),
513 ..Default::default()
514 }))?;
515 let pixels = size.map(|side| (side * scale).round().max(1.0) as u32);
516 let config = surface
517 .get_default_config(&adapter, pixels[0], pixels[1])
518 .ok_or("No supported surface configuration")?;
519 surface.configure(&device, &config);
520 let renderer = draw::Renderer::new(device, queue, config.format);
521 Ok(Self {
522 canvas: Canvas::new(page, pixels, scale)?,
523 surface,
524 config,
525 renderer,
526 frame: Arc::default(),
527 })
528 }
529
530 fn render(&mut self) -> Result<()> {
531 let frame = match self.surface.get_current_texture() {
532 wgpu::CurrentSurfaceTexture::Success(frame)
533 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
534 wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
535 self.surface.configure(&self.renderer.device, &self.config);
536 return Ok(());
537 }
538 _ => return Ok(()),
539 };
540 let canvas = &mut self.canvas;
541 canvas
542 .page
543 .update_pictures(canvas.paper, &Waker::from(self.frame.clone()));
544 let primitives = canvas.page.primitives(canvas.colors())?;
545 self.renderer
546 .draw(
547 &frame.texture.create_view(&Default::default()),
548 [self.config.width, self.config.height],
549 canvas.paper.color,
550 &[canvas.page.viewport.layer(&primitives)],
551 )
552 .map_err(|error| format!("Page drawing failed: {error:?}"))?;
553 self.renderer.queue.present(frame);
554 Ok(())
426555 }
427556}
428557
558fn string(text: *const c_char) -> String {
559 // SAFETY: every caller's contract makes `text` NUL-terminated.
560 unsafe { CStr::from_ptr(text) }
561 .to_string_lossy()
562 .into_owned()
563}
564
565fn owned(text: String) -> *mut c_char {
566 CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw)
567}
568
569/// The notebook folder at `path`, or the lone section file there, as JSON: `name`, and
570/// `sections` in the notebook's order, each with `name`, `path`, `group`, `color` as sRGB
571/// bytes and `readable`. Freed with `sb_string_free`; null if it cannot be read.
572///
429573/// # Safety
430/// `path` is a NUL-terminated path.
574/// `path` is NUL-terminated UTF-8.
575#[unsafe(no_mangle)]
576pub unsafe extern "C" fn sb_notebook(path: *const c_char) -> *mut c_char {
577 report(
578 listing(Path::new(&string(path))).and_then(|listing| Ok(serde_json::to_string(&listing)?)),
579 )
580 .map_or(std::ptr::null_mut(), owned)
581}
582
583/// # Safety
584/// `path` is NUL-terminated UTF-8.
431585#[unsafe(no_mangle)]
432586pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section {
433 let path = unsafe { CStr::from_ptr(path) };
434587 report(
435 std::fs::read(path.to_str().unwrap_or_default())
588 std::fs::read(string(path))
436589 .map_err(Into::into)
437590 .and_then(|bytes| Section::open(&bytes)),
438591 )
......@@ -449,7 +602,13 @@ pub extern "C" fn sb_section_count(section: &Section) -> usize {
449602/// The page's title, alive as long as the section.
450603#[unsafe(no_mangle)]
451604pub extern "C" fn sb_section_title(section: &Section, index: usize) -> *const c_char {
452 section.titles[index].as_ptr()
605 section.headings[index].0.as_ptr()
606}
607
608/// The page's level in the page list: 1 at the top, 2 for a subpage and so on.
609#[unsafe(no_mangle)]
610pub extern "C" fn sb_section_level(section: &Section, index: usize) -> u32 {
611 section.headings[index].1
453612}
454613
455614/// # Safety
......@@ -492,10 +651,11 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) {
492651#[unsafe(no_mangle)]
493652pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) {
494653 let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32);
495 if scale != view.display_scale() {
496 let _ = report(view.page.scale_factor_changed(scale));
654 if scale != view.canvas.display_scale() {
655 let _ = report(view.canvas.page.scale_factor_changed(scale));
497656 }
498 let _ = report(view.page.resized(pixels));
657 let page = &mut view.canvas.page;
658 let _ = report(page.resized(pixels));
499659 view.config.width = pixels[0];
500660 view.config.height = pixels[1];
501661 view.surface.configure(&view.renderer.device, &view.config);
......@@ -512,42 +672,96 @@ pub extern "C" fn sb_view_frame_pending(view: &View) -> bool {
512672 view.frame.0.swap(false, Ordering::Acquire)
513673}
514674
675/// Draws the page on the desktop's dark paper, or on white.
676#[unsafe(no_mangle)]
677pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) {
678 view.canvas.set_dark(dark);
679}
680
681/// Whether the view has input focus: the focused outline shows its frame and grips only then.
682#[unsafe(no_mangle)]
683pub extern "C" fn sb_view_focus(view: &mut View, focused: bool) {
684 let _ = report(view.canvas.hold(|page| page.focus_changed(focused)));
685}
686
515687/// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right
516688/// including the margin OneNote scrolls past the last object.
517689#[unsafe(no_mangle)]
518690pub extern "C" fn sb_view_content(view: &mut View, bounds: &mut [f32; 4]) {
519 *bounds = view.content().map(|value| value * POINT);
691 *bounds = view.canvas.content().map(|value| value * POINT);
692}
693
694/// The frame of the outline nearest the view point `x`, `y`, as `[left, top, right,
695/// bottom]` in points at 100% zoom; false on a page with none.
696#[unsafe(no_mangle)]
697pub extern "C" fn sb_view_block(view: &View, x: f32, y: f32, rect: &mut [f32; 4]) -> bool {
698 view.canvas
699 .block([x, y])
700 .map(|block| *rect = block)
701 .is_some()
520702}
521703
522704/// Zooms and scrolls so the page point `x`, `y` sits at the view's corner; the host's
523705/// scroll view owns the limits and rubber-banding.
524706#[unsafe(no_mangle)]
525707pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f32) {
526 view.set_transform(zoom, [x, y]);
708 view.canvas.set_transform(zoom, [x, y]);
527709}
528710
529/// A tap: places the caret, focuses an outline or starts a new one.
711/// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1
712/// for the text taking input; 2 for another outline's text; 3 for the focused outline's
713/// move or width grip.
530714#[unsafe(no_mangle)]
531pub extern "C" fn sb_view_tap(view: &mut View, x: f32, y: f32) -> bool {
532 report(view.tap([x, y])).unwrap_or(false)
715pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {
716 view.canvas.target([x, y]) as u8
533717}
534718
719/// A finger down: with `sb_view_release` a tap, which places the caret, focuses an outline
720/// or starts a new one; with `sb_view_drag` between, a grip moves or widens an outline.
535721#[unsafe(no_mangle)]
536pub extern "C" fn sb_view_in_active_text(view: &View, x: f32, y: f32) -> bool {
537 view.in_active_text([x, y])
722pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {
723 report(view.canvas.press([x, y])).unwrap_or(false)
538724}
539725
540726#[unsafe(no_mangle)]
541pub extern "C" fn sb_text_length(view: &View) -> u32 {
542 report(view.length()).unwrap_or(0)
727pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool {
728 report(view.canvas.drag([x, y])).unwrap_or(false)
543729}
544730
545/// The text in `start..end`, freed with `sb_string_free`.
546731#[unsafe(no_mangle)]
547pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char {
548 report(view.text([start, end]))
549 .and_then(|text| CString::new(text.replace('\0', "")).ok())
550 .map_or(std::ptr::null_mut(), CString::into_raw)
732pub extern "C" fn sb_view_release(view: &mut View) -> bool {
733 report(view.canvas.release()).unwrap_or(false)
734}
735
736/// Undoes the last edit, or with `redo` redoes the last undone one.
737#[unsafe(no_mangle)]
738pub extern "C" fn sb_view_undo(view: &mut View, redo: bool) -> bool {
739 report(view.canvas.hold(|page| page.undo(redo).map(moved))).unwrap_or(false)
740}
741
742#[unsafe(no_mangle)]
743pub extern "C" fn sb_view_can_undo(view: &View, redo: bool) -> bool {
744 let editor = &view.canvas.page.editor;
745 if redo {
746 editor.can_redo()
747 } else {
748 editor.can_undo()
749 }
750}
751
752/// Toggles 0 bold, 1 italic or 2 underline on the selection.
753#[unsafe(no_mangle)]
754pub extern "C" fn sb_view_toggle(view: &mut View, toggle: u8) -> bool {
755 let toggle = match toggle {
756 0 => Toggle::Bold,
757 1 => Toggle::Italic,
758 _ => Toggle::Underline,
759 };
760 report(
761 view.canvas
762 .hold(|page| page.format(Formatting::Toggle(toggle)).map(moved)),
763 )
764 .unwrap_or(false)
551765}
552766
553767/// The edit the page took since the last call, as `onestore::op::Edit` JSON for the
......@@ -555,34 +769,45 @@ pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char {
555769/// stored, in which case the page should be opened again.
556770#[unsafe(no_mangle)]
557771pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char {
558 report(view.edit())
772 report(view.canvas.edit())
559773 .flatten()
560 .and_then(|edit| CString::new(serde_json::to_string(&edit).ok()?).ok())
561 .map_or(std::ptr::null_mut(), CString::into_raw)
774 .and_then(|edit| serde_json::to_string(&edit).ok())
775 .map_or(std::ptr::null_mut(), owned)
562776}
563777
564778/// # Safety
565/// `text` came from `sb_text` or `sb_view_edit` and is not used again.
779/// `text` came from this library and is not used again.
566780#[unsafe(no_mangle)]
567781pub unsafe extern "C" fn sb_string_free(text: *mut c_char) {
568782 drop(unsafe { CString::from_raw(text) });
569783}
570784
785#[unsafe(no_mangle)]
786pub extern "C" fn sb_text_length(view: &View) -> u32 {
787 view.canvas.length()
788}
789
790/// The text in `start..end`, freed with `sb_string_free`.
791#[unsafe(no_mangle)]
792pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char {
793 owned(view.canvas.text([start, end]))
794}
795
571796#[unsafe(no_mangle)]
572797pub extern "C" fn sb_selection(view: &View, range: &mut [u32; 2]) {
573 if let Some(selection) = report(view.selection()) {
798 if let Some(selection) = report(view.canvas.selection()) {
574799 *range = selection;
575800 }
576801}
577802
578803#[unsafe(no_mangle)]
579804pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool {
580 report(view.select([start, end])).is_some()
805 report(view.canvas.select([start, end])).is_some()
581806}
582807
583808#[unsafe(no_mangle)]
584809pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool {
585 report(view.marked())
810 report(view.canvas.marked())
586811 .flatten()
587812 .map(|marked| *range = marked)
588813 .is_some()
......@@ -597,15 +822,16 @@ pub unsafe extern "C" fn sb_set_marked(
597822 selected_start: u32,
598823 selected_end: u32,
599824) -> bool {
600 let text = unsafe { CStr::from_ptr(text) }
601 .to_string_lossy()
602 .into_owned();
603 report(view.set_marked(text, [selected_start, selected_end])).unwrap_or(false)
825 report(
826 view.canvas
827 .set_marked(string(text), [selected_start, selected_end]),
828 )
829 .unwrap_or(false)
604830}
605831
606832#[unsafe(no_mangle)]
607833pub extern "C" fn sb_unmark(view: &mut View) {
608 view.page.editor.finish_composition();
834 view.canvas.page.editor.finish_composition();
609835}
610836
611837/// Types `text`, a lone `\n` as the Return key.
......@@ -614,13 +840,10 @@ pub extern "C" fn sb_unmark(view: &mut View) {
614840/// `text` is NUL-terminated UTF-8.
615841#[unsafe(no_mangle)]
616842pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool {
617 let text = unsafe { CStr::from_ptr(text) }
618 .to_string_lossy()
619 .into_owned();
620 report(view.insert(text)).unwrap_or(false)
843 report(view.canvas.insert(string(text))).unwrap_or(false)
621844}
622845
623/// Replaces `start..end` with `text`, as autocorrection and dictation do.
846/// Replaces `start..end` with `text`, as autocorrection, dictation and cutting do.
624847///
625848/// # Safety
626849/// `text` is NUL-terminated UTF-8.
......@@ -631,12 +854,11 @@ pub unsafe extern "C" fn sb_replace(
631854 end: u32,
632855 text: *const c_char,
633856) -> bool {
634 let text = unsafe { CStr::from_ptr(text) }
635 .to_string_lossy()
636 .into_owned();
857 let canvas = &mut view.canvas;
637858 report(
638 view.select([start, end])
639 .and_then(|()| Ok(moved(view.page.commit_text(text)?))),
859 canvas
860 .select([start, end])
861 .and_then(|()| canvas.hold(|page| Ok(moved(page.commit_text(string(text))?)))),
640862 )
641863 .unwrap_or(false)
642864}
......@@ -651,12 +873,10 @@ pub unsafe extern "C" fn sb_paste(
651873 text: *const c_char,
652874 language: *const c_char,
653875) -> bool {
654 let text = unsafe { CStr::from_ptr(text) }.to_string_lossy();
655 let language = unsafe { CStr::from_ptr(language) }.to_string_lossy();
876 let (text, language) = (string(text), canvas::language::lcid(&string(language)));
656877 report(
657 view.page
658 .paste(&text, canvas::language::lcid(&language))
659 .map(moved),
878 view.canvas
879 .hold(|page| page.paste(&text, language).map(moved)),
660880 )
661881 .unwrap_or(false)
662882}
......@@ -664,9 +884,8 @@ pub unsafe extern "C" fn sb_paste(
664884#[unsafe(no_mangle)]
665885pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {
666886 report(
667 view.page
668 .key(&Key::Named(NamedKey::Backspace), None)
669 .map(moved),
887 view.canvas
888 .hold(|page| page.key(&Key::Named(NamedKey::Backspace), None).map(moved)),
670889 )
671890 .unwrap_or(false)
672891}
......@@ -674,7 +893,7 @@ pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {
674893/// The caret at `offset` as `[x, y, width, height]` in the view's points.
675894#[unsafe(no_mangle)]
676895pub extern "C" fn sb_caret_rect(view: &View, offset: u32, rect: &mut [f32; 4]) -> bool {
677 report(view.caret_rect(offset))
896 report(view.canvas.caret_rect(offset))
678897 .map(|caret| *rect = caret)
679898 .is_some()
680899}
......@@ -691,7 +910,7 @@ pub unsafe extern "C" fn sb_range_rects(
691910 rects: *mut [f32; 4],
692911 capacity: usize,
693912) -> usize {
694 let found = report(view.range_rects([start, end])).unwrap_or_default();
913 let found = report(view.canvas.range_rects([start, end])).unwrap_or_default();
695914 for (index, rect) in found.iter().take(capacity).enumerate() {
696915 unsafe { rects.add(index).write(*rect) };
697916 }
......@@ -700,23 +919,8 @@ pub unsafe extern "C" fn sb_range_rects(
700919
701920#[unsafe(no_mangle)]
702921pub extern "C" fn sb_closest(view: &View, x: f32, y: f32) -> u32 {
703 report(view.closest([x, y])).unwrap_or(0)
922 report(view.canvas.closest([x, y])).unwrap_or(0)
704923}
705924
706925#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn corpus_section_lists_its_pages() {
712 let path = concat!(
713 env!("CARGO_MANIFEST_DIR"),
714 "/../../corpus/media-edit/candidate/Features.one"
715 );
716 let section = Section::open(&std::fs::read(path).unwrap()).unwrap();
717 for title in &section.titles {
718 eprintln!("{title:?}");
719 }
720 assert!(!section.pages.is_empty());
721 }
722}
926mod tests;
crates/mobile/src/tests.rs created+160
......@@ -0,0 +1,160 @@
1use super::*;
2
3const FEATURES: &str = concat!(
4 env!("CARGO_MANIFEST_DIR"),
5 "/../../corpus/media-edit/candidate/Features.one"
6);
7
8/// A page of the corpus section on a 402 × 874 point phone at 3 pixels per point, at 100%
9/// with the page origin at the view's corner.
10fn canvas(title: &str) -> Canvas {
11 let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap();
12 let index = section
13 .headings
14 .iter()
15 .position(|(heading, _)| heading.to_str().unwrap() == title)
16 .unwrap();
17 let mut canvas = Canvas::new(section.pages[index].clone(), [1206, 2622], 3.0).unwrap();
18 canvas.set_transform(1.0, [0.0; 2]);
19 canvas
20}
21
22/// A view point on the outline holding `text`, `fraction` of the way along its first line.
23fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] {
24 let outline = canvas
25 .page
26 .editor
27 .visible_outlines()
28 .find(|outline| outline.shown_text().contains(text))
29 .unwrap();
30 let bounds = outline.bounds();
31 let x = bounds.x0 as f32 + (bounds.x1 - bounds.x0) as f32 * fraction;
32 let y = bounds.y0 as f32 + 6.0;
33 [x * POINT, y * POINT]
34}
35
36#[test]
37fn the_section_lists_titles_and_levels() {
38 let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap();
39 assert!(
40 section
41 .headings
42 .iter()
43 .any(|(title, _)| title.to_str() == Ok("Paragraph controls"))
44 );
45 assert!(section.headings.iter().all(|(_, level)| *level >= 1));
46}
47
48#[test]
49fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
50 let root = concat!(
51 env!("CARGO_MANIFEST_DIR"),
52 "/../../corpus/m6/native-features-01/notebook"
53 );
54 let notebook = listing(Path::new(root)).unwrap();
55 let names: Vec<_> = notebook
56 .sections
57 .iter()
58 .map(|tab| (tab.group.as_str(), tab.name.as_str()))
59 .collect();
60 eprintln!("{names:?}");
61 assert!(names.contains(&("", "Features")));
62 assert!(notebook.sections.iter().any(|tab| !tab.group.is_empty()));
63 let lone = listing(Path::new(FEATURES)).unwrap();
64 assert_eq!(lone.sections.len(), 1);
65}
66
67#[test]
68fn touches_route_to_text_the_page_and_the_focused_outlines_grip() {
69 let mut canvas = canvas("Paragraph controls");
70 let text = point_on(&canvas, "Collapsed parent", 0.1);
71 let first = canvas.target(text);
72 assert!(
73 matches!(first, Target::ActiveText | Target::Text),
74 "{first:?}"
75 );
76 assert!(canvas.press(text).unwrap());
77 canvas.release().unwrap();
78 assert_eq!(canvas.target(text), Target::ActiveText);
79 // The grip strip sits above the text; a finger reaches it from 16 points higher still.
80 let bounds = canvas.active().bounds();
81 let above = [
82 (bounds.x0 as f32 + 40.0) * POINT,
83 (bounds.y0 as f32 - 12.0) * POINT - 10.0,
84 ];
85 assert_eq!(canvas.target(above), Target::Grip);
86 // Unfocused, the grips hide and a touch there reaches the page.
87 let _ = canvas.page.focus_changed(false).unwrap();
88 assert_eq!(canvas.target(above), Target::Page);
89}
90
91#[test]
92fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() {
93 let mut canvas = canvas("Paragraph controls");
94 let text = point_on(&canvas, "Collapsed parent", 0.1);
95 canvas.press(text).unwrap();
96 canvas.release().unwrap();
97 canvas.edit().unwrap();
98 let before = canvas.active().origin();
99 let grip = [(before[0] + 40.0) * POINT, (before[1] - 8.0) * POINT];
100 assert_eq!(canvas.target(grip), Target::Grip);
101 canvas.press(grip).unwrap();
102 for step in 1..=10 {
103 let step = step as f32;
104 canvas
105 .drag([grip[0] + step * 10.0, grip[1] + step * 7.0])
106 .unwrap();
107 }
108 canvas.release().unwrap();
109 let after = canvas.active().origin();
110 let margin = canvas.page.editor.margin_origin();
111 assert_ne!(after, before);
112 for axis in 0..2 {
113 let cells = (after[axis] - margin[axis]) / 18.0;
114 assert!(
115 (cells - cells.round()).abs() < 1e-3,
116 "{after:?} off the grid"
117 );
118 }
119 assert!(canvas.edit().unwrap().is_some());
120 assert!(canvas.page.editor.can_undo());
121}
122
123#[test]
124fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {
125 let mut canvas = canvas("Paragraph controls");
126 let text = point_on(&canvas, "Collapsed parent", 0.1);
127 canvas.press(text).unwrap();
128 canvas.release().unwrap();
129 let shown = canvas.active().shown_text();
130 let length = canvas.length();
131 assert_eq!(length as usize, shown.encode_utf16().count());
132 assert_eq!(canvas.text([0, length]), shown);
133 let start = shown.find("Collapsed").unwrap() as u32;
134 canvas.select([start, start + 9]).unwrap();
135 assert_eq!(canvas.selection().unwrap(), [start, start + 9]);
136 assert!(!canvas.range_rects([start, start + 9]).unwrap().is_empty());
137 let caret = canvas.caret_rect(start).unwrap();
138 let closest = canvas
139 .closest([caret[0] + 1.0, caret[1] + caret[3] / 2.0])
140 .unwrap();
141 assert!(closest.abs_diff(start) <= 1, "{closest} for {start}");
142 // Every offset has a caret, including those around the collapsed children.
143 for offset in 0..=length {
144 canvas.caret_rect(offset).unwrap();
145 }
146}
147
148#[test]
149fn undo_restores_typing() {
150 let mut canvas = canvas("Paragraph controls");
151 let text = point_on(&canvas, "Collapsed parent", 0.1);
152 canvas.press(text).unwrap();
153 canvas.release().unwrap();
154 let before = canvas.active().shown_text();
155 canvas.insert("hi".into()).unwrap();
156 assert_ne!(canvas.active().shown_text(), before);
157 assert!(moved(canvas.page.undo(false).unwrap()));
158 assert_eq!(canvas.active().shown_text(), before);
159 assert!(canvas.page.editor.can_redo());
160}