diff --git a/apps/ios/Snowbound.xcodeproj/project.pbxproj b/apps/ios/Snowbound.xcodeproj/project.pbxproj index ade163a6cf6230b92abeae2b766737955dd236de..7d83a972b8732bc7904b5361fa1deea4c1950680 100644 --- a/apps/ios/Snowbound.xcodeproj/project.pbxproj +++ b/apps/ios/Snowbound.xcodeproj/project.pbxproj @@ -7,12 +7,12 @@ objects = { /* Begin PBXBuildFile section */ - 5B0000000000000000000101 /* Features.one in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Features.one */; }; + 5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 5B0000000000000000000201 /* Features.one */ = {isa = PBXFileReference; lastKnownFileType = file; name = Features.one; path = "../../corpus/media-edit/candidate/Features.one"; sourceTree = SOURCE_ROOT; }; + 5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; }; 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = ""; }; 5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = ""; }; @@ -41,7 +41,7 @@ isa = PBXGroup; children = ( 5B0000000000000000000300 /* Snowbound */, - 5B0000000000000000000201 /* Features.one */, + 5B0000000000000000000201 /* Sample */, 5B0000000000000000000202 /* Info.plist */, 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */, 5B0000000000000000000204 /* build-rust.sh */, @@ -120,7 +120,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5B0000000000000000000101 /* Features.one in Resources */, + 5B0000000000000000000101 /* Sample in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index a6f5061283c3e4975dc6e68cde6692b6494dd941..ffc4257758c4ff3a0c04f76b444a8f862fcc8530 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -14,117 +14,329 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } } -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { +/// Notebook, section and page columns as Notes lays out folders, notes and a note: side by +/// side on a wide screen, a navigation stack on a phone. +final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate { var window: UIWindow? + private let split = UISplitViewController(style: .tripleColumn) + private let sections = SectionsViewController() + private let pages = PagesViewController() + private var showingPage = false func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { guard let scene = scene as? UIWindowScene else { return } let window = UIWindow(windowScene: scene) - let path = ProcessInfo.processInfo.environment["SNOWBOUND_SECTION"] - ?? Bundle.main.path(forResource: "Features", ofType: "one")! - let pages = PagesViewController(section: Section(path: path)) - window.rootViewController = UINavigationController(rootViewController: pages) + split.delegate = self + split.preferredDisplayMode = .oneBesideSecondary + split.preferredSplitBehavior = .tile + split.setViewController(sections, for: .primary) + split.setViewController(pages, for: .supplementary) + split.setViewController(UIViewController(), for: .secondary) + sections.onOpen = { [weak self] section in self?.show(section: section) } + pages.onOpen = { [weak self] section, page in self?.show(section: section, page: page) } + window.rootViewController = split window.makeKeyAndVisible() self.window = window - if let page = ProcessInfo.processInfo.environment["SNOWBOUND_PAGE"].flatMap(Int.init) { - pages.show(page: page, animated: false) + sections.load(Library.opening()) + let environment = ProcessInfo.processInfo.environment + // The split view collapses into a stack once the window is on screen. + DispatchQueue.main.async { [self] in + if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init), let notebook = sections.notebook, + index < notebook.sections.count + { + show(section: notebook.sections[index]) + if let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), let section = pages.section, + page < section.headings.count + { + show(section: section, page: page) + } + } } } + + private func show(section tab: Tab) { + pages.load(tab) + split.show(.supplementary) + } + + private func show(section: Section, page: Int) { + showingPage = true + let controller = PageViewController(section: section, page: page) + split.setViewController(UINavigationController(rootViewController: controller), for: .secondary) + split.show(.secondary) + } + + /// A phone opens on the notebook's sections, as Notes opens on its folders. + func splitViewController( + _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column + ) -> UISplitViewController.Column { + showingPage ? proposedTopColumn : .primary + } +} + +/// A notebook's listing from `sb_notebook`. +struct Notebook: Decodable { + let name: String + let sections: [Tab] +} + +struct Tab: Decodable { + let name: String + let path: String + /// The section group holding it, `/`-separated; empty at the notebook's top. + let group: String + let color: [UInt8] + let readable: Bool + + var uiColor: UIColor { + UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1) + } +} + +/// Where the notebook shown comes from: the one last opened from Files, kept as a bookmark, +/// or the bundled sample. +enum Library { + private static let bookmarkKey = "notebook" + /// The Files location being read, held open while it is shown. + private static var accessed: URL? + + static func opening() -> Notebook? { + if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] { return read(path) } + var stale = false + if let data = UserDefaults.standard.data(forKey: bookmarkKey), + let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale), + let notebook = open(url) + { + return notebook + } + // The bundled folder keeps its corpus name. + return Bundle.main.path(forResource: "notebook", ofType: nil).flatMap(read).map { + Notebook(name: "Sample", sections: $0.sections) + } + } + + /// A notebook folder or section file chosen in Files, remembered for the next launch. + static func open(_ url: URL) -> Notebook? { + accessed?.stopAccessingSecurityScopedResource() + accessed = url.startAccessingSecurityScopedResource() ? url : nil + guard let notebook = read(url.path) else { return nil } + if let data = try? url.bookmarkData() { UserDefaults.standard.set(data, forKey: bookmarkKey) } + return notebook + } + + private static func read(_ path: String) -> Notebook? { + guard let json = sb_notebook(path) else { return nil } + defer { sb_string_free(json) } + return try? JSONDecoder().decode(Notebook.self, from: Data(String(cString: json).utf8)) + } } /// A parsed .one section; pages are read-only copies until saving lands. final class Section { let handle: OpaquePointer? let name: String + let color: UIColor + /// Each page's title and level in the page list, 1 at the top. + let headings: [(title: String, level: Int)] - init(path: String) { - handle = sb_section_open(path) - name = (path as NSString).lastPathComponent.replacingOccurrences(of: ".one", with: "") + init(_ tab: Tab) { + handle = sb_section_open(tab.path) + name = tab.name + color = tab.uiColor + headings = handle.map { handle in + (0.. Void)? - init(section: Section) { - self.section = section - titles = section.titles + init() { super.init(style: .insetGrouped) - title = section.name + navigationItem.largeTitleDisplayMode = .always } required init?(coder: NSCoder) { fatalError() } override func viewDidLoad() { super.viewDidLoad() - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") - navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "Open", image: UIImage(systemName: "folder"), target: self, action: #selector(open)) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "section") + let open = UIBarButtonItem( + title: "Open Notebook", image: UIImage(systemName: "folder"), target: self, action: #selector(open)) + navigationItem.rightBarButtonItem = open + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.navigationBar.prefersLargeTitles = true + } + + func load(_ notebook: Notebook?) { + self.notebook = notebook + title = notebook?.name + var groups: [(name: String, sections: [Tab])] = [] + for tab in notebook?.sections ?? [] { + if groups.last?.name == tab.group { + groups[groups.count - 1].sections.append(tab) + } else { + groups.append((tab.group, [tab])) + } + } + self.groups = groups + tableView.reloadData() + var empty = UIContentUnavailableConfiguration.empty() + empty.text = notebook == nil ? "No Notebook Open" : "No Sections" + empty.secondaryText = notebook == nil ? "Open a notebook folder or section from Files." : nil + contentUnavailableConfiguration = groups.isEmpty ? empty : nil } @objc private func open() { let picker = UIDocumentPickerViewController( - forOpeningContentTypes: [UTType(filenameExtension: "one") ?? .data], asCopy: true) + forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) picker.delegate = self present(picker, animated: true) } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return } - section = Section(path: url.path) - titles = section.titles + guard let notebook = Library.open(url) else { + let alert = UIAlertController( + title: "Can’t Open Notebook", message: "Choose a OneNote notebook folder or a section file.", + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + return + } + load(notebook) + } + + override func numberOfSections(in tableView: UITableView) -> Int { groups.count } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + groups[section].name.isEmpty ? nil : groups[section].name.replacingOccurrences(of: "/", with: " › ") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + groups[section].sections.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let tab = groups[indexPath.section].sections[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: "section", for: indexPath) + var content = cell.defaultContentConfiguration() + content.text = tab.name + content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill") + content.imageProperties.tintColor = tab.uiColor + if !tab.readable { + content.secondaryText = "Can’t be opened here" + content.textProperties.color = .secondaryLabel + } + cell.contentConfiguration = content + cell.accessoryType = tab.readable ? .disclosureIndicator : .none + cell.selectionStyle = tab.readable ? .default : .none + return cell + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + groups[indexPath.section].sections[indexPath.row].readable ? indexPath : nil + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + onOpen?(groups[indexPath.section].sections[indexPath.row]) + } +} + +final class PagesViewController: UITableViewController { + private(set) var section: Section? + var onOpen: ((Section, Int) -> Void)? + + init() { + super.init(style: .plain) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") + } + + func load(_ tab: Tab) { + let section = Section(tab) + self.section = section title = section.name tableView.reloadData() - } - - func show(page: Int, animated: Bool) { - guard page < titles.count else { return } - navigationController?.pushViewController( - PageViewController(section: section, page: page, title: titles[page]), animated: animated) + var empty = UIContentUnavailableConfiguration.empty() + empty.text = section.handle == nil ? "Can’t Open Section" : "No Pages" + contentUnavailableConfiguration = section.headings.isEmpty ? empty : nil } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - titles.count + self.section?.headings.count ?? 0 } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let heading = section!.headings[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath) var content = cell.defaultContentConfiguration() - content.text = titles[indexPath.row].isEmpty ? "Untitled page" : titles[indexPath.row] + content.text = heading.title.isEmpty ? "Untitled Page" : heading.title + if heading.level > 1 { content.textProperties.color = .secondaryLabel } + // Subpages sit under their page, indented as in OneNote's page list. + content.directionalLayoutMargins.leading += CGFloat(heading.level - 1) * 20 cell.contentConfiguration = content - cell.accessoryType = .disclosureIndicator return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - show(page: indexPath.row, animated: true) + guard let section else { return } + onOpen?(section, indexPath.row) } } final class PageViewController: UIViewController { private let section: Section private let page: Int + private lazy var canvas = CanvasView(section: section, page: page) + private lazy var done = UIBarButtonItem( + systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() }) + private lazy var undo = UIBarButtonItem( + systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) + private lazy var redo = UIBarButtonItem( + systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) - init(section: Section, page: Int, title: String) { + init(section: Section, page: Int) { self.section = section self.page = page super.init(nibName: nil, bundle: nil) - self.title = title + title = section.headings[page].title navigationItem.largeTitleDisplayMode = .never } required init?(coder: NSCoder) { fatalError() } override func loadView() { - view = CanvasView(section: section, page: page) + view = canvas + canvas.onChange = { [weak self] in self?.editingChanged() } + } + + /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it. + private func editingChanged() { + guard canvas.isFirstResponder else { + navigationItem.rightBarButtonItems = nil + return + } + undo.isEnabled = canvas.canUndo(redo: false) + redo.isEnabled = canvas.canUndo(redo: true) + if navigationItem.rightBarButtonItems?.first !== done { + navigationItem.rightBarButtonItems = [done, redo, undo] + } } } diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index 75eb32851ea184dd48d19775463e56ca2c51d890..f9c7f4fedbc7fe8491756f738567da42c0db67f8 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -4,7 +4,8 @@ private final class MetalView: UIView { override class var layerClass: AnyClass { CAMetalLayer.self } } -/// An offset into the active outline's text in UTF-16 units, paragraphs joined by a newline. +/// An offset into the active outline's shown text in UTF-16 units, paragraphs joined by a +/// newline; collapsed paragraphs are not part of it. private final class Position: UITextPosition { let value: Int init(_ value: Int) { self.value = value } @@ -37,13 +38,31 @@ private extension UITextRange { var upper: Int { (self as! Range).hi } } +/// What a touch lands on; see `sb_view_target`. +private enum Target: UInt8 { + case page, activeText, text, grip +} + +/// Routes the system's undo (shake, three-finger swipe, the keyboard's undo key) to the +/// canvas's history. +private final class CanvasUndoManager: UndoManager { + weak var canvas: CanvasView? + override var canUndo: Bool { canvas?.canUndo(redo: false) ?? false } + override var canRedo: Bool { canvas?.canUndo(redo: true) ?? false } + override func undo() { canvas?.undo(redo: false) } + override func redo() { canvas?.undo(redo: true) } +} + /// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page /// drawn into a Metal layer pinned to the visible bounds, and the active outline's text -/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, -/// selection handles and loupe work on it. +/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text +/// interaction, loupe and edit menu work on it. final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate, - UITextSelectionDisplayInteractionDelegate + UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate { + /// The zoom a page opens at once the reader has pinched one. + private static let zoomKey = "zoom" + private let section: Section private let page: Int private let metal = MetalView() @@ -56,38 +75,74 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI private var origin = CGPoint.zero private var syncing = false private var scrolled = false + /// How far right of the content's corner a page opens. + private var left: CGFloat = 0 + /// The zoom a double tap returns to. + private var resting: CGFloat = 1 + private var tapZooming = false private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) + private lazy var doubleTap: UITapGestureRecognizer = { + let recognizer = UITapGestureRecognizer(target: self, action: #selector(doubleTapped)) + recognizer.numberOfTapsRequired = 2 + return recognizer + }() + /// Moves or widens the focused outline from its grips. + private lazy var grip = UIPanGestureRecognizer(target: self, action: #selector(gripped)) + /// Where the touch the grip and handle drags look at came down. + private var touchStart = CGPoint.zero + /// Drags a selection handle, which the text interaction leaves to the view drawing it. + private lazy var handleDrag = UIPanGestureRecognizer(target: self, action: #selector(handleDragged)) + /// The selection end a handle drag keeps, and the loupe following the other. + private var handleAnchor = 0 + private var loupe: UITextLoupeSession? + private lazy var editMenu = UIEditMenuInteraction(delegate: nil) private let interaction = UITextInteraction(for: .editable) - /// Draws the system caret, selection highlight and handles; the canvas paints neither. + /// Draws the caret, selection highlight and handles, which the text interaction's + /// gestures move; the canvas paints neither. private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self) + private let history = CanvasUndoManager() weak var inputDelegate: UITextInputDelegate? - lazy var tokenizer: UITextInputTokenizer = UITextInputStringTokenizer(textInput: self) + lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self) + /// Called when editing starts or stops and after each change, for the bar buttons. + var onChange: (() -> Void)? init(section: Section, page: Int) { self.section = section self.page = page super.init(frame: .zero) - backgroundColor = .white + backgroundColor = .systemBackground delegate = self minimumZoomScale = 0.25 maximumZoomScale = 4 keyboardDismissMode = .interactive addSubview(content) insertSubview(metal, at: 0) + // Touches land on the scroll view itself, where the text interaction looks for them. metal.isUserInteractionEnabled = false + content.isUserInteractionEnabled = false let layer = metal.layer as! CAMetalLayer layer.isOpaque = true // Frames reach the screen with the transaction that moves UIKit's caret and handles. layer.presentsWithTransaction = true - addGestureRecognizer(tap) + for recognizer in [tap, doubleTap, grip, handleDrag] { + recognizer.delegate = self + addGestureRecognizer(recognizer) + } + tap.require(toFail: doubleTap) + panGestureRecognizer.require(toFail: grip) + panGestureRecognizer.require(toFail: handleDrag) interaction.textInput = self interaction.delegate = self addInteraction(interaction) addInteraction(display) + display.isActivated = false + addInteraction(editMenu) + history.canvas = self NotificationCenter.default.addObserver( self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() } } required init?(coder: NSCoder) { fatalError() } @@ -102,6 +157,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI override func layoutSubviews() { super.layoutSubviews() metal.frame = bounds + // Selection views go in at the back, where the opaque page would hide them. + sendSubviewToBack(metal) let scale = window?.screen.scale ?? 3 metal.layer.contentsScale = scale guard bounds.width > 0, bounds.height > 0 else { return } @@ -116,9 +173,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page, Float(bounds.width), Float(bounds.height), Float(scale)) guard let handle else { return } + sb_view_focus(handle, false) + paper() sync() - let fit = (bounds.width - safeAreaInsets.left - safeAreaInsets.right) / content.bounds.width - zoomScale = min(1, max(0.5, fit)) + let skip = margin + resting = openingZoom() + zoomScale = resting + left = skip * resting home() #if DEBUG runScript() @@ -145,6 +206,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI sb_view_render(handle) } + /// The page follows the system's appearance, on the desktop's dark paper in dark mode. + private func paper() { + guard let handle else { return } + sb_view_set_dark(handle, traitCollection.userInterfaceStyle == .dark) + dirty = true + } + // MARK: Scrolling and zoom /// 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 Float(origin.y + contentOffset.y / zoomScale)) metal.frame = bounds dirty = true - display.setNeedsSelectionUpdate() + } + + /// The zoom the reader last pinched to, within reason; before that, OneNote's 100%, where its 11 pt + /// body text shows at 15 points, or up to 140% where the whole page fits across. + private func openingZoom() -> CGFloat { + if let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double { + // A page opens readable however far the last one was pinched. + return min(2, max(0.75, zoom)) + } + let width = bounds.inset(by: safeAreaInsets).width + return min(1.4, max(1, width / (content.bounds.width - margin))) + } + + /// The empty page left of its first outline, which a phone skips on opening. + private var margin: CGFloat { + guard let handle else { return 0 } + var block: [Float] = [0, 0, 0, 0] + guard sb_view_block(handle, 0, 0, &block) else { return 0 } + return max(0, CGFloat(block[0]) - origin.x - 12) } /// 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 } private func home() { - if !scrolled { contentOffset = CGPoint(x: -adjustedContentInset.left, y: -adjustedContentInset.top) } + if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) } } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true } + func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrolled = true } func viewForZooming(in scrollView: UIScrollView) -> UIView? { content } func scrollViewDidScroll(_ scrollView: UIScrollView) { transform() } - func scrollViewDidZoom(_ scrollView: UIScrollView) { transform() } + func scrollViewDidZoom(_ scrollView: UIScrollView) { + transform() + // Scrolling carries the caret and handles along; zooming moves them within the page. + display.setNeedsSelectionUpdate() + } + + func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + if tapZooming { + tapZooming = false + } else { + resting = scale + UserDefaults.standard.set(Double(scale), forKey: Self.zoomKey) + } + } + + @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) { + zoom(at: recognizer.location(in: self)) + } + + /// Zooms to fit the outline nearest `point` across the view, or back out, as a double + /// tap does in Safari. + func zoom(at point: CGPoint) { + guard let handle else { return } + tapZooming = true + var block: [Float] = [0, 0, 0, 0] + let local = visible(point) + guard zoomScale <= resting * 1.05, sb_view_block(handle, Float(local.x), Float(local.y), &block) else { + setZoomScale(resting, animated: true) + return + } + let width = bounds.inset(by: safeAreaInsets).width + let margin: CGFloat = 12 + let scale = min(maximumZoomScale, (width - 2 * margin) / (CGFloat(block[2] - block[0]))) + guard scale > zoomScale * 1.05 else { + setZoomScale(resting, animated: true) + return + } + let x = CGFloat(block[0]) - origin.x - margin / scale + let y = point.y / zoomScale - bounds.height / scale / 2 + zoom(to: CGRect(x: x, y: y, width: width / scale, height: bounds.height / scale), animated: true) + } @objc private func keyboardChanged(_ notification: Notification) { guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, @@ -205,33 +332,125 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI // MARK: Touch - /// The canvas places the caret, focuses outlines and starts new ones; the system's text - /// interaction takes over inside the outline already taking input. - private func inActiveText(_ point: CGPoint) -> Bool { - guard let handle else { return false } + private func target(_ point: CGPoint) -> Target { + guard let handle else { return .page } let local = visible(point) - return isFirstResponder && sb_view_in_active_text(handle, Float(local.x), Float(local.y)) + return Target(rawValue: sb_view_target(handle, Float(local.x), Float(local.y))) ?? .page + } + + func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) } + return true } override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { - recognizer === tap - ? !inActiveText(recognizer.location(in: self)) : super.gestureRecognizerShouldBegin(recognizer) + switch recognizer { + case tap: + [.page, .grip].contains(target(recognizer.location(in: self))) && !onHandle(recognizer.location(in: self)) + case doubleTap: target(recognizer.location(in: self)) == .page + case grip: target(touchStart) == .grip + case handleDrag: onHandle(touchStart) + default: super.gestureRecognizerShouldBegin(recognizer) + } } + /// The system's text interaction works in the outline taking input; a touch on another + /// outline's text focuses it first, as a tap there would. func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { - inActiveText(point) + if onHandle(point) { return true } + switch target(point) { + case .activeText: break + case .text: press(at: point) + case .page, .grip: return false + } + if !isFirstResponder { _ = becomeFirstResponder() } + return true + } + + /// Whether `point` is on a selection handle, whose knob hangs off the text. + private func onHandle(_ point: CGPoint) -> Bool { + guard isFirstResponder, let range = selectedTextRange, !range.isEmpty else { return false } + return [range.start, range.end].contains { + caretRect(for: $0).insetBy(dx: -22, dy: -22).contains(point) + } } @objc private func tapped(_ recognizer: UITapGestureRecognizer) { tap(at: recognizer.location(in: self)) } - /// A tap at `point` in the scroll view's bounds. + /// A tap at `point` in the scroll view's bounds: places the caret, focuses an outline or + /// starts a new one. func tap(at point: CGPoint) { + press(at: point) + if !isFirstResponder { _ = becomeFirstResponder() } + } + + private func press(at point: CGPoint) { guard let handle else { return } let point = visible(point) - edit(external: true) { sb_view_tap(handle, Float(point.x), Float(point.y)) } - if !isFirstResponder { _ = becomeFirstResponder() } + edit(external: true) { + let pressed = sb_view_press(handle, Float(point.x), Float(point.y)) + return sb_view_release(handle) || pressed + } + } + + @objc private func handleDragged(_ recognizer: UIPanGestureRecognizer) { + guard let range = selectedTextRange else { return } + // The finger sits below the knob; aim at the text line above it. + let point = recognizer.location(in: self) + let aim = CGPoint(x: point.x, y: point.y - 16) + switch recognizer.state { + case .began: + let start = caretRect(for: range.start) + let end = caretRect(for: range.end) + let nearStart = hypot(start.midX - touchStart.x, start.midY - touchStart.y) + < hypot(end.midX - touchStart.x, end.midY - touchStart.y) + handleAnchor = nearStart ? range.upper : range.lower + let handle = display.handleViews.min { + hypot($0.center.x - touchStart.x, $0.center.y - touchStart.y) + < hypot($1.center.x - touchStart.x, $1.center.y - touchStart.y) + } + loupe = UITextLoupeSession.begin(at: aim, fromSelectionWidgetView: handle, in: self) + editMenu.dismissMenu() + fallthrough + case .changed: + guard let position = closestPosition(to: aim) else { return } + // A handle stops one character short of the other. + let step = position.offset < handleAnchor || (position.offset == handleAnchor && handleAnchor > 0) ? -1 : 1 + let end = position.offset == handleAnchor ? handleAnchor + step : position.offset + let moved = Range(handleAnchor, min(max(end, 0), endOfDocument.offset)) + edit(external: true) { choose(moved) } + let caret = caretRect(for: position) + loupe?.move(to: CGPoint(x: caret.midX, y: caret.midY), withCaretRect: caret, trackingCaret: false) + default: + loupe?.invalidate() + loupe = nil + let end = caretRect(for: range.end) + editMenu.presentEditMenu(with: UIEditMenuConfiguration( + identifier: nil, sourcePoint: CGPoint(x: end.midX, y: caretRect(for: range.start).minY))) + } + } + + @objc private func gripped(_ recognizer: UIPanGestureRecognizer) { + guard let handle else { return } + let point = visible(recognizer.location(in: self)) + switch recognizer.state { + case .began: + let start = visible(touchStart) + _ = sb_view_press(handle, Float(start.x), Float(start.y)) + _ = sb_view_drag(handle, Float(point.x), Float(point.y)) + // The caret and handles stay behind until the outline lands. + display.isActivated = false + editMenu.dismissMenu() + dirty = true + case .changed: + _ = sb_view_drag(handle, Float(point.x), Float(point.y)) + dirty = true + default: + display.isActivated = isFirstResponder + edit(external: true) { sb_view_release(handle) } + } } /// 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 inputDelegate?.textDidChange(self) inputDelegate?.selectionDidChange(self) } + dirty = true if changed { sync() revealCaret() } + display.setNeedsSelectionUpdate() + onChange?() } /// 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 } private func revealCaret() { - guard let handle, isFirstResponder else { return } - var range: [UInt32] = [0, 0] - sb_selection(handle, &range) - let caret = caretRect(for: Position(Int(range[1]))) + guard handle != nil, isFirstResponder, let range = selectedTextRange else { return } + let caret = caretRect(for: range.end) scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false) } + // MARK: Responder + override var canBecomeFirstResponder: Bool { handle != nil } override func becomeFirstResponder() -> Bool { guard super.becomeFirstResponder() else { return false } + if let handle { sb_view_focus(handle, true) } display.isActivated = true + dirty = true + onChange?() return true } override func resignFirstResponder() -> Bool { guard super.resignFirstResponder() else { return false } + if let handle { sb_view_focus(handle, false) } display.isActivated = false + dirty = true + onChange?() return true } + override var undoManager: UndoManager? { history } + + func canUndo(redo: Bool) -> Bool { handle.map { sb_view_can_undo($0, redo) } ?? false } + + func undo(redo: Bool) { + guard let handle else { return } + edit(external: true) { sb_view_undo(handle, redo) } + } + + private var selection: (lo: Int, hi: Int) { + guard let range = selectedTextRange else { return (0, 0) } + return (range.lower, range.upper) + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + let (lo, hi) = selection + switch action { + case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)), + #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)): + return lo < hi + case #selector(paste(_:)): return UIPasteboard.general.hasStrings + case #selector(select(_:)): return lo == hi && hasText + case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset) + default: return super.canPerformAction(action, withSender: sender) + } + } + + override func copy(_ sender: Any?) { + guard let range = selectedTextRange, let text = text(in: range) else { return } + UIPasteboard.general.string = text + } + + override func cut(_ sender: Any?) { + copy(sender) + delete(sender) + } + + override func delete(_ sender: Any?) { + guard let range = selectedTextRange else { return } + edit(external: true) { replacing(range, with: "") } + } + /// Pasted text takes the keyboard's language, as Windows gives the clipboard. override func paste(_ sender: Any?) { guard let handle, let text = UIPasteboard.general.string else { return } edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") } } + override func select(_ sender: Any?) { + guard let caret = selectedTextRange?.start, + let word = tokenizer.rangeEnclosingPosition(caret, with: .word, inDirection: .storage(.backward)) + ?? tokenizer.rangeEnclosingPosition(caret, with: .word, inDirection: .storage(.forward)) + else { return } + edit(external: true) { choose(word) } + } + + override func selectAll(_ sender: Any?) { + edit(external: true) { choose(Range(0, endOfDocument.offset)) } + } + + override func toggleBoldface(_ sender: Any?) { toggle(0) } + override func toggleItalics(_ sender: Any?) { toggle(1) } + override func toggleUnderline(_ sender: Any?) { toggle(2) } + + private func toggle(_ style: UInt8) { + guard let handle else { return } + edit(external: true) { sb_view_toggle(handle, style) } + } + // MARK: Coordinates /// 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 return String(cString: text) } + private func replacing(_ range: UITextRange, with text: String) -> Bool { + guard let handle else { return false } + return sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text) + } + func replace(_ range: UITextRange, withText text: String) { - guard let handle else { return } - edit { sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text) } + edit { replacing(range, with: text) } + } + + private func choose(_ range: UITextRange) -> Bool { + guard let handle else { return false } + return sb_select(handle, UInt32(range.lower), UInt32(range.upper)) } var selectedTextRange: UITextRange? { @@ -335,10 +635,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI return Range(Int(range[0]), Int(range[1])) } set { - guard let handle, let newValue else { return } - _ = sb_select(handle, UInt32(newValue.lower), UInt32(newValue.upper)) - dirty = true + guard let newValue else { return } + _ = choose(newValue) display.setNeedsSelectionUpdate() + dirty = true + onChange?() } } @@ -425,14 +726,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI var rect: [Float] = [0, 0, 0, 0] guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero } var caret = bounded(rect) + caret.origin.x -= 1 caret.size.width = 2 return caret } func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { guard let handle else { return [] } - var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: 64) - let count = min(64, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, 64)) + let capacity = 256 + var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: capacity) + let count = min(capacity, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, capacity)) return (0.. Bool { + [UITextStorageDirection.forward.rawValue, UITextLayoutDirection.right.rawValue, + UITextLayoutDirection.down.rawValue].contains(direction.rawValue) + } + + private func line(_ offset: Int) -> CGFloat { canvas.caretRect(for: Position(offset)).minY.rounded() } + + private func atEdge(_ offset: Int, forward: Bool) -> Bool { + let end = canvas.endOfDocument.offset + return forward + ? offset >= end || line(offset + 1) != line(offset) + : offset <= 0 || line(offset - 1) != line(offset) + } + + private func edge(_ offset: Int, forward: Bool) -> Int { + var offset = offset + while !atEdge(offset, forward: forward) { offset += forward ? 1 : -1 } + return offset + } + + override func isPosition( + _ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection + ) -> Bool { + guard granularity == .line else { + return super.isPosition(position, atBoundary: granularity, inDirection: direction) + } + return atEdge(position.offset, forward: forward(direction)) + } + + override func position( + from position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection + ) -> UITextPosition? { + guard granularity == .line else { + return super.position(from: position, toBoundary: granularity, inDirection: direction) + } + return Position(edge(position.offset, forward: forward(direction))) + } + + override func rangeEnclosingPosition( + _ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection + ) -> UITextRange? { + guard granularity == .line else { + return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) + } + return Range(edge(position.offset, forward: false), edge(position.offset, forward: true)) + } + + override func isPosition( + _ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection + ) -> Bool { + granularity == .line || super.isPosition(position, withinTextUnit: granularity, inDirection: direction) + } +} diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift index d98e1cae5c17dc4fb7feb25a3b12e114b307432c..db0ca924baf54af000d6b151013bfc853e58299b 100644 --- a/apps/ios/Snowbound/Script.swift +++ b/apps/ios/Snowbound/Script.swift @@ -5,8 +5,10 @@ extension CanvasView { /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check /// input without a finger: steps joined by `|`, such as /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`. - /// `tap` takes points from the view's corner, `select` text offsets, `scroll` a content - /// offset; `shot:a` saves the window to Documents/a.png, as a device has no screenshot command. + /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, + /// `scroll` a content offset; `done` ends editing, `tree` saves the view hierarchy to + /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no + /// screenshot command. func runScript() { guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } for (index, step) in script.split(separator: "|").enumerated() { @@ -32,6 +34,9 @@ extension CanvasView { case "unmark": unmarkText() case "select": let values = argument.split(separator: ",").compactMap { Int($0) } + // As a gesture would, the change reaches the system's text interaction. + inputDelegate?.selectionWillChange(self) + defer { inputDelegate?.selectionDidChange(self) } selectedTextRange = textRange( from: position(from: beginningOfDocument, offset: values[0])!, to: position(from: beginningOfDocument, offset: values[1])!) @@ -39,6 +44,14 @@ extension CanvasView { let values = argument.split(separator: ",").compactMap { Double($0) } setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true) case "zoom": setZoomScale(Double(argument) ?? 1, animated: false) + case "doubletap": + let values = argument.split(separator: ",").compactMap { Double($0) } + zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) + case "done": _ = resignFirstResponder() + case "tree": + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + try? "\(value(forKey: "recursiveDescription") ?? "")".write( + to: documents.appendingPathComponent("tree.txt"), atomically: true, encoding: .utf8) case "shot": guard let window else { break } let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in diff --git a/apps/ios/build-rust.sh b/apps/ios/build-rust.sh index aae3316c1622e3d2918d81abdf230cfa5dd10874..2737aad4120df5b480bb01f45bcfdf581510b140 100755 --- a/apps/ios/build-rust.sh +++ b/apps/ios/build-rust.sh @@ -10,5 +10,5 @@ release= root="$SRCROOT/../.." # Xcode's SDK variables would point host build scripts at the iOS SDK. 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" \ - CARGO_TARGET_DIR="$root/target/ios" \ + CARGO_TARGET_DIR="$root/target/ios" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" \ cargo build --manifest-path "$root/Cargo.toml" -p mobile --target "$target" $release diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index 08a0a19a63b232cd1d1f282c8472cc8ab8bdefb7..753337b7784456db049aef9851324e567340d06c 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -11,8 +11,10 @@ crate-type = ["staticlib"] [dependencies] canvas = { path = "../canvas", features = ["interaction"] } draw = { path = "../draw" } +notebook = { path = "../notebook" } onestore = { path = "../onestore" } wgpu.workspace = true parley.workspace = true pollster = "0.4" +serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 726e45fbef99d7ab41948a9a78dfc511bea9acaa..c5bfc33bc6cc384f3f7715b772b8604d9822e67c 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -8,9 +8,12 @@ typedef struct Section Section; typedef struct View View; +char *sb_notebook(const char *path); + Section *sb_section_open(const char *path); size_t sb_section_count(const Section *section); const char *sb_section_title(const Section *section, size_t index); +uint32_t sb_section_level(const Section *section, size_t index); void sb_section_free(Section *section); 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); void sb_view_resize(View *view, float width, float height, float scale); bool sb_view_render(View *view); bool sb_view_frame_pending(const View *view); +void sb_view_set_dark(View *view, bool dark); +void sb_view_focus(View *view, bool focused); void sb_view_content(View *view, float bounds[4]); +bool sb_view_block(const View *view, float x, float y, float rect[4]); void sb_view_set_transform(View *view, float zoom, float x, float y); -bool sb_view_tap(View *view, float x, float y); -bool sb_view_in_active_text(const View *view, float x, float y); +uint8_t sb_view_target(const View *view, float x, float y); +bool sb_view_press(View *view, float x, float y); +bool sb_view_drag(View *view, float x, float y); +bool sb_view_release(View *view); +bool sb_view_undo(View *view, bool redo); +bool sb_view_can_undo(const View *view, bool redo); +bool sb_view_toggle(View *view, uint8_t toggle); char *sb_view_edit(View *view); +void sb_string_free(char *text); uint32_t sb_text_length(const View *view); char *sb_text(const View *view, uint32_t start, uint32_t end); -void sb_string_free(char *text); void sb_selection(const View *view, uint32_t range[2]); bool sb_select(View *view, uint32_t start, uint32_t end); bool sb_marked(const View *view, uint32_t range[2]); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index da46f88aed76a848482c779d784621f072b549c8..b1536b6f8a28b4d8b79ac9276e7abe9708217ea9 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -1,6 +1,6 @@ -//! A canvas page behind a C surface for native mobile shells: a section's pages, one drawn -//! into a `CAMetalLayer`, and the active outline's text as flat UTF-16 offsets, paragraphs -//! joined by `\n`, the model UIKit's `UITextInput` speaks. +//! A canvas page behind a C surface for native mobile shells: a notebook's sections, a +//! section's pages, one page drawn into a `CAMetalLayer`, and the active outline's text as +//! the flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). //! //! Lengths and positions are in the host's points; the view converts through its display //! scale. Calls returning `bool` report whether the page or selection changed, after which @@ -8,12 +8,13 @@ use canvas::{ document::TextPosition, - editor::Selection, + editor::{Formatting, Selection, TextOutline, Toggle}, gpu::{Paper, Viewport, page::PageScene}, - interaction::{Hit, PageView, Response, TextColors, page_hit_test}, + interaction::{Hit, PageView, Response, TextColors}, layout::TextEngine, }; use draw::edit::{Key, NamedKey, SelectionUnit}; +use notebook::discover::{self, Folder, SectionState}; use onestore::{ ExGuid, RevisionIndex, Store, document::Document, @@ -24,6 +25,7 @@ use parley::{Affinity, BoundingBox}; use std::{ error::Error, ffi::{CStr, CString, c_char, c_void}, + path::Path, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -37,17 +39,99 @@ type Result = std::result::Result>; /// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch. const POINT: f32 = 96.0 / 72.0; -/// The platform's text interaction draws the caret and selection. -const COLORS: TextColors = TextColors { - caret: [0.0; 4], - selection: [0.0; 4], - paper: Paper::WHITE, -}; +/// The tab colour OneNote gives a section that stores none, as the desktop shows it. +const SECTION_COLOR: u32 = 0x00e4_a88a; + +#[derive(serde::Serialize)] +struct Listing { + name: String, + sections: Vec, +} + +#[derive(serde::Serialize)] +struct Tab { + name: String, + /// The section file's absolute path, for `sb_section_open`. + path: String, + /// The section group holding it, `/`-separated; empty at the notebook's top. + group: String, + /// The tab colour in sRGB. + color: [u8; 3], + /// Password-protected or unreadable sections list but do not open. + readable: bool, +} + +/// A notebook folder's sections in its order, or a lone section file as a notebook of one. +fn listing(path: &Path) -> Result { + let stem = |path: &Path| { + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default() + }; + if path.is_file() { + return Ok(Listing { + name: stem(path), + sections: vec![Tab { + name: stem(path), + path: path.to_string_lossy().into_owned(), + group: String::new(), + color: rgb(SECTION_COLOR), + readable: true, + }], + }); + } + let folder = discover::discover( + &mut discover::Local::open(path)?, + discover::Limits { + entries: 10_000, + bytes_per_file: 1 << 30, + depth: 16, + }, + )?; + let mut sections = Vec::new(); + fn walk(folder: &Folder, root: &Path, sections: &mut Vec) { + for section in &folder.sections { + let file = root.join(§ion.path); + let (name, color, readable) = match §ion.state { + SectionState::Readable { name, color } => (name.clone(), *color, true), + SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), + }; + sections.push(Tab { + name: name.unwrap_or_else(|| { + file.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default() + }), + path: file.to_string_lossy().into_owned(), + group: folder.path.clone(), + color: rgb(color.unwrap_or(SECTION_COLOR)), + readable, + }); + } + for group in &folder.groups { + // OneNote keeps deleted sections and pages here, out of the notebook's view. + if !group.path.ends_with("OneNote_RecycleBin") { + walk(group, root, sections); + } + } + } + walk(&folder, path, &mut sections); + Ok(Listing { + name: stem(path), + sections, + }) +} + +fn rgb(colorref: u32) -> [u8; 3] { + let [red, green, blue, _] = colorref.to_le_bytes(); + [red, green, blue] +} pub struct Section { /// Each page with the object space holding it. pages: Vec<(ExGuid, Page)>, - titles: Vec, + /// The page list's title and outline level (1 at the top) for each page. + headings: Vec<(CString, u32)>, } impl Section { @@ -55,16 +139,17 @@ impl Section { let store = Store::parse(bytes)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; - let pages = document - .pages()? - .into_iter() - .map(|(space, id)| Ok((space, Page::from_revision(document.active(space)?, id)?))) - .collect::, onestore::Error>>()?; - let titles = pages - .iter() - .map(|(_, page)| CString::new(page.title.replace('\0', ""))) - .collect::>()?; - Ok(Self { pages, titles }) + let mut pages = Vec::new(); + let mut headings = Vec::new(); + for (space, id) in document.pages()? { + let revision = document.active(space)?; + let (title, level) = Page::heading(revision, id); + pages.push((space, Page::from_revision(revision, id)?)); + // Titles keep OneNote's line breaks, which a one-line list shows as spaces. + let title = title.replace(|char: char| char.is_control(), " "); + headings.push((CString::new(title)?, level)); + } + Ok(Self { pages, headings }) } } @@ -78,16 +163,6 @@ impl Wake for Frame { } } -pub struct View { - page: PageView, - /// The object space of the page shown, which its edits name. - space: ExGuid, - surface: wgpu::Surface<'static>, - config: wgpu::SurfaceConfiguration, - renderer: draw::Renderer, - frame: Arc, -} - fn report(result: Result) -> Option { result.map_err(|error| eprintln!("snowbound: {error}")).ok() } @@ -96,53 +171,70 @@ fn moved(response: Response) -> bool { response.changed || response.moved } -impl View { - fn new( - layer: *mut c_void, - (space, page): (ExGuid, Page), - size: [f32; 2], - scale: f32, - ) -> Result { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); - // SAFETY: the host passes a live CAMetalLayer that outlives the view. - let surface = unsafe { - instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) - }?; - let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { - compatible_surface: Some(&surface), - ..Default::default() - }))?; - // The simulator's adapter falls short of wgpu's default limits. - let (device, queue) = - pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - required_limits: adapter.limits(), - ..Default::default() - }))?; - let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); - let config = surface - .get_default_config(&adapter, pixels[0], pixels[1]) - .ok_or("No supported surface configuration")?; - surface.configure(&device, &config); - let renderer = draw::Renderer::new(device, queue, config.format); +/// What a touch lands on, so the host routes it before its gesture starts. +#[repr(u8)] +#[derive(Debug, PartialEq)] +enum Target { + /// Empty page, a picture, a check box or the date: the canvas takes a tap. + Page = 0, + /// The text taking input, where the platform's text interaction works. + ActiveText = 1, + /// Another outline's text, which a touch focuses before the platform's text interaction + /// takes over. + Text = 2, + /// The focused outline's move or width grip. + Grip = 3, +} + +/// The page and its text model, apart from the surface it is drawn on. +struct Canvas { + page: PageView, + /// The object space of the page shown, which its edits name. + space: ExGuid, + paper: Paper, +} + +impl Canvas { + fn new((space, page): (ExGuid, Page), pixels: [u32; 2], scale: f32) -> Result { let mut engine = TextEngine::default(); let (scene, editor) = PageScene::from_page(page, &mut engine)?; + let mut page = PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + pixels, + scale, + Duration::from_millis(350), + ); + page.touch = true; Ok(Self { - page: PageView::new( - editor, - engine, - Some((scene, [0.0; 2])), - pixels, - scale, - Duration::from_millis(350), - ), + page, space, - surface, - config, - renderer, - frame: Arc::default(), + paper: Paper::WHITE, }) } + /// The platform's text interaction draws the caret and selection. + fn colors(&self) -> TextColors { + TextColors { + caret: [0.0; 4], + selection: [0.0; 4], + paper: self.paper, + } + } + + /// The desktop's dark page, or white paper. + fn set_dark(&mut self, dark: bool) { + self.paper = if dark { + Paper { + color: draw::srgb(0x1f, 0x20, 0x22), + ink: draw::srgb(0xe6, 0xe6, 0xe6), + } + } else { + Paper::WHITE + }; + } + /// What the page took since the last call as one edit, or none when it took nothing. fn edit(&mut self) -> Result> { let ops = self.page.editor.take_ops()?; @@ -173,31 +265,6 @@ impl View { point.map(|value| value * self.display_scale()) } - fn render(&mut self) -> Result<()> { - let frame = match self.surface.get_current_texture() { - wgpu::CurrentSurfaceTexture::Success(frame) - | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, - wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { - self.surface.configure(&self.renderer.device, &self.config); - return Ok(()); - } - _ => return Ok(()), - }; - self.page - .update_pictures(COLORS.paper, &Waker::from(self.frame.clone())); - let primitives = self.page.primitives(COLORS)?; - self.renderer - .draw( - &frame.texture.create_view(&Default::default()), - [self.config.width, self.config.height], - COLORS.paper.color, - &[self.page.viewport.layer(&primitives)], - ) - .map_err(|error| format!("Page drawing failed: {error:?}"))?; - self.renderer.queue.present(frame); - Ok(()) - } - /// The page content's extent in document points: the scroll limits of a view with no /// size at scale 1. fn content(&mut self) -> [f32; 4] { @@ -218,97 +285,134 @@ impl View { self.page.viewport.origin = corner.map(|value| -value * scale * zoom); } - fn tap(&mut self, point: [f32; 2]) -> Result { - let mut changed = moved(self.page.pointer_moved(self.device(point))?); - changed |= moved(self.page.pointer_pressed(Instant::now())?); - changed |= moved(self.page.pointer_released()?); - // A finger leaves no hover behind. - let _ = self.page.pointer_left(); - Ok(changed) + fn outline(&self, id: ExGuid) -> Option<&TextOutline> { + let editor = &self.page.editor; + editor + .visible_outlines() + .chain(editor.caret_outline()) + .find(|outline| outline.id == id) } - /// Whether `point` lands on the text of the outline taking input, where the platform's - /// own text interaction places the caret. - fn in_active_text(&self, point: [f32; 2]) -> bool { - let point = self.page.viewport.document_point(self.device(point)); - let pixel = self.display_scale() / self.page.viewport.scale; - matches!( - page_hit_test(&self.page.editor, self.page.scene.as_ref(), point, pixel), - Some(Hit::Text { id, .. }) if id == self.page.editor.active_outline().id - ) - } - - /// UTF-16 lengths of the active outline's text paragraphs. - fn lengths(&self) -> Result> { - self.page - .editor - .active_outline() - .document() - .paragraphs() - .map(|paragraph| Ok(paragraph.utf16_offset(paragraph.text().len())?)) - .collect() - } - - fn position(&self, flat: u32) -> Result { - let lengths = self.lengths()?; - let mut rest = flat; - for (paragraph, length) in lengths.iter().enumerate() { - if rest <= *length { - return Ok(TextPosition { - paragraph, - offset: rest, - }); + fn target(&self, point: [f32; 2]) -> Target { + match self.page.hit(self.device(point)) { + Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, + Some(Hit::Text { id, point }) + if !self + .outline(id) + .is_some_and(|outline| outline.contains_extension(point)) => + { + if id == self.page.editor.active_outline().id { + Target::ActiveText + } else { + Target::Text + } } - rest -= length + 1; + _ => Target::Page, } - Ok(TextPosition { - paragraph: lengths.len() - 1, - offset: lengths[lengths.len() - 1], + } + + /// Runs `change` on the page, then puts the view back where the host's scroll view has + /// it: the canvas clamps and reveals knowing neither the navigation bar nor the keyboard, + /// and the host reveals the caret itself. + fn hold(&mut self, change: impl FnOnce(&mut PageView) -> Result) -> Result { + let Viewport { scale, origin, .. } = self.page.viewport; + let result = change(&mut self.page); + self.page.viewport.scale = scale; + self.page.viewport.origin = origin; + result + } + + /// A finger down at `point`: the start of a tap or of a grip drag. + fn press(&mut self, point: [f32; 2]) -> Result { + let point = self.device(point); + self.hold(|page| { + let changed = moved(page.pointer_moved(point)?); + Ok(moved(page.pointer_pressed(Instant::now())?) || changed) }) } - fn flat(&self, position: TextPosition) -> Result { - let before: u32 = self.lengths()?[..position.paragraph] - .iter() - .map(|length| length + 1) - .sum(); - Ok(before + position.offset) + fn drag(&mut self, point: [f32; 2]) -> Result { + let point = self.device(point); + self.hold(|page| Ok(moved(page.pointer_moved(point)?))) } - fn length(&self) -> Result { - let lengths = self.lengths()?; - Ok(lengths.iter().sum::() + lengths.len() as u32 - 1) + fn release(&mut self) -> Result { + self.hold(|page| { + let changed = moved(page.pointer_released()?); + // A finger leaves no hover behind. + let _ = page.pointer_left(); + Ok(changed) + }) } - fn text(&self, range: [u32; 2]) -> Result { - let range = self.position(range[0])?..self.position(range[1])?; - Ok(self + /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom. + fn block(&self, point: [f32; 2]) -> Option<[f32; 4]> { + let [x, y] = self + .page + .viewport + .document_point(self.device(point)) + .map(f64::from); + let distance = |rect: &BoundingBox| { + let dx = (rect.x0 - x).max(x - rect.x1).max(0.0); + let dy = (rect.y0 - y).max(y - rect.y1).max(0.0); + dx.hypot(dy) + }; + let rect = self .page .editor - .active_outline() - .document() - .slice(range)? - .iter() - .map(|paragraph| paragraph.text()) - .collect::>() - .join("\n")) + .visible_outlines() + .map(TextOutline::bounds) + .min_by(|a, b| distance(a).total_cmp(&distance(b)))?; + Some([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32 * POINT)) + } + + fn active(&self) -> &TextOutline { + self.page.editor.active_outline() + } + + fn text(&self, range: [u32; 2]) -> String { + let units: Vec = self.active().shown_text().encode_utf16().collect(); + let [start, end] = range.map(|offset| (offset as usize).min(units.len())); + String::from_utf16_lossy(&units[start..end.max(start)]) + } + + fn length(&self) -> u32 { + self.active().shown_text().encode_utf16().count() as u32 } fn selection(&self) -> Result<[u32; 2]> { let [anchor, focus] = self.page.editor.selection().positions; - Ok([self.flat(anchor.min(focus))?, self.flat(anchor.max(focus))?]) + let outline = self.active(); + Ok([ + outline.utf16_offset(anchor.min(focus))?, + outline.utf16_offset(anchor.max(focus))?, + ]) + } + + fn positions(&self, range: [u32; 2]) -> Result<[TextPosition; 2]> { + let outline = self.active(); + Ok([ + outline.utf16_position(range[0])?, + outline.utf16_position(range[1])?, + ]) } fn select(&mut self, range: [u32; 2]) -> Result<()> { - let positions = [self.position(range[0])?, self.position(range[1])?]; + let positions = self.positions(range)?; Ok(self.page.editor.select(Selection::from(positions))?) } fn marked(&self) -> Result> { + let outline = self.active(); self.page .editor .marked_range() - .map(|range| Ok([self.flat(range.start)?, self.flat(range.end)?])) + .map(|range| { + Ok([ + outline.utf16_offset(range.start)?, + outline.utf16_offset(range.end)?, + ]) + }) .transpose() } @@ -325,23 +429,25 @@ impl View { .map_or(text.len(), |(byte, _)| byte) }; let cursor = (byte(selected[0]), byte(selected[1])); - Ok(moved(self.page.compose(text, Some(cursor))?)) + self.hold(|page| Ok(moved(page.compose(text, Some(cursor))?))) } fn insert(&mut self, text: String) -> Result { - let response = if text == "\n" { - self.page.key(&Key::Named(NamedKey::Enter), None)? - } else { - self.page.commit_text(text)? - }; - Ok(moved(response)) + self.hold(|page| { + Ok(moved(if text == "\n" { + page.key(&Key::Named(NamedKey::Enter), None)? + } else { + page.commit_text(text)? + })) + }) } - /// A document rectangle in the active outline, in the view's points as `[x, y, width, - /// height]`. - fn view_rect(&self, rect: BoundingBox, origin: [f32; 2]) -> [f32; 4] { + /// A rectangle in the active outline's coordinates, in the view's points as `[x, y, + /// width, height]`. + fn view_rect(&self, rect: BoundingBox) -> [f32; 4] { let viewport = self.page.viewport; let points = self.display_scale(); + let origin = self.active().origin(); let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]]; [ (x * viewport.scale + viewport.origin[0]) / points, @@ -351,88 +457,135 @@ impl View { ] } - fn caret_rect(&self, flat: u32) -> Result<[f32; 4]> { - let position = self.position(flat)?; - let outline = self.page.editor.active_outline(); - let paragraph = outline.paragraph_layout(position.paragraph)?; - let visible = paragraph.projection.visible_offset(position.offset)?; - let byte = paragraph.projection.text().byte_offset(visible)?; - let caret = paragraph - .text - .caret(paragraph.text.cursor(byte, Affinity::Downstream), 1.0); - let origin = outline.origin(); - Ok(self.view_rect( - caret, - [ - origin[0] + paragraph.origin[0], - origin[1] + paragraph.origin[1], - ], - )) + fn caret_rect(&self, offset: u32) -> Result<[f32; 4]> { + let position = self.active().utf16_position(offset)?; + let caret = self + .page + .editor + .caret_at(position, Affinity::Downstream, 1.0)?; + Ok(self.view_rect(caret)) } /// The selection rectangles of a range, one or more per line. fn range_rects(&self, range: [u32; 2]) -> Result> { - let [start, end] = [self.position(range[0])?, self.position(range[1])?]; - let outline = self.page.editor.active_outline(); - let origin = outline.origin(); - let mut rects = Vec::new(); - for (index, paragraph) in outline.layouts() { - if index < start.paragraph || index > end.paragraph { - continue; - } - let cursor = |offset: u32, affinity| -> Result<_> { - let visible = paragraph.projection.visible_offset(offset)?; - let byte = paragraph.projection.text().byte_offset(visible)?; - Ok(paragraph.text.cursor(byte, affinity)) - }; - let first = cursor( - if index == start.paragraph { - start.offset - } else { - 0 - }, - Affinity::Downstream, - )?; - let last = if index == end.paragraph { - cursor(end.offset, Affinity::Upstream)? - } else { - paragraph - .text - .cursor(paragraph.projection.text().text().len(), Affinity::Upstream) - }; - let origin = [ - origin[0] + paragraph.origin[0], - origin[1] + paragraph.origin[1], - ]; - for rect in paragraph - .text - .selection(parley::editing::Selection::new(first, last)) - { - rects.push(self.view_rect(rect, origin)); - } - } - Ok(rects) + let rects = self.page.editor.range_rects(Selection { + positions: self.positions(range)?, + affinities: [Affinity::Downstream, Affinity::Upstream], + })?; + Ok(rects.into_iter().map(|rect| self.view_rect(rect)).collect()) } fn closest(&self, point: [f32; 2]) -> Result { let point = self.page.viewport.document_point(self.device(point)); - let origin = self.page.editor.active_outline().origin(); + let origin = self.active().origin(); let selection = self.page.editor.selection_at( point[0] - origin[0], point[1] - origin[1], SelectionUnit::Grapheme, )?; - self.flat(selection.positions[1]) + Ok(self.active().utf16_offset(selection.positions[1])?) } } +pub struct View { + canvas: Canvas, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + renderer: draw::Renderer, + frame: Arc, +} + +impl View { + fn new(layer: *mut c_void, page: (ExGuid, Page), size: [f32; 2], scale: f32) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + // SAFETY: the host passes a live CAMetalLayer that outlives the view. + let surface = unsafe { + instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) + }?; + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + ..Default::default() + }))?; + // The simulator's adapter falls short of wgpu's default limits. + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + }))?; + let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); + let config = surface + .get_default_config(&adapter, pixels[0], pixels[1]) + .ok_or("No supported surface configuration")?; + surface.configure(&device, &config); + let renderer = draw::Renderer::new(device, queue, config.format); + Ok(Self { + canvas: Canvas::new(page, pixels, scale)?, + surface, + config, + renderer, + frame: Arc::default(), + }) + } + + fn render(&mut self) -> Result<()> { + let frame = match self.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(frame) + | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, + wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { + self.surface.configure(&self.renderer.device, &self.config); + return Ok(()); + } + _ => return Ok(()), + }; + let canvas = &mut self.canvas; + canvas + .page + .update_pictures(canvas.paper, &Waker::from(self.frame.clone())); + let primitives = canvas.page.primitives(canvas.colors())?; + self.renderer + .draw( + &frame.texture.create_view(&Default::default()), + [self.config.width, self.config.height], + canvas.paper.color, + &[canvas.page.viewport.layer(&primitives)], + ) + .map_err(|error| format!("Page drawing failed: {error:?}"))?; + self.renderer.queue.present(frame); + Ok(()) + } +} + +fn string(text: *const c_char) -> String { + // SAFETY: every caller's contract makes `text` NUL-terminated. + unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned() +} + +fn owned(text: String) -> *mut c_char { + CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw) +} + +/// The notebook folder at `path`, or the lone section file there, as JSON: `name`, and +/// `sections` in the notebook's order, each with `name`, `path`, `group`, `color` as sRGB +/// bytes and `readable`. Freed with `sb_string_free`; null if it cannot be read. +/// +/// # Safety +/// `path` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_notebook(path: *const c_char) -> *mut c_char { + report( + listing(Path::new(&string(path))).and_then(|listing| Ok(serde_json::to_string(&listing)?)), + ) + .map_or(std::ptr::null_mut(), owned) +} + /// # Safety -/// `path` is a NUL-terminated path. +/// `path` is NUL-terminated UTF-8. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section { - let path = unsafe { CStr::from_ptr(path) }; report( - std::fs::read(path.to_str().unwrap_or_default()) + std::fs::read(string(path)) .map_err(Into::into) .and_then(|bytes| Section::open(&bytes)), ) @@ -449,7 +602,13 @@ pub extern "C" fn sb_section_count(section: &Section) -> usize { /// The page's title, alive as long as the section. #[unsafe(no_mangle)] pub extern "C" fn sb_section_title(section: &Section, index: usize) -> *const c_char { - section.titles[index].as_ptr() + section.headings[index].0.as_ptr() +} + +/// The page's level in the page list: 1 at the top, 2 for a subpage and so on. +#[unsafe(no_mangle)] +pub extern "C" fn sb_section_level(section: &Section, index: usize) -> u32 { + section.headings[index].1 } /// # Safety @@ -492,10 +651,11 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) { #[unsafe(no_mangle)] pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) { let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32); - if scale != view.display_scale() { - let _ = report(view.page.scale_factor_changed(scale)); + if scale != view.canvas.display_scale() { + let _ = report(view.canvas.page.scale_factor_changed(scale)); } - let _ = report(view.page.resized(pixels)); + let page = &mut view.canvas.page; + let _ = report(page.resized(pixels)); view.config.width = pixels[0]; view.config.height = pixels[1]; view.surface.configure(&view.renderer.device, &view.config); @@ -512,42 +672,96 @@ pub extern "C" fn sb_view_frame_pending(view: &View) -> bool { view.frame.0.swap(false, Ordering::Acquire) } +/// Draws the page on the desktop's dark paper, or on white. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) { + view.canvas.set_dark(dark); +} + +/// Whether the view has input focus: the focused outline shows its frame and grips only then. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_focus(view: &mut View, focused: bool) { + let _ = report(view.canvas.hold(|page| page.focus_changed(focused))); +} + /// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right /// including the margin OneNote scrolls past the last object. #[unsafe(no_mangle)] pub extern "C" fn sb_view_content(view: &mut View, bounds: &mut [f32; 4]) { - *bounds = view.content().map(|value| value * POINT); + *bounds = view.canvas.content().map(|value| value * POINT); +} + +/// The frame of the outline nearest the view point `x`, `y`, as `[left, top, right, +/// bottom]` in points at 100% zoom; false on a page with none. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_block(view: &View, x: f32, y: f32, rect: &mut [f32; 4]) -> bool { + view.canvas + .block([x, y]) + .map(|block| *rect = block) + .is_some() } /// Zooms and scrolls so the page point `x`, `y` sits at the view's corner; the host's /// scroll view owns the limits and rubber-banding. #[unsafe(no_mangle)] pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f32) { - view.set_transform(zoom, [x, y]); + view.canvas.set_transform(zoom, [x, y]); } -/// A tap: places the caret, focuses an outline or starts a new one. +/// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1 +/// for the text taking input; 2 for another outline's text; 3 for the focused outline's +/// move or width grip. #[unsafe(no_mangle)] -pub extern "C" fn sb_view_tap(view: &mut View, x: f32, y: f32) -> bool { - report(view.tap([x, y])).unwrap_or(false) +pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { + view.canvas.target([x, y]) as u8 } +/// A finger down: with `sb_view_release` a tap, which places the caret, focuses an outline +/// or starts a new one; with `sb_view_drag` between, a grip moves or widens an outline. #[unsafe(no_mangle)] -pub extern "C" fn sb_view_in_active_text(view: &View, x: f32, y: f32) -> bool { - view.in_active_text([x, y]) +pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool { + report(view.canvas.press([x, y])).unwrap_or(false) } #[unsafe(no_mangle)] -pub extern "C" fn sb_text_length(view: &View) -> u32 { - report(view.length()).unwrap_or(0) +pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool { + report(view.canvas.drag([x, y])).unwrap_or(false) } -/// The text in `start..end`, freed with `sb_string_free`. #[unsafe(no_mangle)] -pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char { - report(view.text([start, end])) - .and_then(|text| CString::new(text.replace('\0', "")).ok()) - .map_or(std::ptr::null_mut(), CString::into_raw) +pub extern "C" fn sb_view_release(view: &mut View) -> bool { + report(view.canvas.release()).unwrap_or(false) +} + +/// Undoes the last edit, or with `redo` redoes the last undone one. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_undo(view: &mut View, redo: bool) -> bool { + report(view.canvas.hold(|page| page.undo(redo).map(moved))).unwrap_or(false) +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_can_undo(view: &View, redo: bool) -> bool { + let editor = &view.canvas.page.editor; + if redo { + editor.can_redo() + } else { + editor.can_undo() + } +} + +/// Toggles 0 bold, 1 italic or 2 underline on the selection. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_toggle(view: &mut View, toggle: u8) -> bool { + let toggle = match toggle { + 0 => Toggle::Bold, + 1 => Toggle::Italic, + _ => Toggle::Underline, + }; + report( + view.canvas + .hold(|page| page.format(Formatting::Toggle(toggle)).map(moved)), + ) + .unwrap_or(false) } /// 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 { /// stored, in which case the page should be opened again. #[unsafe(no_mangle)] pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char { - report(view.edit()) + report(view.canvas.edit()) .flatten() - .and_then(|edit| CString::new(serde_json::to_string(&edit).ok()?).ok()) - .map_or(std::ptr::null_mut(), CString::into_raw) + .and_then(|edit| serde_json::to_string(&edit).ok()) + .map_or(std::ptr::null_mut(), owned) } /// # Safety -/// `text` came from `sb_text` or `sb_view_edit` and is not used again. +/// `text` came from this library and is not used again. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_string_free(text: *mut c_char) { drop(unsafe { CString::from_raw(text) }); } +#[unsafe(no_mangle)] +pub extern "C" fn sb_text_length(view: &View) -> u32 { + view.canvas.length() +} + +/// The text in `start..end`, freed with `sb_string_free`. +#[unsafe(no_mangle)] +pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char { + owned(view.canvas.text([start, end])) +} + #[unsafe(no_mangle)] pub extern "C" fn sb_selection(view: &View, range: &mut [u32; 2]) { - if let Some(selection) = report(view.selection()) { + if let Some(selection) = report(view.canvas.selection()) { *range = selection; } } #[unsafe(no_mangle)] pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool { - report(view.select([start, end])).is_some() + report(view.canvas.select([start, end])).is_some() } #[unsafe(no_mangle)] pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool { - report(view.marked()) + report(view.canvas.marked()) .flatten() .map(|marked| *range = marked) .is_some() @@ -597,15 +822,16 @@ pub unsafe extern "C" fn sb_set_marked( selected_start: u32, selected_end: u32, ) -> bool { - let text = unsafe { CStr::from_ptr(text) } - .to_string_lossy() - .into_owned(); - report(view.set_marked(text, [selected_start, selected_end])).unwrap_or(false) + report( + view.canvas + .set_marked(string(text), [selected_start, selected_end]), + ) + .unwrap_or(false) } #[unsafe(no_mangle)] pub extern "C" fn sb_unmark(view: &mut View) { - view.page.editor.finish_composition(); + view.canvas.page.editor.finish_composition(); } /// Types `text`, a lone `\n` as the Return key. @@ -614,13 +840,10 @@ pub extern "C" fn sb_unmark(view: &mut View) { /// `text` is NUL-terminated UTF-8. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool { - let text = unsafe { CStr::from_ptr(text) } - .to_string_lossy() - .into_owned(); - report(view.insert(text)).unwrap_or(false) + report(view.canvas.insert(string(text))).unwrap_or(false) } -/// Replaces `start..end` with `text`, as autocorrection and dictation do. +/// Replaces `start..end` with `text`, as autocorrection, dictation and cutting do. /// /// # Safety /// `text` is NUL-terminated UTF-8. @@ -631,12 +854,11 @@ pub unsafe extern "C" fn sb_replace( end: u32, text: *const c_char, ) -> bool { - let text = unsafe { CStr::from_ptr(text) } - .to_string_lossy() - .into_owned(); + let canvas = &mut view.canvas; report( - view.select([start, end]) - .and_then(|()| Ok(moved(view.page.commit_text(text)?))), + canvas + .select([start, end]) + .and_then(|()| canvas.hold(|page| Ok(moved(page.commit_text(string(text))?)))), ) .unwrap_or(false) } @@ -651,12 +873,10 @@ pub unsafe extern "C" fn sb_paste( text: *const c_char, language: *const c_char, ) -> bool { - let text = unsafe { CStr::from_ptr(text) }.to_string_lossy(); - let language = unsafe { CStr::from_ptr(language) }.to_string_lossy(); + let (text, language) = (string(text), canvas::language::lcid(&string(language))); report( - view.page - .paste(&text, canvas::language::lcid(&language)) - .map(moved), + view.canvas + .hold(|page| page.paste(&text, language).map(moved)), ) .unwrap_or(false) } @@ -664,9 +884,8 @@ pub unsafe extern "C" fn sb_paste( #[unsafe(no_mangle)] pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { report( - view.page - .key(&Key::Named(NamedKey::Backspace), None) - .map(moved), + view.canvas + .hold(|page| page.key(&Key::Named(NamedKey::Backspace), None).map(moved)), ) .unwrap_or(false) } @@ -674,7 +893,7 @@ pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { /// The caret at `offset` as `[x, y, width, height]` in the view's points. #[unsafe(no_mangle)] pub extern "C" fn sb_caret_rect(view: &View, offset: u32, rect: &mut [f32; 4]) -> bool { - report(view.caret_rect(offset)) + report(view.canvas.caret_rect(offset)) .map(|caret| *rect = caret) .is_some() } @@ -691,7 +910,7 @@ pub unsafe extern "C" fn sb_range_rects( rects: *mut [f32; 4], capacity: usize, ) -> usize { - let found = report(view.range_rects([start, end])).unwrap_or_default(); + let found = report(view.canvas.range_rects([start, end])).unwrap_or_default(); for (index, rect) in found.iter().take(capacity).enumerate() { unsafe { rects.add(index).write(*rect) }; } @@ -700,23 +919,8 @@ pub unsafe extern "C" fn sb_range_rects( #[unsafe(no_mangle)] pub extern "C" fn sb_closest(view: &View, x: f32, y: f32) -> u32 { - report(view.closest([x, y])).unwrap_or(0) + report(view.canvas.closest([x, y])).unwrap_or(0) } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn corpus_section_lists_its_pages() { - let path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../corpus/media-edit/candidate/Features.one" - ); - let section = Section::open(&std::fs::read(path).unwrap()).unwrap(); - for title in §ion.titles { - eprintln!("{title:?}"); - } - assert!(!section.pages.is_empty()); - } -} +mod tests; diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1d46514a8c9971107b17ae4adbedc3ba3b7463ff --- /dev/null +++ b/crates/mobile/src/tests.rs @@ -0,0 +1,160 @@ +use super::*; + +const FEATURES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/media-edit/candidate/Features.one" +); + +/// A page of the corpus section on a 402 × 874 point phone at 3 pixels per point, at 100% +/// with the page origin at the view's corner. +fn canvas(title: &str) -> Canvas { + let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap(); + let index = section + .headings + .iter() + .position(|(heading, _)| heading.to_str().unwrap() == title) + .unwrap(); + let mut canvas = Canvas::new(section.pages[index].clone(), [1206, 2622], 3.0).unwrap(); + canvas.set_transform(1.0, [0.0; 2]); + canvas +} + +/// A view point on the outline holding `text`, `fraction` of the way along its first line. +fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] { + let outline = canvas + .page + .editor + .visible_outlines() + .find(|outline| outline.shown_text().contains(text)) + .unwrap(); + let bounds = outline.bounds(); + let x = bounds.x0 as f32 + (bounds.x1 - bounds.x0) as f32 * fraction; + let y = bounds.y0 as f32 + 6.0; + [x * POINT, y * POINT] +} + +#[test] +fn the_section_lists_titles_and_levels() { + let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap(); + assert!( + section + .headings + .iter() + .any(|(title, _)| title.to_str() == Ok("Paragraph controls")) + ); + assert!(section.headings.iter().all(|(_, level)| *level >= 1)); +} + +#[test] +fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() { + let root = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/m6/native-features-01/notebook" + ); + let notebook = listing(Path::new(root)).unwrap(); + let names: Vec<_> = notebook + .sections + .iter() + .map(|tab| (tab.group.as_str(), tab.name.as_str())) + .collect(); + eprintln!("{names:?}"); + assert!(names.contains(&("", "Features"))); + assert!(notebook.sections.iter().any(|tab| !tab.group.is_empty())); + let lone = listing(Path::new(FEATURES)).unwrap(); + assert_eq!(lone.sections.len(), 1); +} + +#[test] +fn touches_route_to_text_the_page_and_the_focused_outlines_grip() { + let mut canvas = canvas("Paragraph controls"); + let text = point_on(&canvas, "Collapsed parent", 0.1); + let first = canvas.target(text); + assert!( + matches!(first, Target::ActiveText | Target::Text), + "{first:?}" + ); + assert!(canvas.press(text).unwrap()); + canvas.release().unwrap(); + assert_eq!(canvas.target(text), Target::ActiveText); + // The grip strip sits above the text; a finger reaches it from 16 points higher still. + let bounds = canvas.active().bounds(); + let above = [ + (bounds.x0 as f32 + 40.0) * POINT, + (bounds.y0 as f32 - 12.0) * POINT - 10.0, + ]; + assert_eq!(canvas.target(above), Target::Grip); + // Unfocused, the grips hide and a touch there reaches the page. + let _ = canvas.page.focus_changed(false).unwrap(); + assert_eq!(canvas.target(above), Target::Page); +} + +#[test] +fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() { + let mut canvas = canvas("Paragraph controls"); + let text = point_on(&canvas, "Collapsed parent", 0.1); + canvas.press(text).unwrap(); + canvas.release().unwrap(); + canvas.edit().unwrap(); + let before = canvas.active().origin(); + let grip = [(before[0] + 40.0) * POINT, (before[1] - 8.0) * POINT]; + assert_eq!(canvas.target(grip), Target::Grip); + canvas.press(grip).unwrap(); + for step in 1..=10 { + let step = step as f32; + canvas + .drag([grip[0] + step * 10.0, grip[1] + step * 7.0]) + .unwrap(); + } + canvas.release().unwrap(); + let after = canvas.active().origin(); + let margin = canvas.page.editor.margin_origin(); + assert_ne!(after, before); + for axis in 0..2 { + let cells = (after[axis] - margin[axis]) / 18.0; + assert!( + (cells - cells.round()).abs() < 1e-3, + "{after:?} off the grid" + ); + } + assert!(canvas.edit().unwrap().is_some()); + assert!(canvas.page.editor.can_undo()); +} + +#[test] +fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() { + let mut canvas = canvas("Paragraph controls"); + let text = point_on(&canvas, "Collapsed parent", 0.1); + canvas.press(text).unwrap(); + canvas.release().unwrap(); + let shown = canvas.active().shown_text(); + let length = canvas.length(); + assert_eq!(length as usize, shown.encode_utf16().count()); + assert_eq!(canvas.text([0, length]), shown); + let start = shown.find("Collapsed").unwrap() as u32; + canvas.select([start, start + 9]).unwrap(); + assert_eq!(canvas.selection().unwrap(), [start, start + 9]); + assert!(!canvas.range_rects([start, start + 9]).unwrap().is_empty()); + let caret = canvas.caret_rect(start).unwrap(); + let closest = canvas + .closest([caret[0] + 1.0, caret[1] + caret[3] / 2.0]) + .unwrap(); + assert!(closest.abs_diff(start) <= 1, "{closest} for {start}"); + // Every offset has a caret, including those around the collapsed children. + for offset in 0..=length { + canvas.caret_rect(offset).unwrap(); + } +} + +#[test] +fn undo_restores_typing() { + let mut canvas = canvas("Paragraph controls"); + let text = point_on(&canvas, "Collapsed parent", 0.1); + canvas.press(text).unwrap(); + canvas.release().unwrap(); + let before = canvas.active().shown_text(); + canvas.insert("hi".into()).unwrap(); + assert_ne!(canvas.active().shown_text(), before); + assert!(moved(canvas.page.undo(false).unwrap())); + assert_eq!(canvas.active().shown_text(), before); + assert!(canvas.page.editor.can_redo()); +}