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 @@...@@ -7,12 +7,12 @@
7 objects = {7 objects = {
88
9/* Begin PBXBuildFile section */9/* 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 */; };
11/* End PBXBuildFile section */11/* End PBXBuildFile section */
1212
13/* Begin PBXFileReference section */13/* Begin PBXFileReference section */
14 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; };14 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; };
15 5B0000000000000000000201 /* Features.one */ = {isa = PBXFileReference; lastKnownFileType = file; name = Features.one; path = "../../corpus/media-edit/candidate/Features.one"; sourceTree = SOURCE_ROOT; };15 5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; };
16 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };16 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
17 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; };17 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; };
18 5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; };18 5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; };
...@@ -41,7 +41,7 @@...@@ -41,7 +41,7 @@
41 isa = PBXGroup;41 isa = PBXGroup;
42 children = (42 children = (
43 5B0000000000000000000300 /* Snowbound */,43 5B0000000000000000000300 /* Snowbound */,
44 5B0000000000000000000201 /* Features.one */,44 5B0000000000000000000201 /* Sample */,
45 5B0000000000000000000202 /* Info.plist */,45 5B0000000000000000000202 /* Info.plist */,
46 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */,46 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */,
47 5B0000000000000000000204 /* build-rust.sh */,47 5B0000000000000000000204 /* build-rust.sh */,
...@@ -120,7 +120,7 @@...@@ -120,7 +120,7 @@
120 isa = PBXResourcesBuildPhase;120 isa = PBXResourcesBuildPhase;
121 buildActionMask = 2147483647;121 buildActionMask = 2147483647;
122 files = (122 files = (
123 5B0000000000000000000101 /* Features.one in Resources */,123 5B0000000000000000000101 /* Sample in Resources */,
124 );124 );
125 runOnlyForDeploymentPostprocessing = 0;125 runOnlyForDeploymentPostprocessing = 0;
126 };126 };
apps/ios/Snowbound/App.swift+254-42
...@@ -14,117 +14,329 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {...@@ -14,117 +14,329 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
14 }14 }
15}15}
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 {
18 var window: UIWindow?20 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
20 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {26 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
21 guard let scene = scene as? UIWindowScene else { return }27 guard let scene = scene as? UIWindowScene else { return }
22 let window = UIWindow(windowScene: scene)28 let window = UIWindow(windowScene: scene)
23 let path = ProcessInfo.processInfo.environment["SNOWBOUND_SECTION"]29 split.delegate = self
24 ?? Bundle.main.path(forResource: "Features", ofType: "one")!30 split.preferredDisplayMode = .oneBesideSecondary
25 let pages = PagesViewController(section: Section(path: path))31 split.preferredSplitBehavior = .tile
26 window.rootViewController = UINavigationController(rootViewController: pages)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
27 window.makeKeyAndVisible()38 window.makeKeyAndVisible()
28 self.window = window39 self.window = window
29 if let page = ProcessInfo.processInfo.environment["SNOWBOUND_PAGE"].flatMap(Int.init) {40 sections.load(Library.opening())
30 pages.show(page: page, animated: false)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 }
31 }54 }
32 }55 }
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 }
33}132}
34133
35/// A parsed .one section; pages are read-only copies until saving lands.134/// A parsed .one section; pages are read-only copies until saving lands.
36final class Section {135final class Section {
37 let handle: OpaquePointer?136 let handle: OpaquePointer?
38 let name: String137 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) {142 init(_ tab: Tab) {
41 handle = sb_section_open(path)143 handle = sb_section_open(tab.path)
42 name = (path as NSString).lastPathComponent.replacingOccurrences(of: ".one", with: "")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 } ?? []
43 }151 }
44152
45 deinit { if let handle { sb_section_free(handle) } }153 deinit { if let handle { sb_section_free(handle) } }
46
47 var titles: [String] {
48 guard let handle else { return [] }
49 return (0..<sb_section_count(handle)).map { String(cString: sb_section_title(handle, $0)) }
50 }
51}154}
52155
53final class PagesViewController: UITableViewController, UIDocumentPickerDelegate {156final class SectionsViewController: UITableViewController, UIDocumentPickerDelegate {
54 private var section: Section157 private(set) var notebook: Notebook?
55 private var titles: [String]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) {162 init() {
58 self.section = section
59 titles = section.titles
60 super.init(style: .insetGrouped)163 super.init(style: .insetGrouped)
61 title = section.name164 navigationItem.largeTitleDisplayMode = .always
62 }165 }
63166
64 required init?(coder: NSCoder) { fatalError() }167 required init?(coder: NSCoder) { fatalError() }
65168
66 override func viewDidLoad() {169 override func viewDidLoad() {
67 super.viewDidLoad()170 super.viewDidLoad()
68 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")171 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "section")
69 navigationItem.rightBarButtonItem = UIBarButtonItem(172 let open = UIBarButtonItem(
70 title: "Open", image: UIImage(systemName: "folder"), target: self, action: #selector(open))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
71 }199 }
72200
73 @objc private func open() {201 @objc private func open() {
74 let picker = UIDocumentPickerViewController(202 let picker = UIDocumentPickerViewController(
75 forOpeningContentTypes: [UTType(filenameExtension: "one") ?? .data], asCopy: true)203 forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])
76 picker.delegate = self204 picker.delegate = self
77 present(picker, animated: true)205 present(picker, animated: true)
78 }206 }
79207
80 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {208 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
81 guard let url = urls.first else { return }209 guard let url = urls.first else { return }
82 section = Section(path: url.path)210 guard let notebook = Library.open(url) else {
83 titles = section.titles211 let alert = UIAlertController(
84 title = section.name212 title: "Can’t Open Notebook", message: "Choose a OneNote notebook folder or a section file.",
85 tableView.reloadData()213 preferredStyle: .alert)
214 alert.addAction(UIAlertAction(title: "OK", style: .default))
215 present(alert, animated: true)
216 return
217 }
218 load(notebook)
86 }219 }
87220
88 func show(page: Int, animated: Bool) {221 override func numberOfSections(in tableView: UITableView) -> Int { groups.count }
89 guard page < titles.count else { return }222
90 navigationController?.pushViewController(223 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
91 PageViewController(section: section, page: page, title: titles[page]), animated: animated)224 groups[section].name.isEmpty ? nil : groups[section].name.replacingOccurrences(of: "/", with: " › ")
92 }225 }
93226
94 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {227 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
95 titles.count228 groups[section].sections.count
96 }229 }
97230
98 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {231 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]
99 let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath)288 let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath)
100 var content = cell.defaultContentConfiguration()289 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
102 cell.contentConfiguration = content294 cell.contentConfiguration = content
103 cell.accessoryType = .disclosureIndicator
104 return cell295 return cell
105 }296 }
106297
107 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {298 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
108 tableView.deselectRow(at: indexPath, animated: true)299 guard let section else { return }
109 show(page: indexPath.row, animated: true)300 onOpen?(section, indexPath.row)
110 }301 }
111}302}
112303
113final class PageViewController: UIViewController {304final class PageViewController: UIViewController {
114 private let section: Section305 private let section: Section
115 private let page: Int306 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) {
118 self.section = section316 self.section = section
119 self.page = page317 self.page = page
120 super.init(nibName: nil, bundle: nil)318 super.init(nibName: nil, bundle: nil)
121 self.title = title319 title = section.headings[page].title
122 navigationItem.largeTitleDisplayMode = .never320 navigationItem.largeTitleDisplayMode = .never
123 }321 }
124322
125 required init?(coder: NSCoder) { fatalError() }323 required init?(coder: NSCoder) { fatalError() }
126324
127 override func loadView() {325 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 }
129 }341 }
130}342}
apps/ios/Snowbound/CanvasView.swift+402-35
...@@ -4,7 +4,8 @@ private final class MetalView: UIView {...@@ -4,7 +4,8 @@ private final class MetalView: UIView {
4 override class var layerClass: AnyClass { CAMetalLayer.self }4 override class var layerClass: AnyClass { CAMetalLayer.self }
5}5}
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.
8private final class Position: UITextPosition {9private final class Position: UITextPosition {
9 let value: Int10 let value: Int
10 init(_ value: Int) { self.value = value }11 init(_ value: Int) { self.value = value }
...@@ -37,13 +38,31 @@ private extension UITextRange {...@@ -37,13 +38,31 @@ private extension UITextRange {
37 var upper: Int { (self as! Range).hi }38 var upper: Int { (self as! Range).hi }
38}39}
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
40/// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page56/// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page
41/// drawn into a Metal layer pinned to the visible bounds, and the active outline's text57/// 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,58/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text
43/// selection handles and loupe work on it.59/// interaction, loupe and edit menu work on it.
44final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,60final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
45 UITextSelectionDisplayInteractionDelegate61 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate
46{62{
63 /// The zoom a page opens at once the reader has pinched one.
64 private static let zoomKey = "zoom"
65
47 private let section: Section66 private let section: Section
48 private let page: Int67 private let page: Int
49 private let metal = MetalView()68 private let metal = MetalView()
...@@ -56,38 +75,74 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -56,38 +75,74 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
56 private var origin = CGPoint.zero75 private var origin = CGPoint.zero
57 private var syncing = false76 private var syncing = false
58 private var scrolled = false77 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
59 private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped))83 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)
60 private let interaction = UITextInteraction(for: .editable)99 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.
62 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)102 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)
103 private let history = CanvasUndoManager()
63104
64 weak var inputDelegate: UITextInputDelegate?105 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
67 init(section: Section, page: Int) {110 init(section: Section, page: Int) {
68 self.section = section111 self.section = section
69 self.page = page112 self.page = page
70 super.init(frame: .zero)113 super.init(frame: .zero)
71 backgroundColor = .white114 backgroundColor = .systemBackground
72 delegate = self115 delegate = self
73 minimumZoomScale = 0.25116 minimumZoomScale = 0.25
74 maximumZoomScale = 4117 maximumZoomScale = 4
75 keyboardDismissMode = .interactive118 keyboardDismissMode = .interactive
76 addSubview(content)119 addSubview(content)
77 insertSubview(metal, at: 0)120 insertSubview(metal, at: 0)
121 // Touches land on the scroll view itself, where the text interaction looks for them.
78 metal.isUserInteractionEnabled = false122 metal.isUserInteractionEnabled = false
123 content.isUserInteractionEnabled = false
79 let layer = metal.layer as! CAMetalLayer124 let layer = metal.layer as! CAMetalLayer
80 layer.isOpaque = true125 layer.isOpaque = true
81 // Frames reach the screen with the transaction that moves UIKit's caret and handles.126 // Frames reach the screen with the transaction that moves UIKit's caret and handles.
82 layer.presentsWithTransaction = true127 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)
84 interaction.textInput = self135 interaction.textInput = self
85 interaction.delegate = self136 interaction.delegate = self
86 addInteraction(interaction)137 addInteraction(interaction)
87 addInteraction(display)138 addInteraction(display)
139 display.isActivated = false
140 addInteraction(editMenu)
141 history.canvas = self
88 NotificationCenter.default.addObserver(142 NotificationCenter.default.addObserver(
89 self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification,143 self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification,
90 object: nil)144 object: nil)
145 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() }
91 }146 }
92147
93 required init?(coder: NSCoder) { fatalError() }148 required init?(coder: NSCoder) { fatalError() }
...@@ -102,6 +157,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -102,6 +157,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
102 override func layoutSubviews() {157 override func layoutSubviews() {
103 super.layoutSubviews()158 super.layoutSubviews()
104 metal.frame = bounds159 metal.frame = bounds
160 // Selection views go in at the back, where the opaque page would hide them.
161 sendSubviewToBack(metal)
105 let scale = window?.screen.scale ?? 3162 let scale = window?.screen.scale ?? 3
106 metal.layer.contentsScale = scale163 metal.layer.contentsScale = scale
107 guard bounds.width > 0, bounds.height > 0 else { return }164 guard bounds.width > 0, bounds.height > 0 else { return }
...@@ -116,9 +173,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -116,9 +173,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
116 Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page,173 Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page,
117 Float(bounds.width), Float(bounds.height), Float(scale))174 Float(bounds.width), Float(bounds.height), Float(scale))
118 guard let handle else { return }175 guard let handle else { return }
176 sb_view_focus(handle, false)
177 paper()
119 sync()178 sync()
120 let fit = (bounds.width - safeAreaInsets.left - safeAreaInsets.right) / content.bounds.width179 let skip = margin
121 zoomScale = min(1, max(0.5, fit))180 resting = openingZoom()
181 zoomScale = resting
182 left = skip * resting
122 home()183 home()
123 #if DEBUG184 #if DEBUG
124 runScript()185 runScript()
...@@ -145,6 +206,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -145,6 +206,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
145 sb_view_render(handle)206 sb_view_render(handle)
146 }207 }
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
148 // MARK: Scrolling and zoom216 // MARK: Scrolling and zoom
149217
150 /// Sizes the scroll content to the page's after an edit, keeping the page point at the218 /// 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...@@ -173,7 +241,25 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
173 Float(origin.y + contentOffset.y / zoomScale))241 Float(origin.y + contentOffset.y / zoomScale))
174 metal.frame = bounds242 metal.frame = bounds
175 dirty = true243 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)
177 }263 }
178264
179 /// Until the reader scrolls, the page's corner stays below the navigation bar as its265 /// 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...@@ -184,13 +270,54 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
184 }270 }
185271
186 private func home() {272 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) }
188 }274 }
189275
190 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true }276 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true }
277 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrolled = true }
191 func viewForZooming(in scrollView: UIScrollView) -> UIView? { content }278 func viewForZooming(in scrollView: UIScrollView) -> UIView? { content }
192 func scrollViewDidScroll(_ scrollView: UIScrollView) { transform() }279 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
195 @objc private func keyboardChanged(_ notification: Notification) {322 @objc private func keyboardChanged(_ notification: Notification) {
196 guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,323 guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
...@@ -205,33 +332,125 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -205,33 +332,125 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
205332
206 // MARK: Touch333 // MARK: Touch
207334
208 /// The canvas places the caret, focuses outlines and starts new ones; the system's text335 private func target(_ point: CGPoint) -> Target {
209 /// interaction takes over inside the outline already taking input.336 guard let handle else { return .page }
210 private func inActiveText(_ point: CGPoint) -> Bool {
211 guard let handle else { return false }
212 let local = visible(point)337 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
214 }344 }
215345
216 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {346 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
217 recognizer === tap347 switch recognizer {
218 ? !inActiveText(recognizer.location(in: self)) : super.gestureRecognizerShouldBegin(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 }
219 }355 }
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.
221 func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {359 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 }
223 }376 }
224377
225 @objc private func tapped(_ recognizer: UITapGestureRecognizer) {378 @objc private func tapped(_ recognizer: UITapGestureRecognizer) {
226 tap(at: recognizer.location(in: self))379 tap(at: recognizer.location(in: self))
227 }380 }
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.
230 func tap(at point: CGPoint) {384 func tap(at point: CGPoint) {
385 press(at: point)
386 if !isFirstResponder { _ = becomeFirstResponder() }
387 }
388
389 private func press(at point: CGPoint) {
231 guard let handle else { return }390 guard let handle else { return }
232 let point = visible(point)391 let point = visible(point)
233 edit(external: true) { sb_view_tap(handle, Float(point.x), Float(point.y)) }392 edit(external: true) {
234 if !isFirstResponder { _ = becomeFirstResponder() }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 }
235 }454 }
236455
237 /// Runs a change to the page, telling the system when it did not ask for it.456 /// 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...@@ -246,10 +465,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
246 inputDelegate?.textDidChange(self)465 inputDelegate?.textDidChange(self)
247 inputDelegate?.selectionDidChange(self)466 inputDelegate?.selectionDidChange(self)
248 }467 }
468 dirty = true
249 if changed {469 if changed {
250 sync()470 sync()
251 revealCaret()471 revealCaret()
252 }472 }
473 display.setNeedsSelectionUpdate()
474 onChange?()
253 }475 }
254476
255 /// Takes the edit the page recorded, so its ops do not pile up; the host drops it477 /// 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...@@ -260,33 +482,102 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
260 }482 }
261483
262 private func revealCaret() {484 private func revealCaret() {
263 guard let handle, isFirstResponder else { return }485 guard handle != nil, isFirstResponder, let range = selectedTextRange else { return }
264 var range: [UInt32] = [0, 0]486 let caret = caretRect(for: range.end)
265 sb_selection(handle, &range)
266 let caret = caretRect(for: Position(Int(range[1])))
267 scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false)487 scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false)
268 }488 }
269489
490 // MARK: Responder
491
270 override var canBecomeFirstResponder: Bool { handle != nil }492 override var canBecomeFirstResponder: Bool { handle != nil }
271493
272 override func becomeFirstResponder() -> Bool {494 override func becomeFirstResponder() -> Bool {
273 guard super.becomeFirstResponder() else { return false }495 guard super.becomeFirstResponder() else { return false }
496 if let handle { sb_view_focus(handle, true) }
274 display.isActivated = true497 display.isActivated = true
498 dirty = true
499 onChange?()
275 return true500 return true
276 }501 }
277502
278 override func resignFirstResponder() -> Bool {503 override func resignFirstResponder() -> Bool {
279 guard super.resignFirstResponder() else { return false }504 guard super.resignFirstResponder() else { return false }
505 if let handle { sb_view_focus(handle, false) }
280 display.isActivated = false506 display.isActivated = false
507 dirty = true
508 onChange?()
281 return true509 return true
282 }510 }
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
284 /// Pasted text takes the keyboard's language, as Windows gives the clipboard.554 /// Pasted text takes the keyboard's language, as Windows gives the clipboard.
285 override func paste(_ sender: Any?) {555 override func paste(_ sender: Any?) {
286 guard let handle, let text = UIPasteboard.general.string else { return }556 guard let handle, let text = UIPasteboard.general.string else { return }
287 edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }557 edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }
288 }558 }
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
290 // MARK: Coordinates581 // MARK: Coordinates
291582
292 /// A point in the scroll view's bounds as the canvas view's points from its corner.583 /// 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...@@ -322,9 +613,18 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
322 return String(cString: text)613 return String(cString: text)
323 }614 }
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
325 func replace(_ range: UITextRange, withText text: String) {621 func replace(_ range: UITextRange, withText text: String) {
326 guard let handle else { return }622 edit { replacing(range, with: text) }
327 edit { sb_replace(handle, UInt32(range.lower), UInt32(range.upper), 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))
328 }628 }
329629
330 var selectedTextRange: UITextRange? {630 var selectedTextRange: UITextRange? {
...@@ -335,10 +635,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -335,10 +635,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
335 return Range(Int(range[0]), Int(range[1]))635 return Range(Int(range[0]), Int(range[1]))
336 }636 }
337 set {637 set {
338 guard let handle, let newValue else { return }638 guard let newValue else { return }
339 _ = sb_select(handle, UInt32(newValue.lower), UInt32(newValue.upper))639 _ = choose(newValue)
340 dirty = true
341 display.setNeedsSelectionUpdate()640 display.setNeedsSelectionUpdate()
641 dirty = true
642 onChange?()
342 }643 }
343 }644 }
344645
...@@ -425,14 +726,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -425,14 +726,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
425 var rect: [Float] = [0, 0, 0, 0]726 var rect: [Float] = [0, 0, 0, 0]
426 guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero }727 guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero }
427 var caret = bounded(rect)728 var caret = bounded(rect)
729 caret.origin.x -= 1
428 caret.size.width = 2730 caret.size.width = 2
429 return caret731 return caret
430 }732 }
431733
432 func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {734 func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
433 guard let handle else { return [] }735 guard let handle else { return [] }
434 var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: 64)736 let capacity = 256
435 let count = min(64, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, 64))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))
436 return (0..<count).map {739 return (0..<count).map {
437 let rect = rects[$0]740 let rect = rects[$0]
438 return SelectionRect(741 return SelectionRect(
...@@ -456,3 +759,67 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -456,3 +759,67 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
456 return Range(position.offset, min(position.offset + 1, endOfDocument.offset))759 return Range(position.offset, min(position.offset + 1, endOfDocument.offset))
457 }760 }
458}761}
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 {...@@ -5,8 +5,10 @@ extension CanvasView {
5 /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check5 /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check
6 /// input without a finger: steps joined by `|`, such as6 /// input without a finger: steps joined by `|`, such as
7 /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.7 /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.
8 /// `tap` takes points from the view's corner, `select` text offsets, `scroll` a content8 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
9 /// offset; `shot:a` saves the window to Documents/a.png, as a device has no screenshot command.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.
10 func runScript() {12 func runScript() {
11 guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }13 guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }
12 for (index, step) in script.split(separator: "|").enumerated() {14 for (index, step) in script.split(separator: "|").enumerated() {
...@@ -32,6 +34,9 @@ extension CanvasView {...@@ -32,6 +34,9 @@ extension CanvasView {
32 case "unmark": unmarkText()34 case "unmark": unmarkText()
33 case "select":35 case "select":
34 let values = argument.split(separator: ",").compactMap { Int($0) }36 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) }
35 selectedTextRange = textRange(40 selectedTextRange = textRange(
36 from: position(from: beginningOfDocument, offset: values[0])!,41 from: position(from: beginningOfDocument, offset: values[0])!,
37 to: position(from: beginningOfDocument, offset: values[1])!)42 to: position(from: beginningOfDocument, offset: values[1])!)
...@@ -39,6 +44,14 @@ extension CanvasView {...@@ -39,6 +44,14 @@ extension CanvasView {
39 let values = argument.split(separator: ",").compactMap { Double($0) }44 let values = argument.split(separator: ",").compactMap { Double($0) }
40 setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true)45 setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true)
41 case "zoom": setZoomScale(Double(argument) ?? 1, animated: false)46 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)
42 case "shot":55 case "shot":
43 guard let window else { break }56 guard let window else { break }
44 let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in57 let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
apps/ios/build-rust.sh+1-1
...@@ -10,5 +10,5 @@ release=...@@ -10,5 +10,5 @@ release=
10root="$SRCROOT/../.."10root="$SRCROOT/../.."
11# Xcode's SDK variables would point host build scripts at the iOS SDK.11# Xcode's SDK variables would point host build scripts at the iOS SDK.
12exec env -i HOME="$HOME" PATH="$HOME/.cargo/bin:/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:/usr/bin:/bin:/usr/sbin:/sbin" USER="$USER" \12exec env -i HOME="$HOME" PATH="$HOME/.cargo/bin:/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:/usr/bin:/bin:/usr/sbin:/sbin" USER="$USER" \
13 CARGO_TARGET_DIR="$root/target/ios" \13 CARGO_TARGET_DIR="$root/target/ios" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" \
14 cargo build --manifest-path "$root/Cargo.toml" -p mobile --target "$target" $release14 cargo build --manifest-path "$root/Cargo.toml" -p mobile --target "$target" $release
crates/mobile/Cargo.toml+2
...@@ -11,8 +11,10 @@ crate-type = ["staticlib"]...@@ -11,8 +11,10 @@ crate-type = ["staticlib"]
11[dependencies]11[dependencies]
12canvas = { path = "../canvas", features = ["interaction"] }12canvas = { path = "../canvas", features = ["interaction"] }
13draw = { path = "../draw" }13draw = { path = "../draw" }
14notebook = { path = "../notebook" }
14onestore = { path = "../onestore" }15onestore = { path = "../onestore" }
15wgpu.workspace = true16wgpu.workspace = true
16parley.workspace = true17parley.workspace = true
17pollster = "0.4"18pollster = "0.4"
19serde = { version = "1", features = ["derive"] }
18serde_json = "1"20serde_json = "1"
crates/mobile/include/snowbound.h+14-3
...@@ -8,9 +8,12 @@...@@ -8,9 +8,12 @@
8typedef struct Section Section;8typedef struct Section Section;
9typedef struct View View;9typedef struct View View;
1010
11char *sb_notebook(const char *path);
12
11Section *sb_section_open(const char *path);13Section *sb_section_open(const char *path);
12size_t sb_section_count(const Section *section);14size_t sb_section_count(const Section *section);
13const char *sb_section_title(const Section *section, size_t index);15const char *sb_section_title(const Section *section, size_t index);
16uint32_t sb_section_level(const Section *section, size_t index);
14void sb_section_free(Section *section);17void sb_section_free(Section *section);
1518
16View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale);19View *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);...@@ -18,15 +21,23 @@ void sb_view_free(View *view);
18void sb_view_resize(View *view, float width, float height, float scale);21void sb_view_resize(View *view, float width, float height, float scale);
19bool sb_view_render(View *view);22bool sb_view_render(View *view);
20bool sb_view_frame_pending(const View *view);23bool sb_view_frame_pending(const View *view);
24void sb_view_set_dark(View *view, bool dark);
25void sb_view_focus(View *view, bool focused);
21void sb_view_content(View *view, float bounds[4]);26void sb_view_content(View *view, float bounds[4]);
27bool sb_view_block(const View *view, float x, float y, float rect[4]);
22void sb_view_set_transform(View *view, float zoom, float x, float y);28void sb_view_set_transform(View *view, float zoom, float x, float y);
23bool sb_view_tap(View *view, float x, float y);29uint8_t sb_view_target(const View *view, float x, float y);
24bool sb_view_in_active_text(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);
25char *sb_view_edit(View *view);36char *sb_view_edit(View *view);
37void sb_string_free(char *text);
2638
27uint32_t sb_text_length(const View *view);39uint32_t sb_text_length(const View *view);
28char *sb_text(const View *view, uint32_t start, uint32_t end);40char *sb_text(const View *view, uint32_t start, uint32_t end);
29void sb_string_free(char *text);
30void sb_selection(const View *view, uint32_t range[2]);41void sb_selection(const View *view, uint32_t range[2]);
31bool sb_select(View *view, uint32_t start, uint32_t end);42bool sb_select(View *view, uint32_t start, uint32_t end);
32bool sb_marked(const View *view, uint32_t range[2]);43bool sb_marked(const View *view, uint32_t range[2]);
crates/mobile/src/lib.rs+504-300
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1//! A canvas page behind a C surface for native mobile shells: a section's pages, one drawn1//! A canvas page behind a C surface for native mobile shells: a notebook's sections, a
2//! into a `CAMetalLayer`, and the active outline's text as flat UTF-16 offsets, paragraphs2//! section's pages, one page drawn into a `CAMetalLayer`, and the active outline's text as
3//! joined by `\n`, the model UIKit's `UITextInput` speaks.3//! the flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`).
4//!4//!
5//! Lengths and positions are in the host's points; the view converts through its display5//! Lengths and positions are in the host's points; the view converts through its display
6//! scale. Calls returning `bool` report whether the page or selection changed, after which6//! scale. Calls returning `bool` report whether the page or selection changed, after which
...@@ -8,12 +8,13 @@...@@ -8,12 +8,13 @@
88
9use canvas::{9use canvas::{
10 document::TextPosition,10 document::TextPosition,
11 editor::Selection,11 editor::{Formatting, Selection, TextOutline, Toggle},
12 gpu::{Paper, Viewport, page::PageScene},12 gpu::{Paper, Viewport, page::PageScene},
13 interaction::{Hit, PageView, Response, TextColors, page_hit_test},13 interaction::{Hit, PageView, Response, TextColors},
14 layout::TextEngine,14 layout::TextEngine,
15};15};
16use draw::edit::{Key, NamedKey, SelectionUnit};16use draw::edit::{Key, NamedKey, SelectionUnit};
17use notebook::discover::{self, Folder, SectionState};
17use onestore::{18use onestore::{
18 ExGuid, RevisionIndex, Store,19 ExGuid, RevisionIndex, Store,
19 document::Document,20 document::Document,
...@@ -24,6 +25,7 @@ use parley::{Affinity, BoundingBox};...@@ -24,6 +25,7 @@ use parley::{Affinity, BoundingBox};
24use std::{25use std::{
25 error::Error,26 error::Error,
26 ffi::{CStr, CString, c_char, c_void},27 ffi::{CStr, CString, c_char, c_void},
28 path::Path,
27 sync::{29 sync::{
28 Arc,30 Arc,
29 atomic::{AtomicBool, Ordering},31 atomic::{AtomicBool, Ordering},
...@@ -37,17 +39,99 @@ type Result<T> = std::result::Result<T, Box<dyn Error>>;...@@ -37,17 +39,99 @@ type Result<T> = std::result::Result<T, Box<dyn Error>>;
37/// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch.39/// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch.
38const POINT: f32 = 96.0 / 72.0;40const POINT: f32 = 96.0 / 72.0;
3941
40/// The platform's text interaction draws the caret and selection.42/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
41const COLORS: TextColors = TextColors {43const SECTION_COLOR: u32 = 0x00e4_a88a;
42 caret: [0.0; 4],44
43 selection: [0.0; 4],45#[derive(serde::Serialize)]
44 paper: Paper::WHITE,46struct Listing {
45};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
47pub struct Section {130pub struct Section {
48 /// Each page with the object space holding it.131 /// Each page with the object space holding it.
49 pages: Vec<(ExGuid, Page)>,132 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)>,
51}135}
52136
53impl Section {137impl Section {
...@@ -55,16 +139,17 @@ impl Section {...@@ -55,16 +139,17 @@ impl Section {
55 let store = Store::parse(bytes)?;139 let store = Store::parse(bytes)?;
56 let index = RevisionIndex::parse(&store)?;140 let index = RevisionIndex::parse(&store)?;
57 let document = Document::parse(&index)?;141 let document = Document::parse(&index)?;
58 let pages = document142 let mut pages = Vec::new();
59 .pages()?143 let mut headings = Vec::new();
60 .into_iter()144 for (space, id) in document.pages()? {
61 .map(|(space, id)| Ok((space, Page::from_revision(document.active(space)?, id)?)))145 let revision = document.active(space)?;
62 .collect::<std::result::Result<Vec<_>, onestore::Error>>()?;146 let (title, level) = Page::heading(revision, id);
63 let titles = pages147 pages.push((space, Page::from_revision(revision, id)?));
64 .iter()148 // Titles keep OneNote's line breaks, which a one-line list shows as spaces.
65 .map(|(_, page)| CString::new(page.title.replace('\0', "")))149 let title = title.replace(|char: char| char.is_control(), " ");
66 .collect::<std::result::Result<_, _>>()?;150 headings.push((CString::new(title)?, level));
67 Ok(Self { pages, titles })151 }
152 Ok(Self { pages, headings })
68 }153 }
69}154}
70155
...@@ -78,16 +163,6 @@ impl Wake for Frame {...@@ -78,16 +163,6 @@ impl Wake for Frame {
78 }163 }
79}164}
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
91fn report<T>(result: Result<T>) -> Option<T> {166fn report<T>(result: Result<T>) -> Option<T> {
92 result.map_err(|error| eprintln!("snowbound: {error}")).ok()167 result.map_err(|error| eprintln!("snowbound: {error}")).ok()
93}168}
...@@ -96,53 +171,70 @@ fn moved(response: Response) -> bool {...@@ -96,53 +171,70 @@ fn moved(response: Response) -> bool {
96 response.changed || response.moved171 response.changed || response.moved
97}172}
98173
99impl View {174/// What a touch lands on, so the host routes it before its gesture starts.
100 fn new(175#[repr(u8)]
101 layer: *mut c_void,176#[derive(Debug, PartialEq)]
102 (space, page): (ExGuid, Page),177enum Target {
103 size: [f32; 2],178 /// Empty page, a picture, a check box or the date: the canvas takes a tap.
104 scale: f32,179 Page = 0,
105 ) -> Result<Self> {180 /// The text taking input, where the platform's text interaction works.
106 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());181 ActiveText = 1,
107 // SAFETY: the host passes a live CAMetalLayer that outlives the view.182 /// Another outline's text, which a touch focuses before the platform's text interaction
108 let surface = unsafe {183 /// takes over.
109 instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer))184 Text = 2,
110 }?;185 /// The focused outline's move or width grip.
111 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {186 Grip = 3,
112 compatible_surface: Some(&surface),187}
113 ..Default::default()188
114 }))?;189/// The page and its text model, apart from the surface it is drawn on.
115 // The simulator's adapter falls short of wgpu's default limits.190struct Canvas {
116 let (device, queue) =191 page: PageView,
117 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {192 /// The object space of the page shown, which its edits name.
118 required_limits: adapter.limits(),193 space: ExGuid,
119 ..Default::default()194 paper: Paper,
120 }))?;195}
121 let pixels = size.map(|side| (side * scale).round().max(1.0) as u32);196
122 let config = surface197impl Canvas {
123 .get_default_config(&adapter, pixels[0], pixels[1])198 fn new((space, page): (ExGuid, Page), pixels: [u32; 2], scale: f32) -> Result<Self> {
124 .ok_or("No supported surface configuration")?;
125 surface.configure(&device, &config);
126 let renderer = draw::Renderer::new(device, queue, config.format);
127 let mut engine = TextEngine::default();199 let mut engine = TextEngine::default();
128 let (scene, editor) = PageScene::from_page(page, &mut engine)?;200 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;
129 Ok(Self {210 Ok(Self {
130 page: PageView::new(211 page,
131 editor,
132 engine,
133 Some((scene, [0.0; 2])),
134 pixels,
135 scale,
136 Duration::from_millis(350),
137 ),
138 space,212 space,
139 surface,213 paper: Paper::WHITE,
140 config,
141 renderer,
142 frame: Arc::default(),
143 })214 })
144 }215 }
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
146 /// What the page took since the last call as one edit, or none when it took nothing.238 /// What the page took since the last call as one edit, or none when it took nothing.
147 fn edit(&mut self) -> Result<Option<Edit>> {239 fn edit(&mut self) -> Result<Option<Edit>> {
148 let ops = self.page.editor.take_ops()?;240 let ops = self.page.editor.take_ops()?;
...@@ -173,31 +265,6 @@ impl View {...@@ -173,31 +265,6 @@ impl View {
173 point.map(|value| value * self.display_scale())265 point.map(|value| value * self.display_scale())
174 }266 }
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
201 /// The page content's extent in document points: the scroll limits of a view with no268 /// The page content's extent in document points: the scroll limits of a view with no
202 /// size at scale 1.269 /// size at scale 1.
203 fn content(&mut self) -> [f32; 4] {270 fn content(&mut self) -> [f32; 4] {
...@@ -218,97 +285,134 @@ impl View {...@@ -218,97 +285,134 @@ impl View {
218 self.page.viewport.origin = corner.map(|value| -value * scale * zoom);285 self.page.viewport.origin = corner.map(|value| -value * scale * zoom);
219 }286 }
220287
221 fn tap(&mut self, point: [f32; 2]) -> Result<bool> {288 fn outline(&self, id: ExGuid) -> Option<&TextOutline> {
222 let mut changed = moved(self.page.pointer_moved(self.device(point))?);289 let editor = &self.page.editor;
223 changed |= moved(self.page.pointer_pressed(Instant::now())?);290 editor
224 changed |= moved(self.page.pointer_released()?);291 .visible_outlines()
225 // A finger leaves no hover behind.292 .chain(editor.caret_outline())
226 let _ = self.page.pointer_left();293 .find(|outline| outline.id == id)
227 Ok(changed)
228 }294 }
229295
230 /// Whether `point` lands on the text of the outline taking input, where the platform's296 fn target(&self, point: [f32; 2]) -> Target {
231 /// own text interaction places the caret.297 match self.page.hit(self.device(point)) {
232 fn in_active_text(&self, point: [f32; 2]) -> bool {298 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,
233 let point = self.page.viewport.document_point(self.device(point));299 Some(Hit::Text { id, point })
234 let pixel = self.display_scale() / self.page.viewport.scale;300 if !self
235 matches!(301 .outline(id)
236 page_hit_test(&self.page.editor, self.page.scene.as_ref(), point, pixel),302 .is_some_and(|outline| outline.contains_extension(point)) =>
237 Some(Hit::Text { id, .. }) if id == self.page.editor.active_outline().id303 {
238 )304 if id == self.page.editor.active_outline().id {
239 }305 Target::ActiveText
240306 } else {
241 /// UTF-16 lengths of the active outline's text paragraphs.307 Target::Text
242 fn lengths(&self) -> Result<Vec<u32>> {308 }
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 });
261 }309 }
262 rest -= length + 1;310 _ => Target::Page,
263 }311 }
264 Ok(TextPosition {312 }
265 paragraph: lengths.len() - 1,313
266 offset: lengths[lengths.len() - 1],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)
267 })331 })
268 }332 }
269333
270 fn flat(&self, position: TextPosition) -> Result<u32> {334 fn drag(&mut self, point: [f32; 2]) -> Result<bool> {
271 let before: u32 = self.lengths()?[..position.paragraph]335 let point = self.device(point);
272 .iter()336 self.hold(|page| Ok(moved(page.pointer_moved(point)?)))
273 .map(|length| length + 1)
274 .sum();
275 Ok(before + position.offset)
276 }337 }
277338
278 fn length(&self) -> Result<u32> {339 fn release(&mut self) -> Result<bool> {
279 let lengths = self.lengths()?;340 self.hold(|page| {
280 Ok(lengths.iter().sum::<u32>() + lengths.len() as u32 - 1)341 let changed = moved(page.pointer_released()?);
342 // A finger leaves no hover behind.
343 let _ = page.pointer_left();
344 Ok(changed)
345 })
281 }346 }
282347
283 fn text(&self, range: [u32; 2]) -> Result<String> {348 /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom.
284 let range = self.position(range[0])?..self.position(range[1])?;349 fn block(&self, point: [f32; 2]) -> Option<[f32; 4]> {
285 Ok(self350 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
286 .page361 .page
287 .editor362 .editor
288 .active_outline()363 .visible_outlines()
289 .document()364 .map(TextOutline::bounds)
290 .slice(range)?365 .min_by(|a, b| distance(a).total_cmp(&distance(b)))?;
291 .iter()366 Some([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32 * POINT))
292 .map(|paragraph| paragraph.text())367 }
293 .collect::<Vec<_>>()368
294 .join("\n"))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
295 }381 }
296382
297 fn selection(&self) -> Result<[u32; 2]> {383 fn selection(&self) -> Result<[u32; 2]> {
298 let [anchor, focus] = self.page.editor.selection().positions;384 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 ])
300 }398 }
301399
302 fn select(&mut self, range: [u32; 2]) -> Result<()> {400 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)?;
304 Ok(self.page.editor.select(Selection::from(positions))?)402 Ok(self.page.editor.select(Selection::from(positions))?)
305 }403 }
306404
307 fn marked(&self) -> Result<Option<[u32; 2]>> {405 fn marked(&self) -> Result<Option<[u32; 2]>> {
406 let outline = self.active();
308 self.page407 self.page
309 .editor408 .editor
310 .marked_range()409 .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 })
312 .transpose()416 .transpose()
313 }417 }
314418
...@@ -325,23 +429,25 @@ impl View {...@@ -325,23 +429,25 @@ impl View {
325 .map_or(text.len(), |(byte, _)| byte)429 .map_or(text.len(), |(byte, _)| byte)
326 };430 };
327 let cursor = (byte(selected[0]), byte(selected[1]));431 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))?)))
329 }433 }
330434
331 fn insert(&mut self, text: String) -> Result<bool> {435 fn insert(&mut self, text: String) -> Result<bool> {
332 let response = if text == "\n" {436 self.hold(|page| {
333 self.page.key(&Key::Named(NamedKey::Enter), None)?437 Ok(moved(if text == "\n" {
334 } else {438 page.key(&Key::Named(NamedKey::Enter), None)?
335 self.page.commit_text(text)?439 } else {
336 };440 page.commit_text(text)?
337 Ok(moved(response))441 }))
442 })
338 }443 }
339444
340 /// A document rectangle in the active outline, in the view's points as `[x, y, width,445 /// A rectangle in the active outline's coordinates, in the view's points as `[x, y,
341 /// height]`.446 /// width, height]`.
342 fn view_rect(&self, rect: BoundingBox, origin: [f32; 2]) -> [f32; 4] {447 fn view_rect(&self, rect: BoundingBox) -> [f32; 4] {
343 let viewport = self.page.viewport;448 let viewport = self.page.viewport;
344 let points = self.display_scale();449 let points = self.display_scale();
450 let origin = self.active().origin();
345 let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]];451 let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]];
346 [452 [
347 (x * viewport.scale + viewport.origin[0]) / points,453 (x * viewport.scale + viewport.origin[0]) / points,
...@@ -351,88 +457,135 @@ impl View {...@@ -351,88 +457,135 @@ impl View {
351 ]457 ]
352 }458 }
353459
354 fn caret_rect(&self, flat: u32) -> Result<[f32; 4]> {460 fn caret_rect(&self, offset: u32) -> Result<[f32; 4]> {
355 let position = self.position(flat)?;461 let position = self.active().utf16_position(offset)?;
356 let outline = self.page.editor.active_outline();462 let caret = self
357 let paragraph = outline.paragraph_layout(position.paragraph)?;463 .page
358 let visible = paragraph.projection.visible_offset(position.offset)?;464 .editor
359 let byte = paragraph.projection.text().byte_offset(visible)?;465 .caret_at(position, Affinity::Downstream, 1.0)?;
360 let caret = paragraph466 Ok(self.view_rect(caret))
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 ))
371 }467 }
372468
373 /// The selection rectangles of a range, one or more per line.469 /// The selection rectangles of a range, one or more per line.
374 fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> {470 fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> {
375 let [start, end] = [self.position(range[0])?, self.position(range[1])?];471 let rects = self.page.editor.range_rects(Selection {
376 let outline = self.page.editor.active_outline();472 positions: self.positions(range)?,
377 let origin = outline.origin();473 affinities: [Affinity::Downstream, Affinity::Upstream],
378 let mut rects = Vec::new();474 })?;
379 for (index, paragraph) in outline.layouts() {475 Ok(rects.into_iter().map(|rect| self.view_rect(rect)).collect())
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)
415 }476 }
416477
417 fn closest(&self, point: [f32; 2]) -> Result<u32> {478 fn closest(&self, point: [f32; 2]) -> Result<u32> {
418 let point = self.page.viewport.document_point(self.device(point));479 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();
420 let selection = self.page.editor.selection_at(481 let selection = self.page.editor.selection_at(
421 point[0] - origin[0],482 point[0] - origin[0],
422 point[1] - origin[1],483 point[1] - origin[1],
423 SelectionUnit::Grapheme,484 SelectionUnit::Grapheme,
424 )?;485 )?;
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(())
426 }555 }
427}556}
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///
429/// # Safety573/// # 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.
431#[unsafe(no_mangle)]585#[unsafe(no_mangle)]
432pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section {586pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section {
433 let path = unsafe { CStr::from_ptr(path) };
434 report(587 report(
435 std::fs::read(path.to_str().unwrap_or_default())588 std::fs::read(string(path))
436 .map_err(Into::into)589 .map_err(Into::into)
437 .and_then(|bytes| Section::open(&bytes)),590 .and_then(|bytes| Section::open(&bytes)),
438 )591 )
...@@ -449,7 +602,13 @@ pub extern "C" fn sb_section_count(section: &Section) -> usize {...@@ -449,7 +602,13 @@ pub extern "C" fn sb_section_count(section: &Section) -> usize {
449/// The page's title, alive as long as the section.602/// The page's title, alive as long as the section.
450#[unsafe(no_mangle)]603#[unsafe(no_mangle)]
451pub extern "C" fn sb_section_title(section: &Section, index: usize) -> *const c_char {604pub 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
453}612}
454613
455/// # Safety614/// # Safety
...@@ -492,10 +651,11 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) {...@@ -492,10 +651,11 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) {
492#[unsafe(no_mangle)]651#[unsafe(no_mangle)]
493pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) {652pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) {
494 let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32);653 let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32);
495 if scale != view.display_scale() {654 if scale != view.canvas.display_scale() {
496 let _ = report(view.page.scale_factor_changed(scale));655 let _ = report(view.canvas.page.scale_factor_changed(scale));
497 }656 }
498 let _ = report(view.page.resized(pixels));657 let page = &mut view.canvas.page;
658 let _ = report(page.resized(pixels));
499 view.config.width = pixels[0];659 view.config.width = pixels[0];
500 view.config.height = pixels[1];660 view.config.height = pixels[1];
501 view.surface.configure(&view.renderer.device, &view.config);661 view.surface.configure(&view.renderer.device, &view.config);
...@@ -512,42 +672,96 @@ pub extern "C" fn sb_view_frame_pending(view: &View) -> bool {...@@ -512,42 +672,96 @@ pub extern "C" fn sb_view_frame_pending(view: &View) -> bool {
512 view.frame.0.swap(false, Ordering::Acquire)672 view.frame.0.swap(false, Ordering::Acquire)
513}673}
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
515/// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right687/// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right
516/// including the margin OneNote scrolls past the last object.688/// including the margin OneNote scrolls past the last object.
517#[unsafe(no_mangle)]689#[unsafe(no_mangle)]
518pub extern "C" fn sb_view_content(view: &mut View, bounds: &mut [f32; 4]) {690pub 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()
520}702}
521703
522/// Zooms and scrolls so the page point `x`, `y` sits at the view's corner; the host's704/// Zooms and scrolls so the page point `x`, `y` sits at the view's corner; the host's
523/// scroll view owns the limits and rubber-banding.705/// scroll view owns the limits and rubber-banding.
524#[unsafe(no_mangle)]706#[unsafe(no_mangle)]
525pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f32) {707pub 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]);
527}709}
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.
530#[unsafe(no_mangle)]714#[unsafe(no_mangle)]
531pub extern "C" fn sb_view_tap(view: &mut View, x: f32, y: f32) -> bool {715pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {
532 report(view.tap([x, y])).unwrap_or(false)716 view.canvas.target([x, y]) as u8
533}717}
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.
535#[unsafe(no_mangle)]721#[unsafe(no_mangle)]
536pub extern "C" fn sb_view_in_active_text(view: &View, x: f32, y: f32) -> bool {722pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {
537 view.in_active_text([x, y])723 report(view.canvas.press([x, y])).unwrap_or(false)
538}724}
539725
540#[unsafe(no_mangle)]726#[unsafe(no_mangle)]
541pub extern "C" fn sb_text_length(view: &View) -> u32 {727pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool {
542 report(view.length()).unwrap_or(0)728 report(view.canvas.drag([x, y])).unwrap_or(false)
543}729}
544730
545/// The text in `start..end`, freed with `sb_string_free`.
546#[unsafe(no_mangle)]731#[unsafe(no_mangle)]
547pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char {732pub extern "C" fn sb_view_release(view: &mut View) -> bool {
548 report(view.text([start, end]))733 report(view.canvas.release()).unwrap_or(false)
549 .and_then(|text| CString::new(text.replace('\0', "")).ok())734}
550 .map_or(std::ptr::null_mut(), CString::into_raw)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)
551}765}
552766
553/// The edit the page took since the last call, as `onestore::op::Edit` JSON for the767/// 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 {...@@ -555,34 +769,45 @@ pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char {
555/// stored, in which case the page should be opened again.769/// stored, in which case the page should be opened again.
556#[unsafe(no_mangle)]770#[unsafe(no_mangle)]
557pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char {771pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char {
558 report(view.edit())772 report(view.canvas.edit())
559 .flatten()773 .flatten()
560 .and_then(|edit| CString::new(serde_json::to_string(&edit).ok()?).ok())774 .and_then(|edit| serde_json::to_string(&edit).ok())
561 .map_or(std::ptr::null_mut(), CString::into_raw)775 .map_or(std::ptr::null_mut(), owned)
562}776}
563777
564/// # Safety778/// # 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.
566#[unsafe(no_mangle)]780#[unsafe(no_mangle)]
567pub unsafe extern "C" fn sb_string_free(text: *mut c_char) {781pub unsafe extern "C" fn sb_string_free(text: *mut c_char) {
568 drop(unsafe { CString::from_raw(text) });782 drop(unsafe { CString::from_raw(text) });
569}783}
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
571#[unsafe(no_mangle)]796#[unsafe(no_mangle)]
572pub extern "C" fn sb_selection(view: &View, range: &mut [u32; 2]) {797pub 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()) {
574 *range = selection;799 *range = selection;
575 }800 }
576}801}
577802
578#[unsafe(no_mangle)]803#[unsafe(no_mangle)]
579pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool {804pub 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()
581}806}
582807
583#[unsafe(no_mangle)]808#[unsafe(no_mangle)]
584pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool {809pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool {
585 report(view.marked())810 report(view.canvas.marked())
586 .flatten()811 .flatten()
587 .map(|marked| *range = marked)812 .map(|marked| *range = marked)
588 .is_some()813 .is_some()
...@@ -597,15 +822,16 @@ pub unsafe extern "C" fn sb_set_marked(...@@ -597,15 +822,16 @@ pub unsafe extern "C" fn sb_set_marked(
597 selected_start: u32,822 selected_start: u32,
598 selected_end: u32,823 selected_end: u32,
599) -> bool {824) -> bool {
600 let text = unsafe { CStr::from_ptr(text) }825 report(
601 .to_string_lossy()826 view.canvas
602 .into_owned();827 .set_marked(string(text), [selected_start, selected_end]),
603 report(view.set_marked(text, [selected_start, selected_end])).unwrap_or(false)828 )
829 .unwrap_or(false)
604}830}
605831
606#[unsafe(no_mangle)]832#[unsafe(no_mangle)]
607pub extern "C" fn sb_unmark(view: &mut View) {833pub extern "C" fn sb_unmark(view: &mut View) {
608 view.page.editor.finish_composition();834 view.canvas.page.editor.finish_composition();
609}835}
610836
611/// Types `text`, a lone `\n` as the Return key.837/// Types `text`, a lone `\n` as the Return key.
...@@ -614,13 +840,10 @@ pub extern "C" fn sb_unmark(view: &mut View) {...@@ -614,13 +840,10 @@ pub extern "C" fn sb_unmark(view: &mut View) {
614/// `text` is NUL-terminated UTF-8.840/// `text` is NUL-terminated UTF-8.
615#[unsafe(no_mangle)]841#[unsafe(no_mangle)]
616pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool {842pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool {
617 let text = unsafe { CStr::from_ptr(text) }843 report(view.canvas.insert(string(text))).unwrap_or(false)
618 .to_string_lossy()
619 .into_owned();
620 report(view.insert(text)).unwrap_or(false)
621}844}
622845
623/// Replaces `start..end` with `text`, as autocorrection and dictation do.846/// Replaces `start..end` with `text`, as autocorrection, dictation and cutting do.
624///847///
625/// # Safety848/// # Safety
626/// `text` is NUL-terminated UTF-8.849/// `text` is NUL-terminated UTF-8.
...@@ -631,12 +854,11 @@ pub unsafe extern "C" fn sb_replace(...@@ -631,12 +854,11 @@ pub unsafe extern "C" fn sb_replace(
631 end: u32,854 end: u32,
632 text: *const c_char,855 text: *const c_char,
633) -> bool {856) -> bool {
634 let text = unsafe { CStr::from_ptr(text) }857 let canvas = &mut view.canvas;
635 .to_string_lossy()
636 .into_owned();
637 report(858 report(
638 view.select([start, end])859 canvas
639 .and_then(|()| Ok(moved(view.page.commit_text(text)?))),860 .select([start, end])
861 .and_then(|()| canvas.hold(|page| Ok(moved(page.commit_text(string(text))?)))),
640 )862 )
641 .unwrap_or(false)863 .unwrap_or(false)
642}864}
...@@ -651,12 +873,10 @@ pub unsafe extern "C" fn sb_paste(...@@ -651,12 +873,10 @@ pub unsafe extern "C" fn sb_paste(
651 text: *const c_char,873 text: *const c_char,
652 language: *const c_char,874 language: *const c_char,
653) -> bool {875) -> bool {
654 let text = unsafe { CStr::from_ptr(text) }.to_string_lossy();876 let (text, language) = (string(text), canvas::language::lcid(&string(language)));
655 let language = unsafe { CStr::from_ptr(language) }.to_string_lossy();
656 report(877 report(
657 view.page878 view.canvas
658 .paste(&text, canvas::language::lcid(&language))879 .hold(|page| page.paste(&text, language).map(moved)),
659 .map(moved),
660 )880 )
661 .unwrap_or(false)881 .unwrap_or(false)
662}882}
...@@ -664,9 +884,8 @@ pub unsafe extern "C" fn sb_paste(...@@ -664,9 +884,8 @@ pub unsafe extern "C" fn sb_paste(
664#[unsafe(no_mangle)]884#[unsafe(no_mangle)]
665pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {885pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {
666 report(886 report(
667 view.page887 view.canvas
668 .key(&Key::Named(NamedKey::Backspace), None)888 .hold(|page| page.key(&Key::Named(NamedKey::Backspace), None).map(moved)),
669 .map(moved),
670 )889 )
671 .unwrap_or(false)890 .unwrap_or(false)
672}891}
...@@ -674,7 +893,7 @@ pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {...@@ -674,7 +893,7 @@ pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {
674/// The caret at `offset` as `[x, y, width, height]` in the view's points.893/// The caret at `offset` as `[x, y, width, height]` in the view's points.
675#[unsafe(no_mangle)]894#[unsafe(no_mangle)]
676pub extern "C" fn sb_caret_rect(view: &View, offset: u32, rect: &mut [f32; 4]) -> bool {895pub 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))
678 .map(|caret| *rect = caret)897 .map(|caret| *rect = caret)
679 .is_some()898 .is_some()
680}899}
...@@ -691,7 +910,7 @@ pub unsafe extern "C" fn sb_range_rects(...@@ -691,7 +910,7 @@ pub unsafe extern "C" fn sb_range_rects(
691 rects: *mut [f32; 4],910 rects: *mut [f32; 4],
692 capacity: usize,911 capacity: usize,
693) -> usize {912) -> 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();
695 for (index, rect) in found.iter().take(capacity).enumerate() {914 for (index, rect) in found.iter().take(capacity).enumerate() {
696 unsafe { rects.add(index).write(*rect) };915 unsafe { rects.add(index).write(*rect) };
697 }916 }
...@@ -700,23 +919,8 @@ pub unsafe extern "C" fn sb_range_rects(...@@ -700,23 +919,8 @@ pub unsafe extern "C" fn sb_range_rects(
700919
701#[unsafe(no_mangle)]920#[unsafe(no_mangle)]
702pub extern "C" fn sb_closest(view: &View, x: f32, y: f32) -> u32 {921pub 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)
704}923}
705924
706#[cfg(test)]925#[cfg(test)]
707mod tests {926mod 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}
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}