| author | |
| committer | |
| log | 46b2c3bf9ebd9f51be378aaf1835936a82f4ddb9 |
| tree | 0babd16604271ed28e63defcec7335a0e1b46448 |
| parent | b305654ab5ef9281fe63f30857a5ae23fa611558 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-59 files changed, 1356 insertions(+), 387 deletions(-)
apps/ios/Snowbound.xcodeproj/project.pbxproj+4-4| ... | ... | @@ -7,12 +7,12 @@ |
| 7 | 7 | 	objects = { |
| 8 | 8 | |
| 9 | 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 | 11 | /* End PBXBuildFile section */ |
| 12 | 12 | |
| 13 | 13 | /* Begin PBXFileReference section */ |
| 14 | 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 | 16 | 		5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; |
| 17 | 17 | 		5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; }; |
| 18 | 18 | 		5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; }; |
| ... | ... | @@ -41,7 +41,7 @@ |
| 41 | 41 | 			isa = PBXGroup; |
| 42 | 42 | 			children = ( |
| 43 | 43 | 				5B0000000000000000000300 /* Snowbound */, |
| 44 | 				5B0000000000000000000201 /* Features.one */, | |
| 44 | 				5B0000000000000000000201 /* Sample */, | |
| 45 | 45 | 				5B0000000000000000000202 /* Info.plist */, |
| 46 | 46 | 				5B0000000000000000000203 /* Snowbound-Bridging-Header.h */, |
| 47 | 47 | 				5B0000000000000000000204 /* build-rust.sh */, |
| ... | ... | @@ -120,7 +120,7 @@ |
| 120 | 120 | 			isa = PBXResourcesBuildPhase; |
| 121 | 121 | 			buildActionMask = 2147483647; |
| 122 | 122 | 			files = ( |
| 123 | 				5B0000000000000000000101 /* Features.one in Resources */, | |
| 123 | 				5B0000000000000000000101 /* Sample in Resources */, | |
| 124 | 124 | 			); |
| 125 | 125 | 			runOnlyForDeploymentPostprocessing = 0; |
| 126 | 126 | 		}; |
apps/ios/Snowbound/App.swift+254-42| ... | ... | @@ -14,117 +14,329 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { |
| 14 | 14 | } |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | final 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. | |
| 19 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate { | |
| 18 | 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 | |
| 19 | 25 | |
| 20 | 26 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { |
| 21 | 27 | guard let scene = scene as? UIWindowScene else { return } |
| 22 | 28 | let window = UIWindow(windowScene: scene) |
| 23 | let path = ProcessInfo.processInfo.environment["SNOWBOUND_SECTION"] | |
| 24 | ?? Bundle.main.path(forResource: "Features", ofType: "one")! | |
| 25 | let pages = PagesViewController(section: Section(path: path)) | |
| 26 | window.rootViewController = UINavigationController(rootViewController: pages) | |
| 29 | split.delegate = self | |
| 30 | split.preferredDisplayMode = .oneBesideSecondary | |
| 31 | split.preferredSplitBehavior = .tile | |
| 32 | split.setViewController(sections, for: .primary) | |
| 33 | split.setViewController(pages, for: .supplementary) | |
| 34 | split.setViewController(UIViewController(), for: .secondary) | |
| 35 | sections.onOpen = { [weak self] section in self?.show(section: section) } | |
| 36 | pages.onOpen = { [weak self] section, page in self?.show(section: section, page: page) } | |
| 37 | window.rootViewController = split | |
| 27 | 38 | window.makeKeyAndVisible() |
| 28 | 39 | self.window = window |
| 29 | if let page = ProcessInfo.processInfo.environment["SNOWBOUND_PAGE"].flatMap(Int.init) { | |
| 30 | pages.show(page: page, animated: false) | |
| 40 | sections.load(Library.opening()) | |
| 41 | let environment = ProcessInfo.processInfo.environment | |
| 42 | // The split view collapses into a stack once the window is on screen. | |
| 43 | DispatchQueue.main.async { [self] in | |
| 44 | if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init), let notebook = sections.notebook, | |
| 45 | index < notebook.sections.count | |
| 46 | { | |
| 47 | show(section: notebook.sections[index]) | |
| 48 | if let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), let section = pages.section, | |
| 49 | page < section.headings.count | |
| 50 | { | |
| 51 | show(section: section, page: page) | |
| 52 | } | |
| 53 | } | |
| 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`. | |
| 78 | struct Notebook: Decodable { | |
| 79 | let name: String | |
| 80 | let sections: [Tab] | |
| 81 | } | |
| 82 | ||
| 83 | struct 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. | |
| 98 | enum 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 | } |
| 34 | 133 | |
| 35 | 134 | /// A parsed .one section; pages are read-only copies until saving lands. |
| 36 | 135 | final class Section { |
| 37 | 136 | let handle: OpaquePointer? |
| 38 | 137 | 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)] | |
| 39 | 141 | |
| 40 | init(path: String) { | |
| 41 | handle = sb_section_open(path) | |
| 42 | name = (path as NSString).lastPathComponent.replacingOccurrences(of: ".one", with: "") | |
| 142 | init(_ tab: Tab) { | |
| 143 | handle = sb_section_open(tab.path) | |
| 144 | name = tab.name | |
| 145 | color = tab.uiColor | |
| 146 | headings = handle.map { handle in | |
| 147 | (0..<sb_section_count(handle)).map { | |
| 148 | (String(cString: sb_section_title(handle, $0)), Int(sb_section_level(handle, $0))) | |
| 149 | } | |
| 150 | } ?? [] | |
| 43 | 151 | } |
| 44 | 152 | |
| 45 | 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 | } |
| 52 | 155 | |
| 53 | final class PagesViewController: UITableViewController, UIDocumentPickerDelegate { | |
| 54 | private var section: Section | |
| 55 | private var titles: [String] | |
| 156 | final class SectionsViewController: UITableViewController, UIDocumentPickerDelegate { | |
| 157 | private(set) var notebook: Notebook? | |
| 158 | /// Sections by group, in the notebook's order. | |
| 159 | private var groups: [(name: String, sections: [Tab])] = [] | |
| 160 | var onOpen: ((Tab) -> Void)? | |
| 56 | 161 | |
| 57 | init(section: Section) { | |
| 58 | self.section = section | |
| 59 | titles = section.titles | |
| 162 | init() { | |
| 60 | 163 | super.init(style: .insetGrouped) |
| 61 | title = section.name | |
| 164 | navigationItem.largeTitleDisplayMode = .always | |
| 62 | 165 | } |
| 63 | 166 | |
| 64 | 167 | required init?(coder: NSCoder) { fatalError() } |
| 65 | 168 | |
| 66 | 169 | override func viewDidLoad() { |
| 67 | 170 | super.viewDidLoad() |
| 68 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") | |
| 69 | navigationItem.rightBarButtonItem = UIBarButtonItem( | |
| 70 | title: "Open", image: UIImage(systemName: "folder"), target: self, action: #selector(open)) | |
| 171 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "section") | |
| 172 | let open = UIBarButtonItem( | |
| 173 | title: "Open Notebook", image: UIImage(systemName: "folder"), target: self, action: #selector(open)) | |
| 174 | navigationItem.rightBarButtonItem = open | |
| 175 | } | |
| 176 | ||
| 177 | override func viewWillAppear(_ animated: Bool) { | |
| 178 | super.viewWillAppear(animated) | |
| 179 | navigationController?.navigationBar.prefersLargeTitles = true | |
| 180 | } | |
| 181 | ||
| 182 | func load(_ notebook: Notebook?) { | |
| 183 | self.notebook = notebook | |
| 184 | title = notebook?.name | |
| 185 | var groups: [(name: String, sections: [Tab])] = [] | |
| 186 | for tab in notebook?.sections ?? [] { | |
| 187 | if groups.last?.name == tab.group { | |
| 188 | groups[groups.count - 1].sections.append(tab) | |
| 189 | } else { | |
| 190 | groups.append((tab.group, [tab])) | |
| 191 | } | |
| 192 | } | |
| 193 | self.groups = groups | |
| 194 | tableView.reloadData() | |
| 195 | var empty = UIContentUnavailableConfiguration.empty() | |
| 196 | empty.text = notebook == nil ? "No Notebook Open" : "No Sections" | |
| 197 | empty.secondaryText = notebook == nil ? "Open a notebook folder or section from Files." : nil | |
| 198 | contentUnavailableConfiguration = groups.isEmpty ? empty : nil | |
| 71 | 199 | } |
| 72 | 200 | |
| 73 | 201 | @objc private func open() { |
| 74 | 202 | let picker = UIDocumentPickerViewController( |
| 75 | forOpeningContentTypes: [UTType(filenameExtension: "one") ?? .data], asCopy: true) | |
| 203 | forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) | |
| 76 | 204 | picker.delegate = self |
| 77 | 205 | present(picker, animated: true) |
| 78 | 206 | } |
| 79 | 207 | |
| 80 | 208 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { |
| 81 | 209 | guard let url = urls.first else { return } |
| 82 | section = Section(path: url.path) | |
| 83 | titles = section.titles | |
| 84 | title = section.name | |
| 85 | tableView.reloadData() | |
| 210 | guard let notebook = Library.open(url) else { | |
| 211 | let alert = UIAlertController( | |
| 212 | title: "Can’t Open Notebook", message: "Choose a OneNote notebook folder or a section file.", | |
| 213 | preferredStyle: .alert) | |
| 214 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | |
| 215 | present(alert, animated: true) | |
| 216 | return | |
| 217 | } | |
| 218 | load(notebook) | |
| 86 | 219 | } |
| 87 | 220 | |
| 88 | func show(page: Int, animated: Bool) { | |
| 89 | guard page < titles.count else { return } | |
| 90 | navigationController?.pushViewController( | |
| 91 | PageViewController(section: section, page: page, title: titles[page]), animated: animated) | |
| 221 | override func numberOfSections(in tableView: UITableView) -> Int { groups.count } | |
| 222 | ||
| 223 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { | |
| 224 | groups[section].name.isEmpty ? nil : groups[section].name.replacingOccurrences(of: "/", with: " › ") | |
| 92 | 225 | } |
| 93 | 226 | |
| 94 | 227 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { |
| 95 | titles.count | |
| 228 | groups[section].sections.count | |
| 96 | 229 | } |
| 97 | 230 | |
| 98 | 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 | ||
| 257 | final 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 | 288 | let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath) |
| 100 | 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 | 294 | cell.contentConfiguration = content |
| 103 | cell.accessoryType = .disclosureIndicator | |
| 104 | 295 | return cell |
| 105 | 296 | } |
| 106 | 297 | |
| 107 | 298 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { |
| 108 | tableView.deselectRow(at: indexPath, animated: true) | |
| 109 | show(page: indexPath.row, animated: true) | |
| 299 | guard let section else { return } | |
| 300 | onOpen?(section, indexPath.row) | |
| 110 | 301 | } |
| 111 | 302 | } |
| 112 | 303 | |
| 113 | 304 | final class PageViewController: UIViewController { |
| 114 | 305 | private let section: Section |
| 115 | 306 | 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) }) | |
| 116 | 314 | |
| 117 | init(section: Section, page: Int, title: String) { | |
| 315 | init(section: Section, page: Int) { | |
| 118 | 316 | self.section = section |
| 119 | 317 | self.page = page |
| 120 | 318 | super.init(nibName: nil, bundle: nil) |
| 121 | self.title = title | |
| 319 | title = section.headings[page].title | |
| 122 | 320 | navigationItem.largeTitleDisplayMode = .never |
| 123 | 321 | } |
| 124 | 322 | |
| 125 | 323 | required init?(coder: NSCoder) { fatalError() } |
| 126 | 324 | |
| 127 | 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 | 4 | override class var layerClass: AnyClass { CAMetalLayer.self } |
| 5 | 5 | } |
| 6 | 6 | |
| 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. | |
| 8 | 9 | private final class Position: UITextPosition { |
| 9 | 10 | let value: Int |
| 10 | 11 | init(_ value: Int) { self.value = value } |
| ... | ... | @@ -37,13 +38,31 @@ private extension UITextRange { |
| 37 | 38 | var upper: Int { (self as! Range).hi } |
| 38 | 39 | } |
| 39 | 40 | |
| 41 | /// What a touch lands on; see `sb_view_target`. | |
| 42 | private 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. | |
| 48 | private 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 | 56 | /// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page |
| 41 | 57 | /// drawn into a Metal layer pinned to the visible bounds, and the active outline's text |
| 42 | /// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, | |
| 43 | /// selection handles and loupe work on it. | |
| 58 | /// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text | |
| 59 | /// interaction, loupe and edit menu work on it. | |
| 44 | 60 | final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate, |
| 45 | UITextSelectionDisplayInteractionDelegate | |
| 61 | 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 | 66 | private let section: Section |
| 48 | 67 | private let page: Int |
| 49 | 68 | private let metal = MetalView() |
| ... | ... | @@ -56,38 +75,74 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 56 | 75 | private var origin = CGPoint.zero |
| 57 | 76 | private var syncing = false |
| 58 | 77 | 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 | 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 | 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 | 102 | private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self) |
| 103 | private let history = CanvasUndoManager() | |
| 63 | 104 | |
| 64 | 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)? | |
| 66 | 109 | |
| 67 | 110 | init(section: Section, page: Int) { |
| 68 | 111 | self.section = section |
| 69 | 112 | self.page = page |
| 70 | 113 | super.init(frame: .zero) |
| 71 | backgroundColor = .white | |
| 114 | backgroundColor = .systemBackground | |
| 72 | 115 | delegate = self |
| 73 | 116 | minimumZoomScale = 0.25 |
| 74 | 117 | maximumZoomScale = 4 |
| 75 | 118 | keyboardDismissMode = .interactive |
| 76 | 119 | addSubview(content) |
| 77 | 120 | insertSubview(metal, at: 0) |
| 121 | // Touches land on the scroll view itself, where the text interaction looks for them. | |
| 78 | 122 | metal.isUserInteractionEnabled = false |
| 123 | content.isUserInteractionEnabled = false | |
| 79 | 124 | let layer = metal.layer as! CAMetalLayer |
| 80 | 125 | layer.isOpaque = true |
| 81 | 126 | // Frames reach the screen with the transaction that moves UIKit's caret and handles. |
| 82 | 127 | 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 | 135 | interaction.textInput = self |
| 85 | 136 | interaction.delegate = self |
| 86 | 137 | addInteraction(interaction) |
| 87 | 138 | addInteraction(display) |
| 139 | display.isActivated = false | |
| 140 | addInteraction(editMenu) | |
| 141 | history.canvas = self | |
| 88 | 142 | NotificationCenter.default.addObserver( |
| 89 | 143 | self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification, |
| 90 | 144 | object: nil) |
| 145 | registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() } | |
| 91 | 146 | } |
| 92 | 147 | |
| 93 | 148 | required init?(coder: NSCoder) { fatalError() } |
| ... | ... | @@ -102,6 +157,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 102 | 157 | override func layoutSubviews() { |
| 103 | 158 | super.layoutSubviews() |
| 104 | 159 | metal.frame = bounds |
| 160 | // Selection views go in at the back, where the opaque page would hide them. | |
| 161 | sendSubviewToBack(metal) | |
| 105 | 162 | let scale = window?.screen.scale ?? 3 |
| 106 | 163 | metal.layer.contentsScale = scale |
| 107 | 164 | guard bounds.width > 0, bounds.height > 0 else { return } |
| ... | ... | @@ -116,9 +173,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 116 | 173 | Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page, |
| 117 | 174 | Float(bounds.width), Float(bounds.height), Float(scale)) |
| 118 | 175 | guard let handle else { return } |
| 176 | sb_view_focus(handle, false) | |
| 177 | paper() | |
| 119 | 178 | sync() |
| 120 | let fit = (bounds.width - safeAreaInsets.left - safeAreaInsets.right) / content.bounds.width | |
| 121 | zoomScale = min(1, max(0.5, fit)) | |
| 179 | let skip = margin | |
| 180 | resting = openingZoom() | |
| 181 | zoomScale = resting | |
| 182 | left = skip * resting | |
| 122 | 183 | home() |
| 123 | 184 | #if DEBUG |
| 124 | 185 | runScript() |
| ... | ... | @@ -145,6 +206,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 145 | 206 | sb_view_render(handle) |
| 146 | 207 | } |
| 147 | 208 | |
| 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 | 216 | // MARK: Scrolling and zoom |
| 149 | 217 | |
| 150 | 218 | /// 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 | 241 | Float(origin.y + contentOffset.y / zoomScale)) |
| 174 | 242 | metal.frame = bounds |
| 175 | 243 | 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 | } |
| 178 | 264 | |
| 179 | 265 | /// 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 | 270 | } |
| 185 | 271 | |
| 186 | 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 | } |
| 189 | 275 | |
| 190 | 276 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true } |
| 277 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrolled = true } | |
| 191 | 278 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { content } |
| 192 | 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 | } | |
| 194 | 321 | |
| 195 | 322 | @objc private func keyboardChanged(_ notification: Notification) { |
| 196 | 323 | guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, |
| ... | ... | @@ -205,33 +332,125 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 205 | 332 | |
| 206 | 333 | // MARK: Touch |
| 207 | 334 | |
| 208 | /// The canvas places the caret, focuses outlines and starts new ones; the system's text | |
| 209 | /// interaction takes over inside the outline already taking input. | |
| 210 | private func inActiveText(_ point: CGPoint) -> Bool { | |
| 211 | guard let handle else { return false } | |
| 335 | private func target(_ point: CGPoint) -> Target { | |
| 336 | guard let handle else { return .page } | |
| 212 | 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 | } |
| 215 | 345 | |
| 216 | 346 | override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { |
| 217 | recognizer === tap | |
| 218 | ? !inActiveText(recognizer.location(in: self)) : super.gestureRecognizerShouldBegin(recognizer) | |
| 347 | switch recognizer { | |
| 348 | case tap: | |
| 349 | [.page, .grip].contains(target(recognizer.location(in: self))) && !onHandle(recognizer.location(in: self)) | |
| 350 | case doubleTap: target(recognizer.location(in: self)) == .page | |
| 351 | case grip: target(touchStart) == .grip | |
| 352 | case handleDrag: onHandle(touchStart) | |
| 353 | default: super.gestureRecognizerShouldBegin(recognizer) | |
| 354 | } | |
| 219 | 355 | } |
| 220 | 356 | |
| 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 | 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 | } |
| 224 | 377 | |
| 225 | 378 | @objc private func tapped(_ recognizer: UITapGestureRecognizer) { |
| 226 | 379 | tap(at: recognizer.location(in: self)) |
| 227 | 380 | } |
| 228 | 381 | |
| 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 | 384 | func tap(at point: CGPoint) { |
| 385 | press(at: point) | |
| 386 | if !isFirstResponder { _ = becomeFirstResponder() } | |
| 387 | } | |
| 388 | ||
| 389 | private func press(at point: CGPoint) { | |
| 231 | 390 | guard let handle else { return } |
| 232 | 391 | let point = visible(point) |
| 233 | edit(external: true) { sb_view_tap(handle, Float(point.x), Float(point.y)) } | |
| 234 | if !isFirstResponder { _ = becomeFirstResponder() } | |
| 392 | edit(external: true) { | |
| 393 | let pressed = sb_view_press(handle, Float(point.x), Float(point.y)) | |
| 394 | return sb_view_release(handle) || pressed | |
| 395 | } | |
| 396 | } | |
| 397 | ||
| 398 | @objc private func handleDragged(_ recognizer: UIPanGestureRecognizer) { | |
| 399 | guard let range = selectedTextRange else { return } | |
| 400 | // The finger sits below the knob; aim at the text line above it. | |
| 401 | let point = recognizer.location(in: self) | |
| 402 | let aim = CGPoint(x: point.x, y: point.y - 16) | |
| 403 | switch recognizer.state { | |
| 404 | case .began: | |
| 405 | let start = caretRect(for: range.start) | |
| 406 | let end = caretRect(for: range.end) | |
| 407 | let nearStart = hypot(start.midX - touchStart.x, start.midY - touchStart.y) | |
| 408 | < hypot(end.midX - touchStart.x, end.midY - touchStart.y) | |
| 409 | handleAnchor = nearStart ? range.upper : range.lower | |
| 410 | let handle = display.handleViews.min { | |
| 411 | hypot($0.center.x - touchStart.x, $0.center.y - touchStart.y) | |
| 412 | < hypot($1.center.x - touchStart.x, $1.center.y - touchStart.y) | |
| 413 | } | |
| 414 | loupe = UITextLoupeSession.begin(at: aim, fromSelectionWidgetView: handle, in: self) | |
| 415 | editMenu.dismissMenu() | |
| 416 | fallthrough | |
| 417 | case .changed: | |
| 418 | guard let position = closestPosition(to: aim) else { return } | |
| 419 | // A handle stops one character short of the other. | |
| 420 | let step = position.offset < handleAnchor || (position.offset == handleAnchor && handleAnchor > 0) ? -1 : 1 | |
| 421 | let end = position.offset == handleAnchor ? handleAnchor + step : position.offset | |
| 422 | let moved = Range(handleAnchor, min(max(end, 0), endOfDocument.offset)) | |
| 423 | edit(external: true) { choose(moved) } | |
| 424 | let caret = caretRect(for: position) | |
| 425 | loupe?.move(to: CGPoint(x: caret.midX, y: caret.midY), withCaretRect: caret, trackingCaret: false) | |
| 426 | default: | |
| 427 | loupe?.invalidate() | |
| 428 | loupe = nil | |
| 429 | let end = caretRect(for: range.end) | |
| 430 | editMenu.presentEditMenu(with: UIEditMenuConfiguration( | |
| 431 | identifier: nil, sourcePoint: CGPoint(x: end.midX, y: caretRect(for: range.start).minY))) | |
| 432 | } | |
| 433 | } | |
| 434 | ||
| 435 | @objc private func gripped(_ recognizer: UIPanGestureRecognizer) { | |
| 436 | guard let handle else { return } | |
| 437 | let point = visible(recognizer.location(in: self)) | |
| 438 | switch recognizer.state { | |
| 439 | case .began: | |
| 440 | let start = visible(touchStart) | |
| 441 | _ = sb_view_press(handle, Float(start.x), Float(start.y)) | |
| 442 | _ = sb_view_drag(handle, Float(point.x), Float(point.y)) | |
| 443 | // The caret and handles stay behind until the outline lands. | |
| 444 | display.isActivated = false | |
| 445 | editMenu.dismissMenu() | |
| 446 | dirty = true | |
| 447 | case .changed: | |
| 448 | _ = sb_view_drag(handle, Float(point.x), Float(point.y)) | |
| 449 | dirty = true | |
| 450 | default: | |
| 451 | display.isActivated = isFirstResponder | |
| 452 | edit(external: true) { sb_view_release(handle) } | |
| 453 | } | |
| 235 | 454 | } |
| 236 | 455 | |
| 237 | 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 | 465 | inputDelegate?.textDidChange(self) |
| 247 | 466 | inputDelegate?.selectionDidChange(self) |
| 248 | 467 | } |
| 468 | dirty = true | |
| 249 | 469 | if changed { |
| 250 | 470 | sync() |
| 251 | 471 | revealCaret() |
| 252 | 472 | } |
| 473 | display.setNeedsSelectionUpdate() | |
| 474 | onChange?() | |
| 253 | 475 | } |
| 254 | 476 | |
| 255 | 477 | /// 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 | 482 | } |
| 261 | 483 | |
| 262 | 484 | private func revealCaret() { |
| 263 | guard let handle, isFirstResponder else { return } | |
| 264 | var range: [UInt32] = [0, 0] | |
| 265 | sb_selection(handle, &range) | |
| 266 | let caret = caretRect(for: Position(Int(range[1]))) | |
| 485 | guard handle != nil, isFirstResponder, let range = selectedTextRange else { return } | |
| 486 | let caret = caretRect(for: range.end) | |
| 267 | 487 | scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false) |
| 268 | 488 | } |
| 269 | 489 | |
| 490 | // MARK: Responder | |
| 491 | ||
| 270 | 492 | override var canBecomeFirstResponder: Bool { handle != nil } |
| 271 | 493 | |
| 272 | 494 | override func becomeFirstResponder() -> Bool { |
| 273 | 495 | guard super.becomeFirstResponder() else { return false } |
| 496 | if let handle { sb_view_focus(handle, true) } | |
| 274 | 497 | display.isActivated = true |
| 498 | dirty = true | |
| 499 | onChange?() | |
| 275 | 500 | return true |
| 276 | 501 | } |
| 277 | 502 | |
| 278 | 503 | override func resignFirstResponder() -> Bool { |
| 279 | 504 | guard super.resignFirstResponder() else { return false } |
| 505 | if let handle { sb_view_focus(handle, false) } | |
| 280 | 506 | display.isActivated = false |
| 507 | dirty = true | |
| 508 | onChange?() | |
| 281 | 509 | return true |
| 282 | 510 | } |
| 283 | 511 | |
| 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 | 554 | /// Pasted text takes the keyboard's language, as Windows gives the clipboard. |
| 285 | 555 | override func paste(_ sender: Any?) { |
| 286 | 556 | guard let handle, let text = UIPasteboard.general.string else { return } |
| 287 | 557 | edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") } |
| 288 | 558 | } |
| 289 | 559 | |
| 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 | 581 | // MARK: Coordinates |
| 291 | 582 | |
| 292 | 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 | 613 | return String(cString: text) |
| 323 | 614 | } |
| 324 | 615 | |
| 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 | 621 | func replace(_ range: UITextRange, withText text: String) { |
| 326 | guard let handle else { return } | |
| 327 | edit { sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text) } | |
| 622 | edit { replacing(range, with: text) } | |
| 623 | } | |
| 624 | ||
| 625 | private func choose(_ range: UITextRange) -> Bool { | |
| 626 | guard let handle else { return false } | |
| 627 | return sb_select(handle, UInt32(range.lower), UInt32(range.upper)) | |
| 328 | 628 | } |
| 329 | 629 | |
| 330 | 630 | var selectedTextRange: UITextRange? { |
| ... | ... | @@ -335,10 +635,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 335 | 635 | return Range(Int(range[0]), Int(range[1])) |
| 336 | 636 | } |
| 337 | 637 | set { |
| 338 | guard let handle, let newValue else { return } | |
| 339 | _ = sb_select(handle, UInt32(newValue.lower), UInt32(newValue.upper)) | |
| 340 | dirty = true | |
| 638 | guard let newValue else { return } | |
| 639 | _ = choose(newValue) | |
| 341 | 640 | display.setNeedsSelectionUpdate() |
| 641 | dirty = true | |
| 642 | onChange?() | |
| 342 | 643 | } |
| 343 | 644 | } |
| 344 | 645 | |
| ... | ... | @@ -425,14 +726,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 425 | 726 | var rect: [Float] = [0, 0, 0, 0] |
| 426 | 727 | guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero } |
| 427 | 728 | var caret = bounded(rect) |
| 729 | caret.origin.x -= 1 | |
| 428 | 730 | caret.size.width = 2 |
| 429 | 731 | return caret |
| 430 | 732 | } |
| 431 | 733 | |
| 432 | 734 | func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { |
| 433 | 735 | guard let handle else { return [] } |
| 434 | var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: 64) | |
| 435 | let count = min(64, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, 64)) | |
| 736 | let capacity = 256 | |
| 737 | var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: capacity) | |
| 738 | let count = min(capacity, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, capacity)) | |
| 436 | 739 | return (0..<count).map { |
| 437 | 740 | let rect = rects[$0] |
| 438 | 741 | return SelectionRect( |
| ... | ... | @@ -456,3 +759,67 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 456 | 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. | |
| 765 | private 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 | 5 | /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check |
| 6 | 6 | /// input without a finger: steps joined by `|`, such as |
| 7 | 7 | /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`. |
| 8 | /// `tap` takes points from the view's corner, `select` text offsets, `scroll` a content | |
| 9 | /// offset; `shot:a` saves the window to Documents/a.png, as a device has no screenshot command. | |
| 8 | /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, | |
| 9 | /// `scroll` a content offset; `done` ends editing, `tree` saves the view hierarchy to | |
| 10 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no | |
| 11 | /// screenshot command. | |
| 10 | 12 | func runScript() { |
| 11 | 13 | guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } |
| 12 | 14 | for (index, step) in script.split(separator: "|").enumerated() { |
| ... | ... | @@ -32,6 +34,9 @@ extension CanvasView { |
| 32 | 34 | case "unmark": unmarkText() |
| 33 | 35 | case "select": |
| 34 | 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 | 40 | selectedTextRange = textRange( |
| 36 | 41 | from: position(from: beginningOfDocument, offset: values[0])!, |
| 37 | 42 | to: position(from: beginningOfDocument, offset: values[1])!) |
| ... | ... | @@ -39,6 +44,14 @@ extension CanvasView { |
| 39 | 44 | let values = argument.split(separator: ",").compactMap { Double($0) } |
| 40 | 45 | setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true) |
| 41 | 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 | 55 | case "shot": |
| 43 | 56 | guard let window else { break } |
| 44 | 57 | let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in |
apps/ios/build-rust.sh+1-1| ... | ... | @@ -10,5 +10,5 @@ release= |
| 10 | 10 | root="$SRCROOT/../.." |
| 11 | 11 | # Xcode's SDK variables would point host build scripts at the iOS SDK. |
| 12 | 12 | exec env -i HOME="$HOME" PATH="$HOME/.cargo/bin:/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:/usr/bin:/bin:/usr/sbin:/sbin" USER="$USER" \ |
| 13 | 	CARGO_TARGET_DIR="$root/target/ios" \ | |
| 13 | 	CARGO_TARGET_DIR="$root/target/ios" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" \ | |
| 14 | 14 | 	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 | 11 | [dependencies] |
| 12 | 12 | canvas = { path = "../canvas", features = ["interaction"] } |
| 13 | 13 | draw = { path = "../draw" } |
| 14 | notebook = { path = "../notebook" } | |
| 14 | 15 | onestore = { path = "../onestore" } |
| 15 | 16 | wgpu.workspace = true |
| 16 | 17 | parley.workspace = true |
| 17 | 18 | pollster = "0.4" |
| 19 | serde = { version = "1", features = ["derive"] } | |
| 18 | 20 | serde_json = "1" |
crates/mobile/include/snowbound.h+14-3| ... | ... | @@ -8,9 +8,12 @@ |
| 8 | 8 | typedef struct Section Section; |
| 9 | 9 | typedef struct View View; |
| 10 | 10 | |
| 11 | char *sb_notebook(const char *path); | |
| 12 | ||
| 11 | 13 | Section *sb_section_open(const char *path); |
| 12 | 14 | size_t sb_section_count(const Section *section); |
| 13 | 15 | const char *sb_section_title(const Section *section, size_t index); |
| 16 | uint32_t sb_section_level(const Section *section, size_t index); | |
| 14 | 17 | void sb_section_free(Section *section); |
| 15 | 18 | |
| 16 | 19 | View *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 | 21 | void sb_view_resize(View *view, float width, float height, float scale); |
| 19 | 22 | bool sb_view_render(View *view); |
| 20 | 23 | bool sb_view_frame_pending(const View *view); |
| 24 | void sb_view_set_dark(View *view, bool dark); | |
| 25 | void sb_view_focus(View *view, bool focused); | |
| 21 | 26 | void sb_view_content(View *view, float bounds[4]); |
| 27 | bool sb_view_block(const View *view, float x, float y, float rect[4]); | |
| 22 | 28 | void sb_view_set_transform(View *view, float zoom, float x, float y); |
| 23 | bool sb_view_tap(View *view, float x, float y); | |
| 24 | bool sb_view_in_active_text(const View *view, float x, float y); | |
| 29 | uint8_t sb_view_target(const View *view, float x, float y); | |
| 30 | bool sb_view_press(View *view, float x, float y); | |
| 31 | bool sb_view_drag(View *view, float x, float y); | |
| 32 | bool sb_view_release(View *view); | |
| 33 | bool sb_view_undo(View *view, bool redo); | |
| 34 | bool sb_view_can_undo(const View *view, bool redo); | |
| 35 | bool sb_view_toggle(View *view, uint8_t toggle); | |
| 25 | 36 | char *sb_view_edit(View *view); |
| 37 | void sb_string_free(char *text); | |
| 26 | 38 | |
| 27 | 39 | uint32_t sb_text_length(const View *view); |
| 28 | 40 | char *sb_text(const View *view, uint32_t start, uint32_t end); |
| 29 | void sb_string_free(char *text); | |
| 30 | 41 | void sb_selection(const View *view, uint32_t range[2]); |
| 31 | 42 | bool sb_select(View *view, uint32_t start, uint32_t end); |
| 32 | 43 | bool sb_marked(const View *view, uint32_t range[2]); |
crates/mobile/src/lib.rs+504-300| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | //! A canvas page behind a C surface for native mobile shells: a section's pages, one drawn | |
| 2 | //! into a `CAMetalLayer`, and the active outline's text as flat UTF-16 offsets, paragraphs | |
| 3 | //! joined by `\n`, the model UIKit's `UITextInput` speaks. | |
| 1 | //! A canvas page behind a C surface for native mobile shells: a notebook's sections, a | |
| 2 | //! section's pages, one page drawn into a `CAMetalLayer`, and the active outline's text as | |
| 3 | //! the flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). | |
| 4 | 4 | //! |
| 5 | 5 | //! Lengths and positions are in the host's points; the view converts through its display |
| 6 | 6 | //! scale. Calls returning `bool` report whether the page or selection changed, after which |
| ... | ... | @@ -8,12 +8,13 @@ |
| 8 | 8 | |
| 9 | 9 | use canvas::{ |
| 10 | 10 | document::TextPosition, |
| 11 | editor::Selection, | |
| 11 | editor::{Formatting, Selection, TextOutline, Toggle}, | |
| 12 | 12 | gpu::{Paper, Viewport, page::PageScene}, |
| 13 | interaction::{Hit, PageView, Response, TextColors, page_hit_test}, | |
| 13 | interaction::{Hit, PageView, Response, TextColors}, | |
| 14 | 14 | layout::TextEngine, |
| 15 | 15 | }; |
| 16 | 16 | use draw::edit::{Key, NamedKey, SelectionUnit}; |
| 17 | use notebook::discover::{self, Folder, SectionState}; | |
| 17 | 18 | use onestore::{ |
| 18 | 19 | ExGuid, RevisionIndex, Store, |
| 19 | 20 | document::Document, |
| ... | ... | @@ -24,6 +25,7 @@ use parley::{Affinity, BoundingBox}; |
| 24 | 25 | use std::{ |
| 25 | 26 | error::Error, |
| 26 | 27 | ffi::{CStr, CString, c_char, c_void}, |
| 28 | path::Path, | |
| 27 | 29 | sync::{ |
| 28 | 30 | Arc, |
| 29 | 31 | atomic::{AtomicBool, Ordering}, |
| ... | ... | @@ -37,17 +39,99 @@ type Result<T> = std::result::Result<T, Box<dyn Error>>; |
| 37 | 39 | /// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch. |
| 38 | 40 | const POINT: f32 = 96.0 / 72.0; |
| 39 | 41 | |
| 40 | /// The platform's text interaction draws the caret and selection. | |
| 41 | const COLORS: TextColors = TextColors { | |
| 42 | caret: [0.0; 4], | |
| 43 | selection: [0.0; 4], | |
| 44 | paper: Paper::WHITE, | |
| 45 | }; | |
| 42 | /// The tab colour OneNote gives a section that stores none, as the desktop shows it. | |
| 43 | const SECTION_COLOR: u32 = 0x00e4_a88a; | |
| 44 | ||
| 45 | #[derive(serde::Serialize)] | |
| 46 | struct Listing { | |
| 47 | name: String, | |
| 48 | sections: Vec<Tab>, | |
| 49 | } | |
| 50 | ||
| 51 | #[derive(serde::Serialize)] | |
| 52 | struct 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. | |
| 65 | fn 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 | ||
| 125 | fn rgb(colorref: u32) -> [u8; 3] { | |
| 126 | let [red, green, blue, _] = colorref.to_le_bytes(); | |
| 127 | [red, green, blue] | |
| 128 | } | |
| 46 | 129 | |
| 47 | 130 | pub struct Section { |
| 48 | 131 | /// Each page with the object space holding it. |
| 49 | 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 | } |
| 52 | 136 | |
| 53 | 137 | impl Section { |
| ... | ... | @@ -55,16 +139,17 @@ impl Section { |
| 55 | 139 | let store = Store::parse(bytes)?; |
| 56 | 140 | let index = RevisionIndex::parse(&store)?; |
| 57 | 141 | let document = Document::parse(&index)?; |
| 58 | let pages = document | |
| 59 | .pages()? | |
| 60 | .into_iter() | |
| 61 | .map(|(space, id)| Ok((space, Page::from_revision(document.active(space)?, id)?))) | |
| 62 | .collect::<std::result::Result<Vec<_>, onestore::Error>>()?; | |
| 63 | let titles = pages | |
| 64 | .iter() | |
| 65 | .map(|(_, page)| CString::new(page.title.replace('\0', ""))) | |
| 66 | .collect::<std::result::Result<_, _>>()?; | |
| 67 | Ok(Self { pages, titles }) | |
| 142 | let mut pages = Vec::new(); | |
| 143 | let mut headings = Vec::new(); | |
| 144 | for (space, id) in document.pages()? { | |
| 145 | let revision = document.active(space)?; | |
| 146 | let (title, level) = Page::heading(revision, id); | |
| 147 | pages.push((space, Page::from_revision(revision, id)?)); | |
| 148 | // Titles keep OneNote's line breaks, which a one-line list shows as spaces. | |
| 149 | let title = title.replace(|char: char| char.is_control(), " "); | |
| 150 | headings.push((CString::new(title)?, level)); | |
| 151 | } | |
| 152 | Ok(Self { pages, headings }) | |
| 68 | 153 | } |
| 69 | 154 | } |
| 70 | 155 | |
| ... | ... | @@ -78,16 +163,6 @@ impl Wake for Frame { |
| 78 | 163 | } |
| 79 | 164 | } |
| 80 | 165 | |
| 81 | pub 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 | ||
| 91 | 166 | fn report<T>(result: Result<T>) -> Option<T> { |
| 92 | 167 | result.map_err(|error| eprintln!("snowbound: {error}")).ok() |
| 93 | 168 | } |
| ... | ... | @@ -96,53 +171,70 @@ fn moved(response: Response) -> bool { |
| 96 | 171 | response.changed || response.moved |
| 97 | 172 | } |
| 98 | 173 | |
| 99 | impl View { | |
| 100 | fn new( | |
| 101 | layer: *mut c_void, | |
| 102 | (space, page): (ExGuid, Page), | |
| 103 | size: [f32; 2], | |
| 104 | scale: f32, | |
| 105 | ) -> Result<Self> { | |
| 106 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | |
| 107 | // SAFETY: the host passes a live CAMetalLayer that outlives the view. | |
| 108 | let surface = unsafe { | |
| 109 | instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) | |
| 110 | }?; | |
| 111 | let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { | |
| 112 | compatible_surface: Some(&surface), | |
| 113 | ..Default::default() | |
| 114 | }))?; | |
| 115 | // The simulator's adapter falls short of wgpu's default limits. | |
| 116 | let (device, queue) = | |
| 117 | pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { | |
| 118 | required_limits: adapter.limits(), | |
| 119 | ..Default::default() | |
| 120 | }))?; | |
| 121 | let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); | |
| 122 | let config = surface | |
| 123 | .get_default_config(&adapter, pixels[0], pixels[1]) | |
| 124 | .ok_or("No supported surface configuration")?; | |
| 125 | surface.configure(&device, &config); | |
| 126 | let renderer = draw::Renderer::new(device, queue, config.format); | |
| 174 | /// What a touch lands on, so the host routes it before its gesture starts. | |
| 175 | #[repr(u8)] | |
| 176 | #[derive(Debug, PartialEq)] | |
| 177 | enum Target { | |
| 178 | /// Empty page, a picture, a check box or the date: the canvas takes a tap. | |
| 179 | Page = 0, | |
| 180 | /// The text taking input, where the platform's text interaction works. | |
| 181 | ActiveText = 1, | |
| 182 | /// Another outline's text, which a touch focuses before the platform's text interaction | |
| 183 | /// takes over. | |
| 184 | Text = 2, | |
| 185 | /// The focused outline's move or width grip. | |
| 186 | Grip = 3, | |
| 187 | } | |
| 188 | ||
| 189 | /// The page and its text model, apart from the surface it is drawn on. | |
| 190 | struct Canvas { | |
| 191 | page: PageView, | |
| 192 | /// The object space of the page shown, which its edits name. | |
| 193 | space: ExGuid, | |
| 194 | paper: Paper, | |
| 195 | } | |
| 196 | ||
| 197 | impl Canvas { | |
| 198 | fn new((space, page): (ExGuid, Page), pixels: [u32; 2], scale: f32) -> Result<Self> { | |
| 127 | 199 | let mut engine = TextEngine::default(); |
| 128 | 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 | 210 | Ok(Self { |
| 130 | page: PageView::new( | |
| 131 | editor, | |
| 132 | engine, | |
| 133 | Some((scene, [0.0; 2])), | |
| 134 | pixels, | |
| 135 | scale, | |
| 136 | Duration::from_millis(350), | |
| 137 | ), | |
| 211 | page, | |
| 138 | 212 | space, |
| 139 | surface, | |
| 140 | config, | |
| 141 | renderer, | |
| 142 | frame: Arc::default(), | |
| 213 | paper: Paper::WHITE, | |
| 143 | 214 | }) |
| 144 | 215 | } |
| 145 | 216 | |
| 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 | 238 | /// What the page took since the last call as one edit, or none when it took nothing. |
| 147 | 239 | fn edit(&mut self) -> Result<Option<Edit>> { |
| 148 | 240 | let ops = self.page.editor.take_ops()?; |
| ... | ... | @@ -173,31 +265,6 @@ impl View { |
| 173 | 265 | point.map(|value| value * self.display_scale()) |
| 174 | 266 | } |
| 175 | 267 | |
| 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 | 268 | /// The page content's extent in document points: the scroll limits of a view with no |
| 202 | 269 | /// size at scale 1. |
| 203 | 270 | fn content(&mut self) -> [f32; 4] { |
| ... | ... | @@ -218,97 +285,134 @@ impl View { |
| 218 | 285 | self.page.viewport.origin = corner.map(|value| -value * scale * zoom); |
| 219 | 286 | } |
| 220 | 287 | |
| 221 | fn tap(&mut self, point: [f32; 2]) -> Result<bool> { | |
| 222 | let mut changed = moved(self.page.pointer_moved(self.device(point))?); | |
| 223 | changed |= moved(self.page.pointer_pressed(Instant::now())?); | |
| 224 | changed |= moved(self.page.pointer_released()?); | |
| 225 | // A finger leaves no hover behind. | |
| 226 | let _ = self.page.pointer_left(); | |
| 227 | Ok(changed) | |
| 288 | fn outline(&self, id: ExGuid) -> Option<&TextOutline> { | |
| 289 | let editor = &self.page.editor; | |
| 290 | editor | |
| 291 | .visible_outlines() | |
| 292 | .chain(editor.caret_outline()) | |
| 293 | .find(|outline| outline.id == id) | |
| 228 | 294 | } |
| 229 | 295 | |
| 230 | /// Whether `point` lands on the text of the outline taking input, where the platform's | |
| 231 | /// own text interaction places the caret. | |
| 232 | fn in_active_text(&self, point: [f32; 2]) -> bool { | |
| 233 | let point = self.page.viewport.document_point(self.device(point)); | |
| 234 | let pixel = self.display_scale() / self.page.viewport.scale; | |
| 235 | matches!( | |
| 236 | page_hit_test(&self.page.editor, self.page.scene.as_ref(), point, pixel), | |
| 237 | Some(Hit::Text { id, .. }) if id == self.page.editor.active_outline().id | |
| 238 | ) | |
| 239 | } | |
| 240 | ||
| 241 | /// UTF-16 lengths of the active outline's text paragraphs. | |
| 242 | fn lengths(&self) -> Result<Vec<u32>> { | |
| 243 | self.page | |
| 244 | .editor | |
| 245 | .active_outline() | |
| 246 | .document() | |
| 247 | .paragraphs() | |
| 248 | .map(|paragraph| Ok(paragraph.utf16_offset(paragraph.text().len())?)) | |
| 249 | .collect() | |
| 250 | } | |
| 251 | ||
| 252 | fn position(&self, flat: u32) -> Result<TextPosition> { | |
| 253 | let lengths = self.lengths()?; | |
| 254 | let mut rest = flat; | |
| 255 | for (paragraph, length) in lengths.iter().enumerate() { | |
| 256 | if rest <= *length { | |
| 257 | return Ok(TextPosition { | |
| 258 | paragraph, | |
| 259 | offset: rest, | |
| 260 | }); | |
| 296 | fn target(&self, point: [f32; 2]) -> Target { | |
| 297 | match self.page.hit(self.device(point)) { | |
| 298 | Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, | |
| 299 | Some(Hit::Text { id, point }) | |
| 300 | if !self | |
| 301 | .outline(id) | |
| 302 | .is_some_and(|outline| outline.contains_extension(point)) => | |
| 303 | { | |
| 304 | if id == self.page.editor.active_outline().id { | |
| 305 | Target::ActiveText | |
| 306 | } else { | |
| 307 | Target::Text | |
| 308 | } | |
| 261 | 309 | } |
| 262 | rest -= length + 1; | |
| 310 | _ => Target::Page, | |
| 263 | 311 | } |
| 264 | Ok(TextPosition { | |
| 265 | paragraph: lengths.len() - 1, | |
| 266 | offset: lengths[lengths.len() - 1], | |
| 312 | } | |
| 313 | ||
| 314 | /// Runs `change` on the page, then puts the view back where the host's scroll view has | |
| 315 | /// it: the canvas clamps and reveals knowing neither the navigation bar nor the keyboard, | |
| 316 | /// and the host reveals the caret itself. | |
| 317 | fn hold<T>(&mut self, change: impl FnOnce(&mut PageView) -> Result<T>) -> Result<T> { | |
| 318 | let Viewport { scale, origin, .. } = self.page.viewport; | |
| 319 | let result = change(&mut self.page); | |
| 320 | self.page.viewport.scale = scale; | |
| 321 | self.page.viewport.origin = origin; | |
| 322 | result | |
| 323 | } | |
| 324 | ||
| 325 | /// A finger down at `point`: the start of a tap or of a grip drag. | |
| 326 | fn press(&mut self, point: [f32; 2]) -> Result<bool> { | |
| 327 | let point = self.device(point); | |
| 328 | self.hold(|page| { | |
| 329 | let changed = moved(page.pointer_moved(point)?); | |
| 330 | Ok(moved(page.pointer_pressed(Instant::now())?) || changed) | |
| 267 | 331 | }) |
| 268 | 332 | } |
| 269 | 333 | |
| 270 | fn flat(&self, position: TextPosition) -> Result<u32> { | |
| 271 | let before: u32 = self.lengths()?[..position.paragraph] | |
| 272 | .iter() | |
| 273 | .map(|length| length + 1) | |
| 274 | .sum(); | |
| 275 | Ok(before + position.offset) | |
| 334 | fn drag(&mut self, point: [f32; 2]) -> Result<bool> { | |
| 335 | let point = self.device(point); | |
| 336 | self.hold(|page| Ok(moved(page.pointer_moved(point)?))) | |
| 276 | 337 | } |
| 277 | 338 | |
| 278 | fn length(&self) -> Result<u32> { | |
| 279 | let lengths = self.lengths()?; | |
| 280 | Ok(lengths.iter().sum::<u32>() + lengths.len() as u32 - 1) | |
| 339 | fn release(&mut self) -> Result<bool> { | |
| 340 | self.hold(|page| { | |
| 341 | let changed = moved(page.pointer_released()?); | |
| 342 | // A finger leaves no hover behind. | |
| 343 | let _ = page.pointer_left(); | |
| 344 | Ok(changed) | |
| 345 | }) | |
| 281 | 346 | } |
| 282 | 347 | |
| 283 | fn text(&self, range: [u32; 2]) -> Result<String> { | |
| 284 | let range = self.position(range[0])?..self.position(range[1])?; | |
| 285 | Ok(self | |
| 348 | /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom. | |
| 349 | fn block(&self, point: [f32; 2]) -> Option<[f32; 4]> { | |
| 350 | let [x, y] = self | |
| 351 | .page | |
| 352 | .viewport | |
| 353 | .document_point(self.device(point)) | |
| 354 | .map(f64::from); | |
| 355 | let distance = |rect: &BoundingBox| { | |
| 356 | let dx = (rect.x0 - x).max(x - rect.x1).max(0.0); | |
| 357 | let dy = (rect.y0 - y).max(y - rect.y1).max(0.0); | |
| 358 | dx.hypot(dy) | |
| 359 | }; | |
| 360 | let rect = self | |
| 286 | 361 | .page |
| 287 | 362 | .editor |
| 288 | .active_outline() | |
| 289 | .document() | |
| 290 | .slice(range)? | |
| 291 | .iter() | |
| 292 | .map(|paragraph| paragraph.text()) | |
| 293 | .collect::<Vec<_>>() | |
| 294 | .join("\n")) | |
| 363 | .visible_outlines() | |
| 364 | .map(TextOutline::bounds) | |
| 365 | .min_by(|a, b| distance(a).total_cmp(&distance(b)))?; | |
| 366 | Some([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32 * POINT)) | |
| 367 | } | |
| 368 | ||
| 369 | fn active(&self) -> &TextOutline { | |
| 370 | self.page.editor.active_outline() | |
| 371 | } | |
| 372 | ||
| 373 | fn text(&self, range: [u32; 2]) -> String { | |
| 374 | let units: Vec<u16> = self.active().shown_text().encode_utf16().collect(); | |
| 375 | let [start, end] = range.map(|offset| (offset as usize).min(units.len())); | |
| 376 | String::from_utf16_lossy(&units[start..end.max(start)]) | |
| 377 | } | |
| 378 | ||
| 379 | fn length(&self) -> u32 { | |
| 380 | self.active().shown_text().encode_utf16().count() as u32 | |
| 295 | 381 | } |
| 296 | 382 | |
| 297 | 383 | fn selection(&self) -> Result<[u32; 2]> { |
| 298 | 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 | } |
| 301 | 399 | |
| 302 | 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 | 402 | Ok(self.page.editor.select(Selection::from(positions))?) |
| 305 | 403 | } |
| 306 | 404 | |
| 307 | 405 | fn marked(&self) -> Result<Option<[u32; 2]>> { |
| 406 | let outline = self.active(); | |
| 308 | 407 | self.page |
| 309 | 408 | .editor |
| 310 | 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 | 416 | .transpose() |
| 313 | 417 | } |
| 314 | 418 | |
| ... | ... | @@ -325,23 +429,25 @@ impl View { |
| 325 | 429 | .map_or(text.len(), |(byte, _)| byte) |
| 326 | 430 | }; |
| 327 | 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 | } |
| 330 | 434 | |
| 331 | 435 | fn insert(&mut self, text: String) -> Result<bool> { |
| 332 | let response = if text == "\n" { | |
| 333 | self.page.key(&Key::Named(NamedKey::Enter), None)? | |
| 334 | } else { | |
| 335 | self.page.commit_text(text)? | |
| 336 | }; | |
| 337 | Ok(moved(response)) | |
| 436 | self.hold(|page| { | |
| 437 | Ok(moved(if text == "\n" { | |
| 438 | page.key(&Key::Named(NamedKey::Enter), None)? | |
| 439 | } else { | |
| 440 | page.commit_text(text)? | |
| 441 | })) | |
| 442 | }) | |
| 338 | 443 | } |
| 339 | 444 | |
| 340 | /// A document rectangle in the active outline, in the view's points as `[x, y, width, | |
| 341 | /// height]`. | |
| 342 | fn view_rect(&self, rect: BoundingBox, origin: [f32; 2]) -> [f32; 4] { | |
| 445 | /// A rectangle in the active outline's coordinates, in the view's points as `[x, y, | |
| 446 | /// width, height]`. | |
| 447 | fn view_rect(&self, rect: BoundingBox) -> [f32; 4] { | |
| 343 | 448 | let viewport = self.page.viewport; |
| 344 | 449 | let points = self.display_scale(); |
| 450 | let origin = self.active().origin(); | |
| 345 | 451 | let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]]; |
| 346 | 452 | [ |
| 347 | 453 | (x * viewport.scale + viewport.origin[0]) / points, |
| ... | ... | @@ -351,88 +457,135 @@ impl View { |
| 351 | 457 | ] |
| 352 | 458 | } |
| 353 | 459 | |
| 354 | fn caret_rect(&self, flat: u32) -> Result<[f32; 4]> { | |
| 355 | let position = self.position(flat)?; | |
| 356 | let outline = self.page.editor.active_outline(); | |
| 357 | let paragraph = outline.paragraph_layout(position.paragraph)?; | |
| 358 | let visible = paragraph.projection.visible_offset(position.offset)?; | |
| 359 | let byte = paragraph.projection.text().byte_offset(visible)?; | |
| 360 | let caret = paragraph | |
| 361 | .text | |
| 362 | .caret(paragraph.text.cursor(byte, Affinity::Downstream), 1.0); | |
| 363 | let origin = outline.origin(); | |
| 364 | Ok(self.view_rect( | |
| 365 | caret, | |
| 366 | [ | |
| 367 | origin[0] + paragraph.origin[0], | |
| 368 | origin[1] + paragraph.origin[1], | |
| 369 | ], | |
| 370 | )) | |
| 460 | fn caret_rect(&self, offset: u32) -> Result<[f32; 4]> { | |
| 461 | let position = self.active().utf16_position(offset)?; | |
| 462 | let caret = self | |
| 463 | .page | |
| 464 | .editor | |
| 465 | .caret_at(position, Affinity::Downstream, 1.0)?; | |
| 466 | Ok(self.view_rect(caret)) | |
| 371 | 467 | } |
| 372 | 468 | |
| 373 | 469 | /// The selection rectangles of a range, one or more per line. |
| 374 | 470 | fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> { |
| 375 | let [start, end] = [self.position(range[0])?, self.position(range[1])?]; | |
| 376 | let outline = self.page.editor.active_outline(); | |
| 377 | let origin = outline.origin(); | |
| 378 | let mut rects = Vec::new(); | |
| 379 | for (index, paragraph) in outline.layouts() { | |
| 380 | if index < start.paragraph || index > end.paragraph { | |
| 381 | continue; | |
| 382 | } | |
| 383 | let cursor = |offset: u32, affinity| -> Result<_> { | |
| 384 | let visible = paragraph.projection.visible_offset(offset)?; | |
| 385 | let byte = paragraph.projection.text().byte_offset(visible)?; | |
| 386 | Ok(paragraph.text.cursor(byte, affinity)) | |
| 387 | }; | |
| 388 | let first = cursor( | |
| 389 | if index == start.paragraph { | |
| 390 | start.offset | |
| 391 | } else { | |
| 392 | 0 | |
| 393 | }, | |
| 394 | Affinity::Downstream, | |
| 395 | )?; | |
| 396 | let last = if index == end.paragraph { | |
| 397 | cursor(end.offset, Affinity::Upstream)? | |
| 398 | } else { | |
| 399 | paragraph | |
| 400 | .text | |
| 401 | .cursor(paragraph.projection.text().text().len(), Affinity::Upstream) | |
| 402 | }; | |
| 403 | let origin = [ | |
| 404 | origin[0] + paragraph.origin[0], | |
| 405 | origin[1] + paragraph.origin[1], | |
| 406 | ]; | |
| 407 | for rect in paragraph | |
| 408 | .text | |
| 409 | .selection(parley::editing::Selection::new(first, last)) | |
| 410 | { | |
| 411 | rects.push(self.view_rect(rect, origin)); | |
| 412 | } | |
| 413 | } | |
| 414 | Ok(rects) | |
| 471 | let rects = self.page.editor.range_rects(Selection { | |
| 472 | positions: self.positions(range)?, | |
| 473 | affinities: [Affinity::Downstream, Affinity::Upstream], | |
| 474 | })?; | |
| 475 | Ok(rects.into_iter().map(|rect| self.view_rect(rect)).collect()) | |
| 415 | 476 | } |
| 416 | 477 | |
| 417 | 478 | fn closest(&self, point: [f32; 2]) -> Result<u32> { |
| 418 | 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 | 481 | let selection = self.page.editor.selection_at( |
| 421 | 482 | point[0] - origin[0], |
| 422 | 483 | point[1] - origin[1], |
| 423 | 484 | SelectionUnit::Grapheme, |
| 424 | 485 | )?; |
| 425 | self.flat(selection.positions[1]) | |
| 486 | Ok(self.active().utf16_offset(selection.positions[1])?) | |
| 487 | } | |
| 488 | } | |
| 489 | ||
| 490 | pub struct View { | |
| 491 | canvas: Canvas, | |
| 492 | surface: wgpu::Surface<'static>, | |
| 493 | config: wgpu::SurfaceConfiguration, | |
| 494 | renderer: draw::Renderer, | |
| 495 | frame: Arc<Frame>, | |
| 496 | } | |
| 497 | ||
| 498 | impl 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 | } |
| 428 | 557 | |
| 558 | fn 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 | ||
| 565 | fn 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 | 573 | /// # Safety |
| 430 | /// `path` is a NUL-terminated path. | |
| 574 | /// `path` is NUL-terminated UTF-8. | |
| 575 | #[unsafe(no_mangle)] | |
| 576 | pub 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 | 585 | #[unsafe(no_mangle)] |
| 432 | 586 | pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section { |
| 433 | let path = unsafe { CStr::from_ptr(path) }; | |
| 434 | 587 | report( |
| 435 | std::fs::read(path.to_str().unwrap_or_default()) | |
| 588 | std::fs::read(string(path)) | |
| 436 | 589 | .map_err(Into::into) |
| 437 | 590 | .and_then(|bytes| Section::open(&bytes)), |
| 438 | 591 | ) |
| ... | ... | @@ -449,7 +602,13 @@ pub extern "C" fn sb_section_count(section: &Section) -> usize { |
| 449 | 602 | /// The page's title, alive as long as the section. |
| 450 | 603 | #[unsafe(no_mangle)] |
| 451 | 604 | pub 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)] | |
| 610 | pub extern "C" fn sb_section_level(section: &Section, index: usize) -> u32 { | |
| 611 | section.headings[index].1 | |
| 453 | 612 | } |
| 454 | 613 | |
| 455 | 614 | /// # Safety |
| ... | ... | @@ -492,10 +651,11 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) { |
| 492 | 651 | #[unsafe(no_mangle)] |
| 493 | 652 | pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) { |
| 494 | 653 | let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32); |
| 495 | if scale != view.display_scale() { | |
| 496 | let _ = report(view.page.scale_factor_changed(scale)); | |
| 654 | if scale != view.canvas.display_scale() { | |
| 655 | let _ = report(view.canvas.page.scale_factor_changed(scale)); | |
| 497 | 656 | } |
| 498 | let _ = report(view.page.resized(pixels)); | |
| 657 | let page = &mut view.canvas.page; | |
| 658 | let _ = report(page.resized(pixels)); | |
| 499 | 659 | view.config.width = pixels[0]; |
| 500 | 660 | view.config.height = pixels[1]; |
| 501 | 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 | 672 | view.frame.0.swap(false, Ordering::Acquire) |
| 513 | 673 | } |
| 514 | 674 | |
| 675 | /// Draws the page on the desktop's dark paper, or on white. | |
| 676 | #[unsafe(no_mangle)] | |
| 677 | pub 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)] | |
| 683 | pub 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 | 687 | /// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right |
| 516 | 688 | /// including the margin OneNote scrolls past the last object. |
| 517 | 689 | #[unsafe(no_mangle)] |
| 518 | 690 | pub 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)] | |
| 697 | pub 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 | } |
| 521 | 703 | |
| 522 | 704 | /// Zooms and scrolls so the page point `x`, `y` sits at the view's corner; the host's |
| 523 | 705 | /// scroll view owns the limits and rubber-banding. |
| 524 | 706 | #[unsafe(no_mangle)] |
| 525 | 707 | pub 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 | } |
| 528 | 710 | |
| 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 | 714 | #[unsafe(no_mangle)] |
| 531 | pub extern "C" fn sb_view_tap(view: &mut View, x: f32, y: f32) -> bool { | |
| 532 | report(view.tap([x, y])).unwrap_or(false) | |
| 715 | pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { | |
| 716 | view.canvas.target([x, y]) as u8 | |
| 533 | 717 | } |
| 534 | 718 | |
| 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 | 721 | #[unsafe(no_mangle)] |
| 536 | pub extern "C" fn sb_view_in_active_text(view: &View, x: f32, y: f32) -> bool { | |
| 537 | view.in_active_text([x, y]) | |
| 722 | pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool { | |
| 723 | report(view.canvas.press([x, y])).unwrap_or(false) | |
| 538 | 724 | } |
| 539 | 725 | |
| 540 | 726 | #[unsafe(no_mangle)] |
| 541 | pub extern "C" fn sb_text_length(view: &View) -> u32 { | |
| 542 | report(view.length()).unwrap_or(0) | |
| 727 | pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool { | |
| 728 | report(view.canvas.drag([x, y])).unwrap_or(false) | |
| 543 | 729 | } |
| 544 | 730 | |
| 545 | /// The text in `start..end`, freed with `sb_string_free`. | |
| 546 | 731 | #[unsafe(no_mangle)] |
| 547 | pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char { | |
| 548 | report(view.text([start, end])) | |
| 549 | .and_then(|text| CString::new(text.replace('\0', "")).ok()) | |
| 550 | .map_or(std::ptr::null_mut(), CString::into_raw) | |
| 732 | pub extern "C" fn sb_view_release(view: &mut View) -> bool { | |
| 733 | report(view.canvas.release()).unwrap_or(false) | |
| 734 | } | |
| 735 | ||
| 736 | /// Undoes the last edit, or with `redo` redoes the last undone one. | |
| 737 | #[unsafe(no_mangle)] | |
| 738 | pub 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)] | |
| 743 | pub 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)] | |
| 754 | pub 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 | } |
| 552 | 766 | |
| 553 | 767 | /// 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 | 769 | /// stored, in which case the page should be opened again. |
| 556 | 770 | #[unsafe(no_mangle)] |
| 557 | 771 | pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char { |
| 558 | report(view.edit()) | |
| 772 | report(view.canvas.edit()) | |
| 559 | 773 | .flatten() |
| 560 | .and_then(|edit| CString::new(serde_json::to_string(&edit).ok()?).ok()) | |
| 561 | .map_or(std::ptr::null_mut(), CString::into_raw) | |
| 774 | .and_then(|edit| serde_json::to_string(&edit).ok()) | |
| 775 | .map_or(std::ptr::null_mut(), owned) | |
| 562 | 776 | } |
| 563 | 777 | |
| 564 | 778 | /// # 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 | 780 | #[unsafe(no_mangle)] |
| 567 | 781 | pub unsafe extern "C" fn sb_string_free(text: *mut c_char) { |
| 568 | 782 | drop(unsafe { CString::from_raw(text) }); |
| 569 | 783 | } |
| 570 | 784 | |
| 785 | #[unsafe(no_mangle)] | |
| 786 | pub 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)] | |
| 792 | pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char { | |
| 793 | owned(view.canvas.text([start, end])) | |
| 794 | } | |
| 795 | ||
| 571 | 796 | #[unsafe(no_mangle)] |
| 572 | 797 | pub 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 | 799 | *range = selection; |
| 575 | 800 | } |
| 576 | 801 | } |
| 577 | 802 | |
| 578 | 803 | #[unsafe(no_mangle)] |
| 579 | 804 | pub 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 | } |
| 582 | 807 | |
| 583 | 808 | #[unsafe(no_mangle)] |
| 584 | 809 | pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool { |
| 585 | report(view.marked()) | |
| 810 | report(view.canvas.marked()) | |
| 586 | 811 | .flatten() |
| 587 | 812 | .map(|marked| *range = marked) |
| 588 | 813 | .is_some() |
| ... | ... | @@ -597,15 +822,16 @@ pub unsafe extern "C" fn sb_set_marked( |
| 597 | 822 | selected_start: u32, |
| 598 | 823 | selected_end: u32, |
| 599 | 824 | ) -> bool { |
| 600 | let text = unsafe { CStr::from_ptr(text) } | |
| 601 | .to_string_lossy() | |
| 602 | .into_owned(); | |
| 603 | report(view.set_marked(text, [selected_start, selected_end])).unwrap_or(false) | |
| 825 | report( | |
| 826 | view.canvas | |
| 827 | .set_marked(string(text), [selected_start, selected_end]), | |
| 828 | ) | |
| 829 | .unwrap_or(false) | |
| 604 | 830 | } |
| 605 | 831 | |
| 606 | 832 | #[unsafe(no_mangle)] |
| 607 | 833 | pub extern "C" fn sb_unmark(view: &mut View) { |
| 608 | view.page.editor.finish_composition(); | |
| 834 | view.canvas.page.editor.finish_composition(); | |
| 609 | 835 | } |
| 610 | 836 | |
| 611 | 837 | /// Types `text`, a lone `\n` as the Return key. |
| ... | ... | @@ -614,13 +840,10 @@ pub extern "C" fn sb_unmark(view: &mut View) { |
| 614 | 840 | /// `text` is NUL-terminated UTF-8. |
| 615 | 841 | #[unsafe(no_mangle)] |
| 616 | 842 | pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool { |
| 617 | let text = unsafe { CStr::from_ptr(text) } | |
| 618 | .to_string_lossy() | |
| 619 | .into_owned(); | |
| 620 | report(view.insert(text)).unwrap_or(false) | |
| 843 | report(view.canvas.insert(string(text))).unwrap_or(false) | |
| 621 | 844 | } |
| 622 | 845 | |
| 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 | 848 | /// # Safety |
| 626 | 849 | /// `text` is NUL-terminated UTF-8. |
| ... | ... | @@ -631,12 +854,11 @@ pub unsafe extern "C" fn sb_replace( |
| 631 | 854 | end: u32, |
| 632 | 855 | text: *const c_char, |
| 633 | 856 | ) -> bool { |
| 634 | let text = unsafe { CStr::from_ptr(text) } | |
| 635 | .to_string_lossy() | |
| 636 | .into_owned(); | |
| 857 | let canvas = &mut view.canvas; | |
| 637 | 858 | report( |
| 638 | view.select([start, end]) | |
| 639 | .and_then(|()| Ok(moved(view.page.commit_text(text)?))), | |
| 859 | canvas | |
| 860 | .select([start, end]) | |
| 861 | .and_then(|()| canvas.hold(|page| Ok(moved(page.commit_text(string(text))?)))), | |
| 640 | 862 | ) |
| 641 | 863 | .unwrap_or(false) |
| 642 | 864 | } |
| ... | ... | @@ -651,12 +873,10 @@ pub unsafe extern "C" fn sb_paste( |
| 651 | 873 | text: *const c_char, |
| 652 | 874 | language: *const c_char, |
| 653 | 875 | ) -> bool { |
| 654 | let text = unsafe { CStr::from_ptr(text) }.to_string_lossy(); | |
| 655 | let language = unsafe { CStr::from_ptr(language) }.to_string_lossy(); | |
| 876 | let (text, language) = (string(text), canvas::language::lcid(&string(language))); | |
| 656 | 877 | report( |
| 657 | view.page | |
| 658 | .paste(&text, canvas::language::lcid(&language)) | |
| 659 | .map(moved), | |
| 878 | view.canvas | |
| 879 | .hold(|page| page.paste(&text, language).map(moved)), | |
| 660 | 880 | ) |
| 661 | 881 | .unwrap_or(false) |
| 662 | 882 | } |
| ... | ... | @@ -664,9 +884,8 @@ pub unsafe extern "C" fn sb_paste( |
| 664 | 884 | #[unsafe(no_mangle)] |
| 665 | 885 | pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { |
| 666 | 886 | report( |
| 667 | view.page | |
| 668 | .key(&Key::Named(NamedKey::Backspace), None) | |
| 669 | .map(moved), | |
| 887 | view.canvas | |
| 888 | .hold(|page| page.key(&Key::Named(NamedKey::Backspace), None).map(moved)), | |
| 670 | 889 | ) |
| 671 | 890 | .unwrap_or(false) |
| 672 | 891 | } |
| ... | ... | @@ -674,7 +893,7 @@ pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { |
| 674 | 893 | /// The caret at `offset` as `[x, y, width, height]` in the view's points. |
| 675 | 894 | #[unsafe(no_mangle)] |
| 676 | 895 | pub 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 | 897 | .map(|caret| *rect = caret) |
| 679 | 898 | .is_some() |
| 680 | 899 | } |
| ... | ... | @@ -691,7 +910,7 @@ pub unsafe extern "C" fn sb_range_rects( |
| 691 | 910 | rects: *mut [f32; 4], |
| 692 | 911 | capacity: usize, |
| 693 | 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 | 914 | for (index, rect) in found.iter().take(capacity).enumerate() { |
| 696 | 915 | unsafe { rects.add(index).write(*rect) }; |
| 697 | 916 | } |
| ... | ... | @@ -700,23 +919,8 @@ pub unsafe extern "C" fn sb_range_rects( |
| 700 | 919 | |
| 701 | 920 | #[unsafe(no_mangle)] |
| 702 | 921 | pub 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 | } |
| 705 | 924 | |
| 706 | 925 | #[cfg(test)] |
| 707 | mod 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 | } | |
| 926 | mod tests; |
crates/mobile/src/tests.rs created+160| ... | ... | @@ -0,0 +1,160 @@ |
| 1 | use super::*; | |
| 2 | ||
| 3 | const 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. | |
| 10 | fn 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. | |
| 23 | fn 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] | |
| 37 | fn 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] | |
| 49 | fn 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] | |
| 68 | fn 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] | |
| 92 | fn 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] | |
| 124 | fn 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] | |
| 149 | fn 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 | } |