From 76298065bb1d6758c3bce206ebf9a981c49759ed Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 12 Oct 2026 12:19:23 -0700 Subject: [PATCH] feat(mobile): keep Reader views editable through local save completion Retain source geometry, original history and associated save receipts while Reader reflows. Use native image history items with existing undo and redo actions; remove unused legacy verdict presentation from the Swift host. Refs #128 Refs #136 Assisted-by: gpt-6.1-sol --- Cargo.lock | 1 + apps/ios/Snowbound/CanvasView.swift | 182 +- apps/ios/Snowbound/Lists.swift | 8 +- apps/ios/Snowbound/Notebooks.swift | 6 +- apps/ios/Snowbound/PageViewController.swift | 11 +- apps/ios/Snowbound/Prototypes.swift | 20 +- apps/ios/Snowbound/Sync.swift | 5 +- crates/canvas/src/editor.rs | 1695 ++++++++++++----- crates/canvas/src/editor/link.rs | 92 +- crates/canvas/src/gpu/page.rs | 116 +- .../canvas/src/interaction/accessibility.rs | 158 +- crates/canvas/src/interaction/ink.rs | 3 + crates/canvas/src/interaction/mod.rs | 525 ++++- crates/canvas/src/interaction/profile.rs | 10 +- crates/canvas/src/interaction/tests.rs | 729 ++++++- crates/canvas/src/reading.rs | 180 +- crates/mobile/Cargo.toml | 1 + crates/mobile/include/snowbound.h | 7 +- crates/mobile/src/lib.rs | 403 ++-- crates/mobile/src/library.rs | 14 +- crates/mobile/src/recording.rs | 2 +- crates/mobile/src/tests.rs | 979 +++++++++- crates/notebook/src/session.rs | 27 +- crates/notebook/src/session/tests.rs | 46 + crates/snowbound/src/main.rs | 9 +- 25 files changed, 4286 insertions(+), 943 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dffc26b3ff23e45de5ce99533f92bfbcf8bdc6c0..c0c4f969714e94d5675e4288d48beae02fe55b6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2070,6 +2070,7 @@ dependencies = [ "pollster", "relay", "resvg", + "rusqlite", "serde", "serde_json", "tempfile", diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index 0bfa59a16d9903e720f828e040afb78f8b462b2f..c85cba2e40acca347be39556b0b79de480accca3 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -90,7 +90,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI private var left: CGFloat = 0 /// The zoom a double tap returns to. private var resting: CGFloat = 1 - private var tapZooming = false + private var canvasPlace: (zoom: CGFloat, resting: CGFloat, corner: CGPoint, left: CGFloat, scrolled: Bool, edited: Bool)? private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) private lazy var doubleTap: UITapGestureRecognizer = { let recognizer = UITapGestureRecognizer(target: self, action: #selector(doubleTapped)) @@ -230,7 +230,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI // Scrolling lays the view out too; only a new size reconfigures the surface. guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return } sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale)) - transform() + if readingShown { reflowReading(readingWidth) } else { transform() } } else { handle = sb_view_new( Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page, @@ -251,6 +251,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } } + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + if readingShown { reflowReading(readingWidth) } + } + override func didMoveToWindow() { super.didMoveToWindow() link?.invalidate() @@ -288,9 +293,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// reveal know neither the navigation bar nor the keyboard, so `transform` overrides them. private func sync() { guard let handle else { return } + let shown = readingShown + var restored: CGPoint? + if !shown, let place = canvasPlace { + canvasPlace = nil + syncing = true + minimumZoomScale = 0.25 + maximumZoomScale = 4 + zoomScale = place.zoom + resting = place.resting + left = place.left + scrolled = place.scrolled + restored = place.corner + syncing = false + } var bounds: [Float] = [0, 0, 0, 0] sb_view_content(handle, &bounds) - let corner = CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale) + let corner = restored ?? CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale) origin = CGPoint(x: CGFloat(bounds[0]), y: CGFloat(bounds[1])) let size = CGSize(width: CGFloat(bounds[2]) - origin.x, height: CGFloat(bounds[3]) - origin.y) syncing = true @@ -304,6 +323,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI private func transform() { guard let handle, !syncing else { return } + if readingShown { + syncing = true + zoomScale = 1 + contentOffset.x = margin - adjustedContentInset.left + syncing = false + } sb_view_set_transform( handle, Float(zoomScale), Float(origin.x + contentOffset.x / zoomScale), Float(origin.y + contentOffset.y / zoomScale)) @@ -338,7 +363,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } private func home() { - if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) } + if readingShown { transform() } + else if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) } } /// A drag that starts within 30ยฐ of an axis sticks to it; a 24-point stretch of the @@ -362,6 +388,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true + if readingShown { drag = nil; return } let moved = panGestureRecognizer.translation(in: self) let cross: WritableKeyPath? = abs(moved.x) <= abs(moved.y) * Drag.snap ? \.x : abs(moved.y) <= abs(moved.x) * Drag.snap ? \.y : nil @@ -369,6 +396,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { + guard !readingShown else { return } scrolled = true drag = nil pinchStart = spread.map { (zoomScale, $0) } @@ -376,9 +404,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI detentTick.prepare() } - func viewForZooming(in scrollView: UIScrollView) -> UIView? { content } + func viewForZooming(in scrollView: UIScrollView) -> UIView? { readingShown ? nil : content } func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard !syncing else { return } if var drag, isDragging || isDecelerating, !isZooming { let cross = drag.cross let along = cross == \CGPoint.x ? \CGPoint.y : \CGPoint.x @@ -419,6 +448,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// Pinching near 100% sticks there: within `detent` of it the zoom follows the fingers /// on a curve flat at 100% that meets them again at the band's edges. func scrollViewDidZoom(_ scrollView: UIScrollView) { + guard !syncing else { return } + if readingShown { + transform() + inputDelegate?.selectionDidChange(self) + return + } if let pinch = pinchGestureRecognizer, pinch.state == .changed, let pinchStart, let spread { // The scroll view's pinch reports the zoom it shows, so the fingers give the raw one. let raw = log(pinchStart.zoom * spread / pinchStart.spread) @@ -441,15 +476,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + let pinched = pinchStart != nil pinchStart = nil - if tapZooming { - tapZooming = false - } else { - // A pinch let go within the detent settles on 100%. - resting = abs(log(scale)) < Self.detent ? 1 : scale - if resting != scale { setZoomScale(resting, animated: true) } - UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey) - } + guard !readingShown, pinched else { return } + // A pinch let go within the detent settles on 100%. + resting = abs(log(scale)) < Self.detent ? 1 : scale + if resting != scale { setZoomScale(resting, animated: true) } + UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey) } @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) { @@ -459,8 +492,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// 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 + guard let handle, !readingShown else { return } 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 { @@ -601,7 +633,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// Gives the canvas `tool` for the touch starting, where it has another: switching lets /// picked drawings go. private func use(_ tool: InkTool) { - guard let handle, tool != appliedTool else { return } + guard let handle else { return } + if tool != .select, readingShown { showReading(false) } + guard tool != appliedTool else { return } appliedTool = tool sb_view_set_tool(handle, tool.code.tool, tool.code.detail, sectionColor) dirty = true @@ -652,6 +686,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI func showPicker(_ shown: Bool, fingerDraws draws: Bool = true) { guard shown != pickerShown, let host = superview else { return } if shown { + if readingShown { showReading(false) } picker.translatesAutoresizingMaskIntoConstraints = false host.addSubview(picker) NSLayoutConstraint.activate([ @@ -770,15 +805,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI inputDelegate?.textWillChange(self) } let changed = change() - if external { - inputDelegate?.textDidChange(self) - inputDelegate?.selectionDidChange(self) - } dirty = true if changed { + canvasPlace?.edited = true sync() revealCaret() } + if external { + inputDelegate?.textDidChange(self) + inputDelegate?.selectionDidChange(self) + } changedSelection() return changed } @@ -950,30 +986,62 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// The column a reading view fills: the screen's width less the room tags hang in. private var readingWidth: Float { Float(bounds.inset(by: safeAreaInsets).width - 34 - 12) } - /// Whether the page has a reading view here, and how it reads or why not. - var reading: (offered: Bool, verdict: String)? { - struct Reading: Decodable { - let offered: Bool - let verdict: String - } - guard let handle, let read = decode(Reading.self, sb_view_reading(handle, readingWidth)) else { return nil } - return (read.offered, read.verdict) + private var readingAnchor: [Float] { + [Float(origin.x + (contentOffset.x + adjustedContentInset.left + 34) / zoomScale), + Float(origin.y + (contentOffset.y + adjustedContentInset.top) / zoomScale)] } - /// Whether the page shows reflowed, as `showReading` left it. - private(set) var readingShown = false + struct Reading: Decodable { + let offered: Bool + let shown: Bool + } + + var reading: Reading? { + guard let handle else { return nil } + return decode(Reading.self, sb_view_reading(handle, readingWidth)) + } + + var readingShown: Bool { + guard let handle else { return false } + return decode(Reading.self, sb_view_reading(handle, 0))?.shown ?? false + } - /// Shows the page reflowed into the screen's width at 100%, read-only, or as laid out. + /// Shows the page reflowed into the screen's width at 100%, or as laid out. func showReading(_ on: Bool) { + guard on != readingShown else { return } + reflowReading(on ? readingWidth : 0) + } + + private func reflowReading(_ width: Float) { guard let handle else { return } - _ = resignFirstResponder() - readingShown = sb_view_set_reading(handle, on ? readingWidth : 0) - zoomScale = 1 - resting = 1 - scrolled = false + let wasShown = readingShown + let place = (zoom: zoomScale, resting: resting, + corner: CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale), + left: left, scrolled: scrolled, edited: false) + let edited = canvasPlace?.edited ?? false + var anchor = readingAnchor + inputDelegate?.selectionWillChange(self) + let shown = sb_view_set_reading(handle, width, &anchor) + if shown, !wasShown { + canvasPlace = place + syncing = true + minimumZoomScale = 1 + maximumZoomScale = 1 + zoomScale = 1 + resting = 1 + scrolled = true + drag = nil + pinchStart = nil + syncing = false + } sync() - left = margin - home() + if shown { + let y = CGFloat(anchor[1]) - origin.y - adjustedContentInset.top + contentOffset.y = max(-adjustedContentInset.top, min(y, contentSize.height - bounds.height + adjustedContentInset.bottom)) + } else if edited { + revealCaret() + } + inputDelegate?.selectionDidChange(self) dirty = true onChange?() } @@ -1018,6 +1086,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// Insert Space: the next drag moves what lies below or right of where it starts. func insertSpace() { guard let handle else { return } + if readingShown { showReading(false) } sb_view_insert_space(handle) spaceHint.show("Drag down or right to add space", in: self) } @@ -1056,9 +1125,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 } /// Shows the page as stored; with `discard`, after an edit was refused. - func reload(discard: Bool) { + func reload(discard: Bool, pendingOnly: Bool = false) { guard let handle else { return } - edit(external: true) { sb_view_reload(handle, discard) } + if pendingOnly && !sb_view_reload_ready(handle) { return } + guard readingShown else { + edit(external: true) { sb_view_reload(handle, discard, nil) } + return + } + var anchor = readingAnchor + inputDelegate?.selectionWillChange(self) + inputDelegate?.textWillChange(self) + let changed = sb_view_reload(handle, discard, &anchor) + sync() + if readingShown { + let y = CGFloat(anchor[1]) - origin.y - adjustedContentInset.top + contentOffset.y = max(-adjustedContentInset.top, min(y, contentSize.height - bounds.height + adjustedContentInset.bottom)) + } else if changed { + revealCaret() + } + inputDelegate?.textDidChange(self) + inputDelegate?.selectionDidChange(self) + dirty = true + changedSelection() } /// Selects the first place `query` occurs and brings it into view. @@ -1163,9 +1251,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// Commits marked text, so it is stored before the app leaves the screen. func commitComposition() { guard markedTextRange != nil else { return } - inputDelegate?.textWillChange(self) unmarkText() - inputDelegate?.textDidChange(self) } override var keyCommands: [UIKeyCommand]? { @@ -1264,7 +1350,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } set { guard let newValue else { return } - _ = choose(newValue) + if choose(newValue) { canvasPlace?.edited = true } + sync() + revealCaret() dirty = true changedSelection() } @@ -1289,8 +1377,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI func unmarkText() { guard let handle else { return } + inputDelegate?.selectionWillChange(self) + inputDelegate?.textWillChange(self) sb_unmark(handle) + canvasPlace?.edited = true + sync() + revealCaret() + inputDelegate?.textDidChange(self) + inputDelegate?.selectionDidChange(self) dirty = true + changedSelection() } var beginningOfDocument: UITextPosition { Position(0) } diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index bb98d2ada20ff1145f9b086931a8d54147f8d4ae..f4a5e756d4f0faddc3426f960fad2a7ab3a5ce56 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -150,7 +150,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke } updateSelectionActions() for name in [Sync.changed, Section.changed] { - NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(refreshSync(_:)), name: name, object: nil) } NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in guard let self else { return } @@ -244,7 +244,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke /// Reads every notebook's sync status, marks the rows it changed, and tells of a notebook /// that has just stopped syncing with edits waiting. - @objc private func refreshSync() { + @objc private func refreshSync(_ notification: Notification? = nil) { + guard notification?.userInfo?["unchanged"] as? Bool != true else { return } Sync.status { [weak self] notebooks in guard let self else { return } var changed: [Item] = [] @@ -1176,7 +1177,8 @@ final class PagesViewController: UITableViewController { } @objc private func changed(_ notification: Notification) { - guard notification.object as AnyObject? === section else { return } + guard notification.object as AnyObject? === section, + notification.userInfo?["unchanged"] as? Bool != true else { return } reload() } diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index b3241164c40a78ca6e1a5d1b6f4663c2ff350503..f5d394b5f306c0d32790f173910c08f2bf7cd158 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -701,10 +701,12 @@ final class Section { var status: UInt8 = 0 let flags = sb_section_poll(handle, &status) let changed = SaveStatus(rawValue: status) ?? .unknown - guard flags != 0 || changed != self.status else { return } + let unchanged = flags == 0 && changed == self.status self.status = changed if flags & Self.listed != 0 { reloadRows() } - NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": flags]) + NotificationCenter.default.post( + name: Self.changed, object: self, + userInfo: unchanged ? ["flags": flags, "unchanged": true] : ["flags": flags]) } /// A page's title as typed, shown in the list before the section reports it. diff --git a/apps/ios/Snowbound/PageViewController.swift b/apps/ios/Snowbound/PageViewController.swift index 05ac8ead8d67cbe2bab35bf585b59ae5833618f8..05aa5c55ec12251c277966297d193b49fa118ce3 100644 --- a/apps/ios/Snowbound/PageViewController.swift +++ b/apps/ios/Snowbound/PageViewController.swift @@ -32,9 +32,9 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate 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) }) + title: "undo", image: UIImage(systemName: "arrow.uturn.backward"), 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) }) + title: "redo", image: UIImage(systemName: "arrow.uturn.forward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) /// Shows the drawing tools, which a finger then draws with too. private lazy var draw = UIBarButtonItem( title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"), @@ -231,11 +231,11 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate ] } - /// Reading View: the page reflowed into the screen's width, read-only, where it reflows. + /// Reading View: the page reflowed into the screen's width where it reflows. private func readingAction() -> UIAction { let reading = canvas.reading return UIAction( - title: "Reading View", subtitle: reading.flatMap { Prototype.readingNote($0.verdict) }, + title: "Reading View", image: UIImage(systemName: "text.justify.left"), attributes: reading?.offered == true || canvas.readingShown ? [] : .disabled, state: canvas.readingShown ? .on : .off @@ -414,6 +414,9 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.") } else if flags & Section.remote != 0 { canvas.reload(discard: false) + } else { + canvas.reload(discard: false, pendingOnly: true) + if notification.userInfo?["unchanged"] as? Bool == true { return } } if flags & Section.listed != 0 { if section.row(of: page) == nil { diff --git a/apps/ios/Snowbound/Prototypes.swift b/apps/ios/Snowbound/Prototypes.swift index ad28a0be746bb488ae776587b9270a35c24f4ef1..9a72cb95fc89cc0c2f5855fe84e695420aa52d5b 100644 --- a/apps/ios/Snowbound/Prototypes.swift +++ b/apps/ios/Snowbound/Prototypes.swift @@ -4,28 +4,10 @@ import UIKit /// `-SnowboundReading YES`, `-SnowboundWelcome YES`, `-SnowboundSectionStrip YES` or /// `-SnowboundQuickNote YES` (Xcode's scheme, `simctl launch`, or `devicectl โ€ฆ --arguments`). enum Prototype { - /// Page menu: Reading View, the page reflowed into the screen's width, read-only. + /// Page menu: Reading View, the page reflowed into the screen's width. static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") } } -extension Prototype { - /// What the page menu says under Reading View for a verdict from `sb_view_reading`. - static func readingNote(_ verdict: String) -> String? { - let notes = [ - ("Fits", "Page already fits"), - ("RightToLeft", "Right-to-left pages not yet"), - ("Drawn", "Mostly drawings"), - ("Arranged", "Laid out as a canvas"), - ("Asides", "Side notes follow their lines"), - ("SideBySide", "Columns read left to right"), - ("Group", "Overlapping parts stay together"), - ("Wide", "Some parts stay wide"), - ] - let found = notes.filter { verdict.contains($0.0) }.map(\.1) - return found.isEmpty ? nil : found.joined(separator: " ยท ") - } -} - extension Prototype { /// First launch: a welcome where the list would be empty, the first notebook found opened /// at its first page, and the name asked at the first edit rather than the first open. diff --git a/apps/ios/Snowbound/Sync.swift b/apps/ios/Snowbound/Sync.swift index 86beaef13f15a90b3644caef5f1104eb2856b84d..03d21f507a25b46c40dc941260706e703e2f2933 100644 --- a/apps/ios/Snowbound/Sync.swift +++ b/apps/ios/Snowbound/Sync.swift @@ -203,7 +203,7 @@ final class SyncViewController: UITableViewController { Sync.offline = self?.offline.isOn ?? false }, for: .valueChanged) for name in [Sync.changed, Section.changed] { - NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(refresh(_:)), name: name, object: nil) } } @@ -219,7 +219,8 @@ final class SyncViewController: UITableViewController { timer?.invalidate() } - @objc private func refresh() { + @objc private func refresh(_ notification: Notification? = nil) { + guard notification?.userInfo?["unchanged"] as? Bool != true else { return } Sync.status { [weak self] notebooks in self?.notebooks = notebooks.filter { !$0.1.isEmpty } self?.tableView.reloadData() diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index dc8765a15759371842e12de4fb13ba66c3cfc721..d9e447dfc017d4c240d847bff83f4c1d0d64b2f1 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -220,7 +220,7 @@ pub struct TextOutline { min_width: Option, layout: onestore::document::Layout, document: TextDocument, - indents: Vec, + pub(crate) indents: Vec, shaped: OutlineLayout, selection: Selection, } @@ -428,77 +428,31 @@ impl TextOutline { } } + fn geometry(&self) -> OutlineGeometry<'_> { + OutlineGeometry { + source: self, + shaped: &self.shaped, + } + } + pub fn layouts(&self) -> impl Iterator { - let mut shaped = self.shaped.paragraphs.iter().peekable(); - self.document - .text_nodes() - .enumerate() - .filter_map(move |(index, node)| { - if shaped - .peek() - .is_some_and(|paragraph| paragraph.id == node.id) - { - Some((index, shaped.next().unwrap())) - } else { - None - } - }) + self.geometry().layouts() } - /// Layouts follow the text leaves minus hidden ones, so an index shared by a leaf and its - /// layout needs no search. fn visible_index(&self, source: usize) -> Result { - let id = self - .document - .leaf(source) - .ok_or(EditError::InvalidRange)? - .2 - .id; - if self - .shaped - .paragraphs - .get(source) - .is_some_and(|paragraph| paragraph.id == id) - { - return Ok(source); - } - self.shaped - .paragraphs - .iter() - .position(|paragraph| paragraph.id == id) - .ok_or(EditError::InvalidRange) + self.geometry().visible_index(source) } fn source_index(&self, visible: usize) -> usize { - let id = self.shaped.paragraphs[visible].id; - if self - .document - .leaf(visible) - .is_some_and(|(_, _, node)| node.id == id) - { - return visible; - } - self.document - .text_nodes() - .position(|paragraph| paragraph.id == id) - .unwrap() + self.geometry().source_index(visible) } - /// The start of the paragraph after visible paragraph `index` when it follows in the same - /// container, where a whole-paragraph selection ends so that deleting it takes the break. fn next_sibling_start(&self, index: usize) -> Option { - let next = - (index + 1 < self.shaped.paragraphs.len()).then(|| self.source_index(index + 1))?; - let (container, local, _) = self.document.leaf(self.source_index(index))?; - let (next_container, next_local, _) = self.document.leaf(next)?; - (container == next_container && next_local == local + 1).then_some(TextPosition { - paragraph: next, - offset: 0, - }) + self.geometry().next_sibling_start(index) } pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> { - Ok(&self.shaped.paragraphs[self.visible_index(source)?]) + self.geometry().paragraph_layout(source) } /// The shown paragraphs with their source text: those a collapsed parent hides have no @@ -542,6 +496,157 @@ impl TextOutline { focus: TextPosition, affinity: Affinity, width: f32, + ) -> Result { + self.geometry().caret_at(focus, affinity, width) + } + + /// Highlight rectangles of `selection` in the outline, in its coordinates. + pub fn range_rects(&self, selection: Selection) -> Result, EditError> { + self.geometry().range_rects(selection) + } + + /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each + /// line's left, right and baseline. + #[cfg(test)] + pub(crate) fn underlines( + &self, + index: usize, + range: Range, + ) -> Result, EditError> { + self.geometry().underlines(index, range) + } + + /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the + /// next shown one. + pub fn utf16_offset(&self, position: TextPosition) -> Result { + let mut offset = 0; + for (index, paragraph) in self.shown() { + if index == position.paragraph { + return Ok(offset + position.offset); + } + if index > position.paragraph { + return Ok(offset); + } + offset += paragraph.utf16_offset(paragraph.text().len())? + 1; + } + Ok(offset.saturating_sub(1)) + } + + /// The source position at `offset` in `shown_text`, clamped to its end. + pub fn utf16_position(&self, offset: u32) -> Result { + let mut rest = offset; + let mut end = None; + for (index, paragraph) in self.shown() { + let length = paragraph.utf16_offset(paragraph.text().len())?; + if rest <= length { + return Ok(TextPosition { + paragraph: index, + offset: rest, + }); + } + rest -= length + 1; + end = Some(TextPosition { + paragraph: index, + offset: length, + }); + } + end.ok_or(EditError::InvalidRange) + } +} + +#[derive(Clone, Copy)] +pub(crate) struct OutlineGeometry<'a> { + pub(crate) source: &'a TextOutline, + pub(crate) shaped: &'a OutlineLayout, +} + +struct Navigation { + selection: Selection, + preferred_x: Option, + boundary: bool, +} + +impl<'a> OutlineGeometry<'a> { + pub(crate) fn layouts(self) -> impl Iterator + 'a { + let mut shaped = self.shaped.paragraphs.iter().peekable(); + self.source + .document + .text_nodes() + .enumerate() + .filter_map(move |(index, node)| { + if shaped + .peek() + .is_some_and(|paragraph| paragraph.id == node.id) + { + Some((index, shaped.next().unwrap())) + } else { + None + } + }) + } + + /// Hidden leaves have no layout; matching IDs avoid searching the common flat case. + fn visible_index(self, source: usize) -> Result { + let id = self + .source + .document + .leaf(source) + .ok_or(EditError::InvalidRange)? + .2 + .id; + if self + .shaped + .paragraphs + .get(source) + .is_some_and(|paragraph| paragraph.id == id) + { + return Ok(source); + } + self.shaped + .paragraphs + .iter() + .position(|paragraph| paragraph.id == id) + .ok_or(EditError::InvalidRange) + } + + fn source_index(self, visible: usize) -> usize { + let id = self.shaped.paragraphs[visible].id; + if self + .source + .document + .leaf(visible) + .is_some_and(|(_, _, node)| node.id == id) + { + return visible; + } + self.source + .document + .text_nodes() + .position(|paragraph| paragraph.id == id) + .unwrap() + } + + /// Including a sibling's start lets whole-paragraph deletion take the break. + fn next_sibling_start(self, index: usize) -> Option { + let next = + (index + 1 < self.shaped.paragraphs.len()).then(|| self.source_index(index + 1))?; + let (container, local, _) = self.source.document.leaf(self.source_index(index))?; + let (next_container, next_local, _) = self.source.document.leaf(next)?; + (container == next_container && next_local == local + 1).then_some(TextPosition { + paragraph: next, + offset: 0, + }) + } + + fn paragraph_layout(self, source: usize) -> Result<&'a ParagraphLayout, EditError> { + Ok(&self.shaped.paragraphs[self.visible_index(source)?]) + } + + pub(crate) fn caret_at( + self, + focus: TextPosition, + affinity: Affinity, + width: f32, ) -> Result { let paragraph = self.paragraph_layout(focus.paragraph)?; let cursor = paragraph.cursor(focus.offset, affinity)?; @@ -553,8 +658,7 @@ impl TextOutline { Ok(rect) } - /// Highlight rectangles of `selection` in the outline, in its coordinates. - pub fn range_rects(&self, selection: Selection) -> Result, EditError> { + pub(crate) fn range_rects(self, selection: Selection) -> Result, EditError> { let [anchor, focus] = selection.positions; if anchor == focus { return Ok(Vec::new()); @@ -574,7 +678,7 @@ impl TextOutline { .enumerate() .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph) { - let source = self.document.paragraph(index).unwrap(); + let source = self.source.document.paragraph(index).unwrap(); let first = if index == start.paragraph { paragraph.cursor(start.offset, affinities[0])? } else { @@ -611,10 +715,8 @@ impl TextOutline { Ok(rectangles) } - /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each - /// line's left, right and baseline. pub(crate) fn underlines( - &self, + self, index: usize, range: Range, ) -> Result, EditError> { @@ -632,44 +734,48 @@ impl TextOutline { .collect()) } - /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the - /// next shown one. - pub fn utf16_offset(&self, position: TextPosition) -> Result { - let mut offset = 0; - for (index, paragraph) in self.shown() { - if index == position.paragraph { - return Ok(offset + position.offset); - } - if index > position.paragraph { - return Ok(offset); - } - offset += paragraph.utf16_offset(paragraph.text().len())? + 1; + pub(crate) fn selection_at( + self, + x: f32, + y: f32, + unit: SelectionUnit, + ) -> Result { + if !x.is_finite() || !y.is_finite() { + return Err(EditError::InvalidRange); } - Ok(offset.saturating_sub(1)) - } - - /// The source position at `offset` in `shown_text`, clamped to its end. - pub fn utf16_position(&self, offset: u32) -> Result { - let mut rest = offset; - let mut end = None; - for (index, paragraph) in self.shown() { - let length = paragraph.utf16_offset(paragraph.text().len())?; - if rest <= length { - return Ok(TextPosition { - paragraph: index, - offset: rest, - }); - } - rest -= length + 1; - end = Some(TextPosition { - paragraph: index, - offset: length, - }); + let index = self.paragraph_at(x, y)?; + let paragraph = &self.shaped.paragraphs[index]; + let layout = ¶graph.text.shaped; + let point = [x - paragraph.origin[0], y - paragraph.origin[1]]; + let local = edit::selection_at( + layout, + paragraph.text.hit_test(point[0], point[1]), + point[0], + unit, + ); + let position = |cursor: Cursor| -> Result { + let visible = paragraph.projection.text().utf16_offset(cursor.index())?; + Ok(TextPosition { + paragraph: self.source_index(index), + offset: paragraph + .projection + .source_offset(visible, crate::affinity(cursor.affinity()))?, + }) + }; + let mut selection = Selection { + positions: [position(local.anchor())?, position(local.focus())?], + affinities: [local.anchor().affinity(), local.focus().affinity()], + }; + if unit == SelectionUnit::Paragraph + && let Some(next) = self.next_sibling_start(index) + { + selection.positions[1] = next; + selection.affinities[1] = Affinity::Downstream; } - end.ok_or(EditError::InvalidRange) + Ok(selection) } - fn column(&self, x: f32, current: ExGuid) -> Result, EditError> { + fn column(self, x: f32, current: ExGuid) -> Result, EditError> { let paragraphs = self .shaped .paragraphs @@ -684,7 +790,7 @@ impl TextOutline { .map(|table| (table.id, table)) .collect::>(); let mut result = Vec::new(); - let mut pending = vec![visible_paragraphs(self.document.nodes().iter())]; + let mut pending = vec![visible_paragraphs(self.source.document.nodes().iter())]; while let Some(nodes) = pending.last_mut() { let Some(node) = nodes.next() else { pending.pop(); @@ -742,7 +848,207 @@ impl TextOutline { Ok(result) } - fn paragraph_at(&self, x: f32, y: f32) -> Result { + fn navigate( + self, + mut selection: Selection, + movement: Movement, + extend: bool, + mut preferred_x: Option, + ) -> Result { + let [anchor, focus] = selection.positions; + if matches!( + movement, + Movement::ParagraphStart + | Movement::ParagraphEnd + | Movement::DocumentStart + | Movement::DocumentEnd + ) { + let current = self.visible_index(focus.paragraph)?; + let end = matches!(movement, Movement::ParagraphEnd | Movement::DocumentEnd); + let current_end = self.source.document.paragraph(focus.paragraph).unwrap(); + let current_end = current_end.utf16_offset(current_end.text().len())?; + let index = match movement { + Movement::DocumentStart => 0, + Movement::DocumentEnd => self.shaped.paragraphs.len() - 1, + Movement::ParagraphStart if focus.offset == 0 => current.saturating_sub(1), + Movement::ParagraphEnd if focus.offset == current_end => { + (current + 1).min(self.shaped.paragraphs.len() - 1) + } + _ => current, + }; + let source = self.source_index(index); + let paragraph = self.source.document.paragraph(source).unwrap(); + let position = TextPosition { + paragraph: source, + offset: if end { + paragraph.utf16_offset(paragraph.text().len())? + } else { + 0 + }, + }; + let affinity = if end { + Affinity::Upstream + } else { + Affinity::Downstream + }; + if !extend { + selection.positions[0] = position; + selection.affinities[0] = affinity; + } + selection.positions[1] = position; + selection.affinities[1] = affinity; + return Ok(Navigation { + selection, + preferred_x: None, + boundary: false, + }); + } + let horizontal = matches!( + movement, + Movement::Left | Movement::Right | Movement::WordLeft | Movement::WordRight + ); + let left = matches!(movement, Movement::Left | Movement::WordLeft); + if horizontal && !extend && anchor.paragraph != focus.paragraph { + let index = usize::from(if left { anchor > focus } else { anchor < focus }); + let selection = Selection { + positions: [selection.positions[index]; 2], + affinities: [selection.affinities[index]; 2], + }; + return Ok(Navigation { + selection, + preferred_x: None, + boundary: false, + }); + } + let mut index = self.visible_index(focus.paragraph)?; + let paragraph = &self.shaped.paragraphs[index]; + let cursor = paragraph.cursor(focus.offset, selection.affinities[1])?; + let mut next; + let boundary; + if matches!(movement, Movement::Up | Movement::Down) { + let caret = paragraph.text.caret(cursor, 1.0); + let x = preferred_x.unwrap_or(caret.x0 as f32 + paragraph.origin[0]); + let line = paragraph + .text + .lines() + .position(|(_, line)| f64::from(line.top) == caret.y0) + .unwrap(); + let up = matches!(movement, Movement::Up); + let target = if up { + line.checked_sub(1) + .and_then(|line| paragraph.text.lines().nth(line)) + } else { + paragraph.text.lines().nth(line + 1) + } + .map(|(_, line)| line); + let target = if target.is_some() { + target + } else { + let neighbor = if self.shaped.tables.is_empty() { + if up { + index.checked_sub(1) + } else { + (index + 1 < self.shaped.paragraphs.len()).then_some(index + 1) + } + } else { + let column = self.column(x, paragraph.id)?; + let current = column + .iter() + .position(|candidate| *candidate == index) + .ok_or(EditError::InvalidStructure)?; + if up { + current.checked_sub(1).map(|i| column[i]) + } else { + column.get(current + 1).copied() + } + }; + neighbor.and_then(|neighbor| { + index = neighbor; + let paragraph = &self.shaped.paragraphs[index]; + if up { + paragraph.text.lines().last() + } else { + paragraph.text.lines().next() + } + .map(|(_, line)| line) + }) + }; + boundary = target.is_none(); + next = target + .map(|line| { + self.shaped.paragraphs[index].text.hit_test( + x - self.shaped.paragraphs[index].origin[0], + line.top + line.height * 0.5, + ) + }) + .unwrap_or(cursor); + preferred_x = Some(x); + } else { + let local = if anchor.paragraph == focus.paragraph { + ParagraphSelection::new( + paragraph.cursor(anchor.offset, selection.affinities[0])?, + cursor, + ) + } else { + cursor.into() + }; + next = edit::step(¶graph.text.shaped, local, movement, extend).focus(); + if horizontal && next == cursor && (extend || anchor == focus) { + let neighbor = if left { + index.checked_sub(1) + } else { + (index + 1 < self.shaped.paragraphs.len()).then_some(index + 1) + }; + if let Some(neighbor) = neighbor { + index = neighbor; + let paragraph = &self.shaped.paragraphs[index]; + let line = if left { + paragraph.text.lines().last() + } else { + paragraph.text.lines().next() + } + .unwrap() + .1; + next = paragraph.text.hit_test( + if left { f32::MAX } else { -1.0 }, + line.top + line.height * 0.5, + ); + if matches!(movement, Movement::WordLeft | Movement::WordRight) { + next = edit::word_cursor(¶graph.text.shaped, next, left); + } + } + } + boundary = !extend + && anchor == focus + && next == cursor + && self.source_index(index) == focus.paragraph + && matches!(movement, Movement::Left | Movement::Right); + } + if !matches!(movement, Movement::Up | Movement::Down) { + preferred_x = None; + } + let paragraph = &self.shaped.paragraphs[index]; + let visible = paragraph.projection.text().utf16_offset(next.index())?; + let position = TextPosition { + paragraph: self.source_index(index), + offset: paragraph + .projection + .source_offset(visible, crate::affinity(next.affinity()))?, + }; + if !extend { + selection.positions[0] = position; + selection.affinities[0] = next.affinity(); + } + selection.positions[1] = position; + selection.affinities[1] = next.affinity(); + Ok(Navigation { + selection, + preferred_x, + boundary, + }) + } + + fn paragraph_at(self, x: f32, y: f32) -> Result { if self.shaped.tables.is_empty() { return Ok(self .shaped @@ -763,7 +1069,7 @@ impl TextOutline { .iter() .map(|table| (table.id, table)) .collect::>(); - let mut nodes = self.document.nodes(); + let mut nodes = self.source.document.nodes(); loop { let mut target = None; for node in visible_paragraphs(nodes.iter()) { @@ -822,7 +1128,7 @@ impl TextOutline { .min_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2))) .ok_or(EditError::InvalidStructure)? .0; - nodes = self.document.container(Some(cell.id))?; + nodes = self.source.document.container(Some(cell.id))?; } } } @@ -2994,37 +3300,7 @@ impl CanvasEditor { y: f32, unit: SelectionUnit, ) -> Result { - if !x.is_finite() || !y.is_finite() { - return Err(EditError::InvalidRange); - } - let index = self.active_outline().paragraph_at(x, y)?; - let paragraph = &self.active_outline().shaped.paragraphs[index]; - let layout = ¶graph.text.shaped; - let point = [x - paragraph.origin[0], y - paragraph.origin[1]]; - let local = edit::selection_at( - layout, - paragraph.text.hit_test(point[0], point[1]), - point[0], - unit, - ); - let mut selection = self.selection(); - for (slot, cursor) in [local.anchor(), local.focus()].into_iter().enumerate() { - let visible = paragraph.projection.text().utf16_offset(cursor.index())?; - selection.positions[slot] = TextPosition { - paragraph: self.active_outline().source_index(index), - offset: paragraph - .projection - .source_offset(visible, crate::affinity(cursor.affinity()))?, - }; - selection.affinities[slot] = cursor.affinity(); - } - if unit == SelectionUnit::Paragraph - && let Some(next) = self.active_outline().next_sibling_start(index) - { - selection.positions[1] = next; - selection.affinities[1] = Affinity::Downstream; - } - Ok(selection) + self.active_outline().geometry().selection_at(x, y, unit) } pub fn caret(&self, width: f32) -> Result { @@ -3048,12 +3324,46 @@ impl CanvasEditor { movement: Movement, extend: bool, ) -> Result<(), EditorError> { + if self.prepare_navigation(movement, extend)? { + return Ok(()); + } + self.move_selection_prepared(engine, movement, extend) + } + + pub(crate) fn prepare_navigation( + &mut self, + movement: Movement, + extend: bool, + ) -> Result { self.finish_composition(); self.whole = None; self.typing = None; - if !extend && movement == Movement::Right && self.leave_equation()? { - return Ok(()); + Ok(!extend && movement == Movement::Right && self.leave_equation()?) + } + + #[cfg(feature = "interaction")] + pub(crate) fn move_selection_in( + &mut self, + shaped: &OutlineLayout, + movement: Movement, + extend: bool, + ) -> Result<(), EditorError> { + let navigation = OutlineGeometry { + source: self.active_outline(), + shaped, } + .navigate(self.selection(), movement, extend, self.preferred_x)?; + self.preferred_x = navigation.preferred_x; + self.active_outline_mut().selection = navigation.selection; + Ok(()) + } + + pub(crate) fn move_selection_prepared( + &mut self, + engine: &mut TextEngine, + movement: Movement, + extend: bool, + ) -> Result<(), EditorError> { let [anchor, focus] = self.active_outline().selection.positions; if !extend && self.caret_outline().is_some() @@ -3113,308 +3423,151 @@ impl CanvasEditor { } return Ok(()); } - if matches!( + let navigation = self.active_outline().geometry().navigate( + self.selection(), movement, - Movement::ParagraphStart - | Movement::ParagraphEnd - | Movement::DocumentStart - | Movement::DocumentEnd - ) { - let outline = self.active_outline(); - let current = outline.visible_index(focus.paragraph)?; - let end = matches!(movement, Movement::ParagraphEnd | Movement::DocumentEnd); - let current_end = outline.document.paragraph(focus.paragraph).unwrap(); - let current_end = current_end.utf16_offset(current_end.text().len())?; - let index = match movement { - Movement::DocumentStart => 0, - Movement::DocumentEnd => outline.shaped.paragraphs.len() - 1, - Movement::ParagraphStart if focus.offset == 0 => current.saturating_sub(1), - Movement::ParagraphEnd if focus.offset == current_end => { - (current + 1).min(outline.shaped.paragraphs.len() - 1) - } - _ => current, - }; - let source = outline.source_index(index); - let paragraph = outline.document.paragraph(source).unwrap(); - let position = TextPosition { - paragraph: source, - offset: if end { - paragraph.utf16_offset(paragraph.text().len())? - } else { - 0 - }, - }; - let affinity = if end { - Affinity::Upstream - } else { - Affinity::Downstream - }; - let selection = &mut self.active_outline_mut().selection; - if !extend { - selection.positions[0] = position; - selection.affinities[0] = affinity; - } - selection.positions[1] = position; - selection.affinities[1] = affinity; - self.preferred_x = None; - return Ok(()); - } - let horizontal = matches!( - movement, - Movement::Left | Movement::Right | Movement::WordLeft | Movement::WordRight - ); - let left = matches!(movement, Movement::Left | Movement::WordLeft); - if horizontal && !extend && anchor.paragraph != focus.paragraph { - let index = usize::from(if left { anchor > focus } else { anchor < focus }); - self.active_outline_mut().selection.positions = - [self.active_outline().selection.positions[index]; 2]; - self.active_outline_mut().selection.affinities = - [self.active_outline().selection.affinities[index]; 2]; - self.preferred_x = None; - return Ok(()); - } - let mut index = self.active_outline().visible_index(focus.paragraph)?; - let paragraph = &self.active_outline().shaped.paragraphs[index]; - let cursor = - paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?; - let mut next; - if matches!(movement, Movement::Up | Movement::Down) { - let caret = paragraph.text.caret(cursor, 1.0); - let x = self + extend, + self.preferred_x, + )?; + if navigation.boundary + && matches!(movement, Movement::Up | Movement::Down) + && !extend + && anchor == focus + && !self.active_outline().title + { + let x = navigation .preferred_x - .unwrap_or(caret.x0 as f32 + paragraph.origin[0]); - let line = paragraph - .text - .lines() - .position(|(_, line)| f64::from(line.top) == caret.y0) - .unwrap(); + .expect("vertical navigation retains its column"); + let outline = self.active_outline(); + let last = outline.document.nodes().len() - 1; let up = matches!(movement, Movement::Up); - let target = if up { - line.checked_sub(1) - .and_then(|line| paragraph.text.lines().nth(line)) - } else { - paragraph.text.lines().nth(line + 1) - } - .map(|(_, line)| line); - let target = if target.is_some() { - target - } else { - let neighbor = if self.active_outline().shaped.tables.is_empty() { - if up { - index.checked_sub(1) - } else { - (index + 1 < self.active_outline().shaped.paragraphs.len()) - .then_some(index + 1) + let base_node = &outline.document.nodes()[if up { 0 } else { last }]; + let node = self.blank_paragraph(base_node)?; + let trailing = outline + .document + .nodes() + .iter() + .rev() + .take_while(|node| { + node.text() + .is_some_and(|text| text.text.text().bytes().all(|byte| byte == b' ')) + }) + .count(); + let position = if up { + Some([ + outline.origin()[0] + x, + (((f64::from(outline.origin()[1]) / 18.0).ceil() - 3.0) * 18.0 + 14.4) as f32, + ]) + } else if outline.origin()[0] >= 180.0 && trailing >= 2 { + let node = &outline.document.nodes()[last.saturating_sub(trailing)]; + let bottom = match &node.content { + onestore::page::ParagraphContent::Text(_) => { + let paragraph = outline + .shaped + .paragraphs + .iter() + .find(|paragraph| paragraph.id == node.id) + .ok_or(EditError::InvalidStructure)?; + f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height()) } - } else { - let column = self.active_outline().column(x, paragraph.id)?; - let current = column + onestore::page::ParagraphContent::Table(table) => { + let cell = outline + .shaped + .tables + .iter() + .find(|layout| layout.id == table.id) + .and_then(|layout| layout.cells.last()) + .ok_or(EditError::InvalidStructure)?; + f64::from(cell.rect[3]) + 1.68 + } + onestore::page::ParagraphContent::Image(onestore::page::Image { + id, .. + }) + | onestore::page::ParagraphContent::Attachment(onestore::page::Attachment { + id, + .. + }) + | onestore::page::ParagraphContent::Ink(onestore::page::Ink { id, .. }) + | onestore::page::ParagraphContent::Unsupported( + onestore::page::Unsupported { id, .. }, + ) => outline + .shaped + .objects .iter() - .position(|candidate| *candidate == index) - .ok_or(EditError::InvalidStructure)?; - if up { - current.checked_sub(1).map(|i| column[i]) - } else { - column.get(current + 1).copied() - } + .find(|object| object.id == *id) + .map(|object| f64::from(object.bottom)) + .ok_or(EditError::InvalidStructure)?, + } + f64::from(outline.origin()[1]); + let cell = (bottom - 14.4) / 18.0; + // Stored coordinates can straddle a grid boundary by one f32 ULP. + let grid = if (cell - cell.round()).abs() + <= f64::from(f32::EPSILON) * cell.abs().max(1.0) + { + cell.round() + } else { + cell.ceil() }; - neighbor.and_then(|neighbor| { - index = neighbor; - let paragraph = &self.active_outline().shaped.paragraphs[index]; - if up { - paragraph.text.lines().last() - } else { - paragraph.text.lines().next() - } - .map(|(_, line)| line) - }) + Some([outline.origin()[0], ((grid + 3.0) * 18.0 + 14.4) as f32]) + } else { + None }; - if target.is_none() && !extend && anchor == focus && !self.active_outline().title { - let outline = self.active_outline(); - let last = outline.document.nodes().len() - 1; - let up = matches!(movement, Movement::Up); - let base_node = &outline.document.nodes()[if up { 0 } else { last }]; - let node = self.blank_paragraph(base_node)?; - let trailing = outline - .document - .nodes() - .iter() - .rev() - .take_while(|node| { - node.text() - .is_some_and(|text| text.text.text().bytes().all(|byte| byte == b' ')) - }) - .count(); - let position = if up { - Some([ - outline.origin()[0] + x, - (((f64::from(outline.origin()[1]) / 18.0).ceil() - 3.0) * 18.0 + 14.4) - as f32, - ]) - } else if outline.origin()[0] >= 180.0 && trailing >= 2 { - let node = &outline.document.nodes()[last.saturating_sub(trailing)]; - let bottom = match &node.content { - onestore::page::ParagraphContent::Text(_) => { - let paragraph = outline - .shaped - .paragraphs - .iter() - .find(|paragraph| paragraph.id == node.id) - .ok_or(EditError::InvalidStructure)?; - f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height()) - } - onestore::page::ParagraphContent::Table(table) => { - let cell = outline - .shaped - .tables - .iter() - .find(|layout| layout.id == table.id) - .and_then(|layout| layout.cells.last()) - .ok_or(EditError::InvalidStructure)?; - f64::from(cell.rect[3]) + 1.68 - } - onestore::page::ParagraphContent::Image(onestore::page::Image { - id, - .. - }) - | onestore::page::ParagraphContent::Attachment( - onestore::page::Attachment { id, .. }, - ) - | onestore::page::ParagraphContent::Ink(onestore::page::Ink { - id, .. - }) - | onestore::page::ParagraphContent::Unsupported( - onestore::page::Unsupported { id, .. }, - ) => outline - .shaped - .objects - .iter() - .find(|object| object.id == *id) - .map(|object| f64::from(object.bottom)) - .ok_or(EditError::InvalidStructure)?, - } + f64::from(outline.origin()[1]); - let cell = (bottom - 14.4) / 18.0; - // Stored coordinates can straddle a grid boundary by one f32 ULP. - let grid = if (cell - cell.round()).abs() - <= f64::from(f32::EPSILON) * cell.abs().max(1.0) - { - cell.round() - } else { - cell.ceil() - }; - Some([outline.origin()[0], ((grid + 3.0) * 18.0 + 14.4) as f32]) - } else { - None - }; - if let Some(position) = position { - let outline = TextOutline::new( - engine, - TextDocument::from_nodes(vec![node])?, - DEFAULT_OUTLINE_WIDTH, - position, - )?; - self.active = Focus::Caret { - outline: Box::new(outline), - index: self.outlines.len(), - }; - self.preferred_x = Some(if up { 0.0 } else { x }); - return Ok(()); - } - let edit = DocumentEdit { - columns: BTreeMap::new(), - container: None, - range: last + 1..last + 1, - replacement: vec![PageParagraph { - parent: base_node.parent, - ..node - }], - }; - let paragraph = outline.document.paragraphs().count(); - let previous = if let Focus::Outline(index) = self.active { - let draft = self.outlines[index].clone(); - Some(std::mem::replace( - &mut self.active, - Focus::Draft { - index, - outline: Box::new(draft), - }, - )) - } else { - None - }; - if let Err(error) = self.apply( + if let Some(position) = position { + let outline = TextOutline::new( engine, - edit, - [TextPosition { - paragraph, - offset: 0, - }; 2] - .into(), - false, - None, - ) { - if let Some(previous) = previous { - self.active = previous; - } - return Err(error); - } - self.preferred_x = Some(x); + TextDocument::from_nodes(vec![node])?, + DEFAULT_OUTLINE_WIDTH, + position, + )?; + self.active = Focus::Caret { + outline: Box::new(outline), + index: self.outlines.len(), + }; + self.preferred_x = Some(if up { 0.0 } else { x }); return Ok(()); } - next = target - .map(|line| { - self.active_outline().shaped.paragraphs[index] - .text - .hit_test( - x - self.active_outline().shaped.paragraphs[index].origin[0], - line.top + line.height * 0.5, - ) - }) - .unwrap_or(cursor); + let edit = DocumentEdit { + columns: BTreeMap::new(), + container: None, + range: last + 1..last + 1, + replacement: vec![PageParagraph { + parent: base_node.parent, + ..node + }], + }; + let paragraph = outline.document.paragraphs().count(); + let previous = if let Focus::Outline(index) = self.active { + let draft = self.outlines[index].clone(); + Some(std::mem::replace( + &mut self.active, + Focus::Draft { + index, + outline: Box::new(draft), + }, + )) + } else { + None + }; + if let Err(error) = self.apply( + engine, + edit, + [TextPosition { + paragraph, + offset: 0, + }; 2] + .into(), + false, + None, + ) { + if let Some(previous) = previous { + self.active = previous; + } + return Err(error); + } self.preferred_x = Some(x); - } else { - let local = if anchor.paragraph == focus.paragraph { - ParagraphSelection::new( - paragraph - .cursor(anchor.offset, self.active_outline().selection.affinities[0])?, - cursor, - ) - } else { - cursor.into() - }; - next = edit::step(¶graph.text.shaped, local, movement, extend).focus(); - if horizontal && next == cursor && (extend || anchor == focus) { - let neighbor = if left { - index.checked_sub(1) - } else { - (index + 1 < self.active_outline().shaped.paragraphs.len()).then_some(index + 1) - }; - if let Some(neighbor) = neighbor { - index = neighbor; - let paragraph = &self.active_outline().shaped.paragraphs[index]; - let line = if left { - paragraph.text.lines().last() - } else { - paragraph.text.lines().next() - } - .unwrap() - .1; - next = paragraph.text.hit_test( - if left { f32::MAX } else { -1.0 }, - line.top + line.height * 0.5, - ); - if matches!(movement, Movement::WordLeft | Movement::WordRight) { - next = edit::word_cursor(¶graph.text.shaped, next, left); - } - } - } + return Ok(()); } - if !extend - && anchor == focus - && next == cursor - && self.active_outline().source_index(index) == focus.paragraph - && matches!(movement, Movement::Left | Movement::Right) - { + if navigation.boundary && matches!(movement, Movement::Left | Movement::Right) { + let left = movement == Movement::Left; let caret = self.caret(1.0)?; let origin = self.active_outline().origin(); let x = caret.x0 + f64::from(origin[0]); @@ -3443,41 +3596,17 @@ impl CanvasEditor { return Ok(()); } } - if !matches!(movement, Movement::Up | Movement::Down) { - self.preferred_x = None; - } - let paragraph = &self.active_outline().shaped.paragraphs[index]; - let visible = paragraph.projection.text().utf16_offset(next.index())?; - let position = TextPosition { - paragraph: self.active_outline().source_index(index), - offset: paragraph - .projection - .source_offset(visible, crate::affinity(next.affinity()))?, - }; - if !extend { - self.active_outline_mut().selection.positions[0] = position; - self.active_outline_mut().selection.affinities[0] = next.affinity(); - } - self.active_outline_mut().selection.positions[1] = position; - self.active_outline_mut().selection.affinities[1] = next.affinity(); + self.preferred_x = navigation.preferred_x; + self.active_outline_mut().selection = navigation.selection; Ok(()) } + #[cfg(test)] pub(crate) fn selection_rects(&self) -> Result, EditError> { self.active_outline() .range_rects(self.active_outline().selection) } - pub(crate) fn marked_rects(&self) -> Result, EditError> { - match &self.composition { - Some(composition) => self.active_outline().range_rects(Selection { - positions: [composition.range.start, composition.range.end], - affinities: [Affinity::Downstream, Affinity::Upstream], - }), - None => Ok(Vec::new()), - } - } - pub fn replace( &mut self, engine: &mut TextEngine, @@ -4993,10 +5122,8 @@ fn holds(content: &ParagraphContent, id: ExGuid) -> bool { } } -/// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same -/// paragraph, past what changed in its text when it lies after it; at the start of the -/// paragraph now at its place when that one is gone. -fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { +/// Follows paragraph IDs and UTF-16 through edits; deleted paragraphs clamp to their surviving index. +pub fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { let found = old.leaf(position.paragraph).and_then(|(_, _, node)| { let paragraph = new .text_nodes() @@ -5087,6 +5214,660 @@ mod tests { use super::*; use onestore::document::Format; + fn alternate_shape( + engine: &mut TextEngine, + editor: &CanvasEditor, + width: f32, + ) -> OutlineLayout { + let outline = editor.active_outline(); + OutlineLayout::flow( + outline.document.nodes().iter(), + &outline.indents, + width, + true, + 0, + None, + &mut |node, previous, width, indents| { + ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions) + }, + ) + .unwrap() + } + + #[test] + fn alternate_geometry_keeps_source_utf16_and_blank_paragraphs() { + let mut engine = TextEngine::default(); + let document = TextDocument::new(vec![ + Paragraph::from_runs([ + ("alpha ".into(), Format::default()), + ( + "๐Ÿ˜€ e\u{301} ".into(), + Format { + bold: Some(true), + ..Format::default() + }, + ), + ("omega".into(), Format::default()), + ]), + Paragraph::new(String::new(), Format::default()), + Paragraph::new(String::new(), Format::default()), + Paragraph::new("last".into(), Format::default()), + Paragraph::new("left ืื‘ื’ right ๐Ÿ˜€".into(), Format::default()), + ]) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document.clone(), 1000.0).unwrap(); + let selection = [ + TextPosition { + paragraph: 4, + offset: 5, + }, + TextPosition { + paragraph: 0, + offset: 6, + }, + ] + .into(); + editor.select(selection).unwrap(); + let page = editor.page().unwrap(); + let canonical = layout_snapshot(&editor.active_outline().shaped); + let ids = editor + .active_outline() + .layouts() + .map(|(index, paragraph)| (index, paragraph.id)) + .collect::>(); + let text = "alpha ๐Ÿ˜€ e\u{301} omega\n\n\nlast\nleft ืื‘ื’ right ๐Ÿ˜€"; + assert_eq!(editor.active_outline().shown_text(), text); + let mut heights = Vec::new(); + for width in [70.0, 230.0] { + let shaped = alternate_shape(&mut engine, &editor, width); + let geometry = OutlineGeometry { + source: editor.active_outline(), + shaped: &shaped, + }; + assert_eq!( + geometry + .layouts() + .map(|(index, paragraph)| (index, paragraph.id)) + .collect::>(), + ids + ); + for index in [1, 2] { + let paragraph = geometry.paragraph_layout(index).unwrap(); + assert!(paragraph.projection.text().text().is_empty()); + assert_eq!(paragraph.text.lines().count(), 1); + } + for (index, paragraph) in geometry.layouts() { + for (byte, offset) in paragraph.projection.source_boundaries() { + for affinity in [Affinity::Upstream, Affinity::Downstream] { + let cursor = paragraph.cursor(offset, affinity).unwrap(); + if cursor.index() != byte { + continue; + } + let position = TextPosition { + paragraph: index, + offset, + }; + let caret = geometry.caret_at(position, affinity, 1.0).unwrap(); + let hit = geometry + .selection_at( + caret.x0 as f32 + 0.01, + ((caret.y0 + caret.y1) * 0.5) as f32, + SelectionUnit::Grapheme, + ) + .unwrap(); + assert_eq!(hit.positions[1].paragraph, index); + // Bidi and soft-wrap boundaries can share a point with another source caret. + let recovered = geometry + .caret_at(hit.positions[1], hit.affinities[1], 1.0) + .unwrap(); + assert!( + (caret.x0 - recovered.x0).abs() < 0.05, + "{position:?} {affinity:?} -> {hit:?}" + ); + assert!( + (caret.y0 - recovered.y0).abs() < 0.05, + "{position:?} {affinity:?} -> {hit:?}" + ); + let source = document.paragraph(index).unwrap(); + source.byte_offset(hit.positions[1].offset).unwrap(); + let flat = geometry.source.utf16_offset(position).unwrap(); + assert_eq!(geometry.source.utf16_position(flat).unwrap(), position); + } + } + } + assert!(matches!( + geometry.selection_at(f32::NAN, 0.0, SelectionUnit::Grapheme), + Err(EditError::InvalidRange) + )); + heights.push(shaped.size[1]); + assert_eq!(editor.page().unwrap(), page); + assert_eq!(layout_snapshot(&editor.active_outline().shaped), canonical); + assert_eq!(editor.selection(), selection); + assert_eq!(editor.history_depth(), [0, 0]); + assert!(editor.take_ops().unwrap().is_empty()); + } + assert!(heights[0] > heights[1]); + assert_eq!(editor.active_outline().document, document); + assert_eq!( + editor.active_outline().shown_text().encode_utf16().count(), + text.encode_utf16().count() + ); + } + + #[test] + fn alternate_range_and_marked_geometry_use_display_lines() { + let mut engine = TextEngine::default(); + let document = TextDocument::new(vec![ + Paragraph::from_runs([ + ("alpha ๐Ÿ˜€ ".into(), Format::default()), + ( + "a rich run with enough words to wrap several times in a narrow column and twice in a wider column".into(), + Format { bold: Some(true), ..Format::default() }, + ), + ]), + Paragraph::new(String::new(), Format::default()), + Paragraph::new("last".into(), Format::default()), + ]).unwrap(); + let page = CanvasEditor::new(&mut engine, document, 1000.0) + .unwrap() + .page() + .unwrap(); + let selection: Selection = [ + TextPosition { + paragraph: 2, + offset: 4, + }, + TextPosition { + paragraph: 0, + offset: 6, + }, + ] + .into(); + let on_lines = |shaped: &OutlineLayout, rectangles: &[BoundingBox]| { + assert!(!rectangles.is_empty()); + for rect in rectangles { + assert!(shaped.paragraphs.iter().any(|paragraph| { + paragraph.text.lines().any(|(_, line)| { + let top = f64::from(paragraph.origin[1] + line.top); + (rect.y0 - top).abs() < 0.001 + && (rect.y1 - top - f64::from(line.height)).abs() < 0.001 + }) + })); + } + }; + for commit in [false, true] { + let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); + editor.insert(&mut engine, "!").unwrap(); + editor.undo(&mut engine).unwrap(); + editor.take_ops().unwrap(); + editor.select(selection).unwrap(); + let original = editor.page().unwrap(); + let canonical = editor.selection_rects().unwrap(); + let mut counts = Vec::new(); + for width in [70.0, 230.0] { + let shaped = alternate_shape(&mut engine, &editor, width); + let geometry = OutlineGeometry { + source: editor.active_outline(), + shaped: &shaped, + }; + let rectangles = geometry.range_rects(selection).unwrap(); + on_lines(&shaped, &rectangles); + assert_ne!(rectangles, canonical); + let blank = geometry + .caret_at( + TextPosition { + paragraph: 1, + offset: 0, + }, + Affinity::Downstream, + 4.0, + ) + .unwrap(); + assert!(rectangles.contains(&blank)); + counts.push(rectangles.len()); + } + assert!(counts[0] > counts[1]); + assert_eq!(editor.history_depth(), [0, 1]); + assert!(editor.take_ops().unwrap().is_empty()); + let preview = + "marked ๐Ÿ˜€ preview with several words wrapping onto other lines\nsecond line"; + let end = preview.encode_utf16().count().try_into().unwrap(); + editor + .compose(&mut engine, preview.into(), end..end) + .unwrap(); + let marked = editor.marked_range().unwrap(); + let preview_page = editor.page().unwrap(); + let preview_selection = editor.selection(); + let canonical = layout_snapshot(&editor.active_outline().shaped); + for width in [70.0, 230.0] { + let shaped = alternate_shape(&mut engine, &editor, width); + let geometry = OutlineGeometry { + source: editor.active_outline(), + shaped: &shaped, + }; + on_lines( + &shaped, + &geometry + .range_rects(Selection { + positions: [marked.start, marked.end], + affinities: [Affinity::Downstream, Affinity::Upstream], + }) + .unwrap(), + ); + for (index, paragraph) in geometry.layouts() { + let source = geometry.source.document.paragraph(index).unwrap(); + let range = if index == marked.start.paragraph { + marked.start.offset + } else { + 0 + }..if index == marked.end.paragraph { + marked.end.offset + } else { + source.utf16_offset(source.text().len()).unwrap() + }; + for [_, _, baseline] in geometry.underlines(index, range).unwrap() { + assert!(paragraph.text.lines().any(|(_, line)| { + (baseline - paragraph.origin[1] - line.baseline).abs() < 0.001 + })); + } + } + assert_eq!(editor.page().unwrap(), preview_page); + assert_eq!(editor.selection(), preview_selection); + assert_eq!(editor.marked_range(), Some(marked.clone())); + assert_eq!(editor.history_depth(), [0, 1]); + assert_eq!(layout_snapshot(&editor.active_outline().shaped), canonical); + assert!(editor.take_ops().unwrap().is_empty()); + } + if commit { + editor.finish_composition(); + assert!(editor.marked_range().is_none()); + assert_eq!(editor.history_depth(), [1, 0]); + assert!(!editor.take_ops().unwrap().is_empty()); + let committed = editor.page().unwrap(); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.page().unwrap(), original); + assert_eq!(editor.selection(), selection); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.page().unwrap(), committed); + } else { + assert!(editor.cancel_composition(&mut engine).unwrap()); + assert_eq!(editor.page().unwrap(), original); + assert_eq!(editor.selection(), selection); + assert_eq!(editor.history_depth(), [0, 1]); + assert!(editor.take_ops().unwrap().is_empty()); + editor.redo(&mut engine).unwrap(); + assert!( + editor + .active_outline() + .document + .paragraph(0) + .unwrap() + .text() + .starts_with("!alpha") + ); + } + } + } + + #[test] + fn display_title_height_never_changes_canonical_placements() { + let mut engine = TextEngine::default(); + let mut title = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Header".into(), Format::default())]).unwrap(), + 468.0, + [0.0; 2], + ) + .unwrap() + .snapshot(); + title.title = true; + title.layout.max_width = None; + title.layout.width_set_by_user = None; + let body = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Anchor".into(), Format::default())]).unwrap(), + 72.0, + [120.0, 80.0], + ) + .unwrap() + .snapshot(); + let page = Page { + title: "Header".into(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + rtl: false, + color: None, + rule_lines: None, + definitions: BTreeMap::new(), + objects: vec![ + PageObject::Title(Title { + id: onestore::page::text::new_id().unwrap(), + date: None, + layout: Default::default(), + outlines: vec![title], + }), + PageObject::Outline(body), + ], + }; + let text = "A long title must wrap independently of its canonical page and must never move the stored body according to a reader column. ".repeat(4); + let mut results = Vec::new(); + let mut heights = Vec::new(); + for width in [70.0, 230.0] { + let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); + let original = editor.page().unwrap(); + let original_shape = layout_snapshot(&editor.active_outline().shaped); + let shaped = alternate_shape(&mut engine, &editor, width); + OutlineGeometry { + source: editor.active_outline(), + shaped: &shaped, + } + .caret_at( + TextPosition { + paragraph: 0, + offset: 0, + }, + Affinity::Downstream, + 1.0, + ) + .unwrap(); + assert_eq!(editor.page().unwrap(), original); + assert_eq!(editor.history_depth(), [0, 0]); + assert!(editor.take_ops().unwrap().is_empty()); + editor.select_all().unwrap(); + editor.insert(&mut engine, &text).unwrap(); + let shaped = alternate_shape(&mut engine, &editor, width); + let geometry = OutlineGeometry { + source: editor.active_outline(), + shaped: &shaped, + }; + geometry + .range_rects(editor.active_outline().whole()) + .unwrap(); + heights.push(shaped.size[1]); + let edited = editor.page().unwrap(); + let edited_shape = layout_snapshot(&editor.active_outline().shaped); + let edit_ops = editor.take_ops().unwrap(); + assert!(!edit_ops.is_empty()); + assert_eq!(editor.history_depth(), [1, 0]); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.page().unwrap(), original); + assert_eq!( + layout_snapshot(&editor.active_outline().shaped), + original_shape + ); + let undo_ops = editor.take_ops().unwrap(); + assert_eq!(editor.history_depth(), [0, 1]); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.page().unwrap(), edited); + assert_eq!( + layout_snapshot(&editor.active_outline().shaped), + edited_shape + ); + assert_eq!(editor.history_depth(), [1, 0]); + results.push(( + edited, + edited_shape, + edit_ops, + undo_ops, + editor.take_ops().unwrap(), + )); + } + assert!(heights[0] > heights[1]); + assert_eq!(results[0], results[1]); + } + + #[test] + fn alternate_navigation_follows_display_lines_and_keeps_source_anchors() { + let mut engine = TextEngine::default(); + let document = TextDocument::new(vec![ + Paragraph::from_runs([ + ("alpha ๐Ÿ˜€ e\u{301} ".into(), Format::default()), + ( + "many words cross several display lines while the canonical outline stays wide and unchanged ".repeat(3), + Format { bold: Some(true), ..Format::default() }, + ), + ("ืื‘ื’ end".into(), Format::default()), + ]), + Paragraph::new(String::new(), Format::default()), + Paragraph::new(String::new(), Format::default()), + Paragraph::new("last".into(), Format::default()), + ]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 2000.0).unwrap(); + let first = TextPosition { + paragraph: 0, + offset: 0, + }; + let source_selection: Selection = [first; 2].into(); + let original = editor.page().unwrap(); + let canonical = layout_snapshot(&editor.active_outline().shaped); + for width in [70.0, 230.0] { + let shaped = alternate_shape(&mut engine, &editor, width); + let geometry = OutlineGeometry { + source: editor.active_outline(), + shaped: &shaped, + }; + let paragraph = geometry.paragraph_layout(0).unwrap(); + let caret = geometry.caret_at(first, Affinity::Downstream, 1.0).unwrap(); + let (_, second_line) = paragraph.text.lines().nth(1).unwrap(); + let hit = geometry + .selection_at( + caret.x0 as f32, + paragraph.origin[1] + second_line.top + second_line.height * 0.5, + SelectionUnit::Grapheme, + ) + .unwrap(); + let down = geometry + .navigate(source_selection, Movement::Down, false, None) + .unwrap(); + assert_eq!(down.selection, hit); + assert_eq!(down.selection.positions[1].paragraph, 0); + assert_eq!(down.preferred_x, Some(caret.x0 as f32)); + assert!(!down.boundary); + let extending = Selection { + positions: [first, down.selection.positions[1]], + affinities: [Affinity::Downstream, down.selection.affinities[1]], + }; + let next = geometry + .navigate(extending, Movement::Down, true, down.preferred_x) + .unwrap(); + assert_eq!(next.selection.positions[0], first); + assert_eq!(next.selection.affinities[0], Affinity::Downstream); + assert_eq!(next.preferred_x, down.preferred_x); + let next_caret = geometry + .caret_at( + next.selection.positions[1], + next.selection.affinities[1], + 1.0, + ) + .unwrap(); + let (_, third_line) = paragraph.text.lines().nth(2).unwrap(); + assert!( + (next_caret.y0 - f64::from(paragraph.origin[1] + third_line.top)).abs() < 0.001 + ); + for movement in [Movement::LineStart, Movement::LineEnd] { + let line = geometry + .navigate(down.selection, movement, false, down.preferred_x) + .unwrap(); + let line_caret = geometry + .caret_at( + line.selection.positions[1], + line.selection.affinities[1], + 1.0, + ) + .unwrap(); + assert!( + (line_caret.y0 - f64::from(paragraph.origin[1] + second_line.top)).abs() + < 0.001 + ); + assert_eq!(line.preferred_x, None); + assert!(!line.boundary); + } + let last = TextPosition { + paragraph: 3, + offset: 4, + }; + let reversed: Selection = [last, first].into(); + for (movement, expected) in [ + (Movement::Left, first), + (Movement::WordLeft, first), + (Movement::Right, last), + (Movement::WordRight, last), + ] { + let collapsed = geometry + .navigate(reversed, movement, false, Some(15.0)) + .unwrap(); + assert_eq!(collapsed.selection.positions, [expected; 2]); + assert_eq!(collapsed.preferred_x, None); + assert!(!collapsed.boundary); + } + for movement in [ + Movement::Left, + Movement::Right, + Movement::WordLeft, + Movement::WordRight, + Movement::ParagraphStart, + Movement::ParagraphEnd, + Movement::DocumentStart, + Movement::DocumentEnd, + ] { + let moved = geometry.navigate(reversed, movement, true, None).unwrap(); + assert_eq!(moved.selection.positions[0], last); + assert_eq!(moved.selection.affinities[0], reversed.affinities[0]); + for position in moved.selection.positions { + geometry + .source + .document + .paragraph(position.paragraph) + .unwrap() + .byte_offset(position.offset) + .unwrap(); + } + } + assert_eq!(editor.page().unwrap(), original); + assert_eq!(layout_snapshot(&editor.active_outline().shaped), canonical); + assert_eq!(editor.selection(), source_selection); + assert_eq!(editor.history_depth(), [0, 0]); + assert!(editor.take_ops().unwrap().is_empty()); + } + editor + .compose(&mut engine, "marked ๐Ÿ˜€\npreview".into(), 0..0) + .unwrap(); + let marked = editor.marked_range(); + let preview = editor.page().unwrap(); + let selection = editor.selection(); + for width in [70.0, 230.0] { + let shaped = alternate_shape(&mut engine, &editor, width); + let geometry = OutlineGeometry { + source: editor.active_outline(), + shaped: &shaped, + }; + for movement in [ + Movement::Up, + Movement::Down, + Movement::LineStart, + Movement::LineEnd, + ] { + geometry.navigate(selection, movement, true, None).unwrap(); + } + assert_eq!(editor.marked_range(), marked); + assert_eq!(editor.page().unwrap(), preview); + assert_eq!(editor.selection(), selection); + assert_eq!(editor.history_depth(), [0, 0]); + assert!(editor.take_ops().unwrap().is_empty()); + } + editor.cancel_composition(&mut engine).unwrap(); + assert_eq!(editor.page().unwrap(), original); + } + + #[test] + fn alternate_navigation_boundaries_do_not_create_spatial_content() { + let mut engine = TextEngine::default(); + for title in [false, true] { + for text in ["", "alpha ๐Ÿ˜€\nlast"] { + let mut outline = TextOutline::new( + &mut engine, + TextDocument::new( + text.split('\n') + .map(|part| Paragraph::new(part.into(), Format::default())) + .collect(), + ) + .unwrap(), + 300.0, + [220.0, 180.0], + ) + .unwrap(); + outline.title = title; + let mut editor = + CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), None).unwrap(); + let original = editor.page().unwrap(); + let source = editor.active_outline().snapshot(); + let canonical = layout_snapshot(&editor.active_outline().shaped); + for width in [70.0, 230.0] { + let shaped = alternate_shape(&mut engine, &editor, width); + let geometry = OutlineGeometry { + source: editor.active_outline(), + shaped: &shaped, + }; + let start = TextPosition { + paragraph: 0, + offset: 0, + }; + let end = geometry + .navigate([start; 2].into(), Movement::DocumentEnd, false, None) + .unwrap() + .selection; + for (movement, selection) in + [(Movement::Up, [start; 2].into()), (Movement::Down, end)] + { + for extend in [false, true] { + let edge = geometry + .navigate(selection, movement, extend, Some(11.0)) + .unwrap(); + assert!(edge.boundary); + assert_eq!(edge.selection.positions, selection.positions); + let focus = selection.positions[1]; + let affinity = geometry + .paragraph_layout(focus.paragraph) + .unwrap() + .cursor(focus.offset, selection.affinities[1]) + .unwrap() + .affinity(); + assert_eq!(edge.selection.affinities[1], affinity); + assert_eq!( + edge.selection.affinities[0], + if extend { + selection.affinities[0] + } else { + affinity + } + ); + assert_eq!(edge.preferred_x, Some(11.0)); + } + } + assert_eq!( + geometry + .navigate( + [start; 2].into(), + Movement::DocumentStart, + false, + Some(11.0) + ) + .unwrap() + .selection + .positions, + [start; 2] + ); + assert_eq!(editor.active_outline().snapshot(), source); + assert_eq!(editor.page().unwrap(), original); + assert_eq!(layout_snapshot(&editor.active_outline().shaped), canonical); + assert_eq!(editor.history_depth(), [0, 0]); + assert!(editor.take_ops().unwrap().is_empty()); + } + } + } + } + fn table_editor(engine: &mut TextEngine) -> CanvasEditor { use onestore::page::text::new_id; use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow}; diff --git a/crates/canvas/src/editor/link.rs b/crates/canvas/src/editor/link.rs index a1bcfb1353f89b5ad0cde79b93590cfb21cd0e26..ea19af82e140b8cb9276b6076a627d0b4c7546d1 100644 --- a/crates/canvas/src/editor/link.rs +++ b/crates/canvas/src/editor/link.rs @@ -191,49 +191,11 @@ impl CanvasEditor { }) } - /// The address of the link drawn under document point `point` of outline `id`, which a - /// click opens as OneNote's does. - pub(crate) fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option { - let outline = self.visible_outlines().find(|outline| outline.id == id)?; - let index = outline.paragraph_at(x, y).ok()?; - let paragraph = &outline.shaped.paragraphs[index]; - let point = [x - paragraph.origin[0], y - paragraph.origin[1]]; - let cursor = paragraph.text.hit_test(point[0], point[1]); - let edge = paragraph.text.caret(cursor, 0.0).x0 as f32; - let visible = paragraph.projection.text(); - let byte = cursor.index(); - // The character under the point lies on the side of the nearest boundary it is on. - let at = if point[0] < edge { - visible.text()[..byte].char_indices().next_back()?.0 - } else { - visible.text()[byte..].chars().next()?; - byte - }; - let (line, _) = paragraph - .text - .lines() - .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?; - if point[0] > line.metrics().advance { - return None; - } - let source = paragraph - .projection - .source_offset( - visible.utf16_offset(at).ok()?, - onestore::page::text::Affinity::Downstream, - ) - .ok()?; - let paragraph = outline.source_index(index); - links(outline.document.paragraph(paragraph)?, paragraph) - .into_iter() - .find(|link| link.label.contains(&source)) - .map(|link| link.target) - .or_else(|| { - shown_urls(visible) - .into_iter() - .find(|url| url.contains(&at)) - .map(|url| visible.text()[url].to_owned()) - }) + pub(crate) fn link_under(&self, id: ExGuid, point: [f32; 2]) -> Option { + self.visible_outlines() + .find(|outline| outline.id == id)? + .geometry() + .link_under(point) } /// What the Link dialog links, as OneNote 2010 picks it: the selection within one @@ -511,6 +473,50 @@ impl CanvasEditor { } } +impl super::OutlineGeometry<'_> { + pub(crate) fn link_under(self, [x, y]: [f32; 2]) -> Option { + let index = self.paragraph_at(x, y).ok()?; + let paragraph = &self.shaped.paragraphs[index]; + let point = [x - paragraph.origin[0], y - paragraph.origin[1]]; + let cursor = paragraph.text.hit_test(point[0], point[1]); + let edge = paragraph.text.caret(cursor, 0.0).x0 as f32; + let visible = paragraph.projection.text(); + let byte = cursor.index(); + // The character under the point lies on the side of the nearest boundary it is on. + let at = if point[0] < edge { + visible.text()[..byte].char_indices().next_back()?.0 + } else { + visible.text()[byte..].chars().next()?; + byte + }; + let (line, _) = paragraph + .text + .lines() + .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?; + if point[0] > line.metrics().advance { + return None; + } + let source = paragraph + .projection + .source_offset( + visible.utf16_offset(at).ok()?, + onestore::page::text::Affinity::Downstream, + ) + .ok()?; + let paragraph = self.source_index(index); + links(self.source.document().paragraph(paragraph)?, paragraph) + .into_iter() + .find(|link| link.label.contains(&source)) + .map(|link| link.target) + .or_else(|| { + shown_urls(visible) + .into_iter() + .find(|url| url.contains(&at)) + .map(|url| visible.text()[url].to_owned()) + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index c87a393bb84ffe65deef26fce6fc5cb3838e15d1..8e49d5e9a954f71c0c253d5790e712e763031ed9 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -104,12 +104,18 @@ fn outline_origin( mut origin: [f32; 2], title: Option, editor: Option<&CanvasEditor>, + column: Option<&crate::reading::Column>, ) -> Result<[f32; 2], SceneError> { if let Some(id) = title { let title = editor .and_then(|editor| editor.visible_outlines().find(|outline| outline.id == id)) .ok_or(SceneError::MissingOutline)?; - origin[1] += title.bounds().y1 as f32; + let (geometry, projected) = crate::reading::geometry(title, column); + origin[1] += if column.is_some() { + projected[1] + geometry.shaped.size[1] + } else { + title.bounds().y1 as f32 + }; } Ok(origin) } @@ -524,7 +530,7 @@ impl PageScene { below_title, } => { let origin = crate::origin(&source.layout); - if let Ok(origin) = outline_origin(origin, *below_title, editor) { + if let Ok(origin) = outline_origin(origin, *below_title, editor, None) { outline(layout, origin, &mut rects); } } @@ -667,6 +673,7 @@ impl PageScene { pub(crate) fn content_bounds<'a>( &'a self, editor: &'a CanvasEditor, + column: Option<&'a crate::reading::Column>, ) -> impl Iterator + 'a { self.objects(Some(editor)) .into_iter() @@ -677,9 +684,13 @@ impl PageScene { layout, below_title, } => { - let origin = - outline_origin(crate::origin(&source.layout), *below_title, Some(editor)) - .ok()?; + let origin = outline_origin( + crate::origin(&source.layout), + *below_title, + Some(editor), + column, + ) + .ok()?; Some(( [ origin[0], @@ -696,6 +707,7 @@ impl PageScene { crate::origin(&date.source().layout), *below_title, Some(editor), + column, ) .ok()?; let size = date.layout().size; @@ -721,9 +733,53 @@ impl PageScene { }) } + pub(crate) fn header_text<'a>( + &'a self, + editor: &'a CanvasEditor, + column: &'a crate::reading::Column, + ) -> impl Iterator + 'a { + self.objects(Some(editor)) + .into_iter() + .flatten() + .filter_map(move |object| { + let Content::Outline { + source, + layout, + below_title: Some(title), + } = object + else { + return None; + }; + let origin = outline_origin( + crate::origin(&source.layout), + Some(*title), + Some(editor), + Some(column), + ) + .ok()?; + let text = source + .paragraphs + .iter() + .filter_map(|paragraph| paragraph.text()) + .map(|text| text.text.text()) + .collect::>() + .join("\n"); + Some(( + text, + [ + origin[0], + origin[1], + origin[0] + layout.size[0], + origin[1] + layout.size[1], + ], + )) + }) + } + pub(crate) fn date_fields( &self, editor: &CanvasEditor, + column: Option<&crate::reading::Column>, ) -> impl Iterator { let mut fields = [None; 2]; if let Some(date) = editor.date() @@ -737,7 +793,7 @@ impl PageScene { } _ => None, }) - && let Ok(origin) = outline_origin(origin, title, Some(editor)) + && let Ok(origin) = outline_origin(origin, title, Some(editor), column) { for (slot, (field, paragraph)) in fields.iter_mut().zip( date.fields() @@ -765,6 +821,16 @@ impl PageScene { &self, point: [f32; 2], editor: Option<&CanvasEditor>, + outline: impl FnMut(onestore::ExGuid) -> Option, + ) -> Option> { + self.hit_test_with(point, editor, None, outline) + } + + pub(crate) fn hit_test_with( + &self, + point: [f32; 2], + editor: Option<&CanvasEditor>, + column: Option<&crate::reading::Column>, mut outline: impl FnMut(onestore::ExGuid) -> Option, ) -> Option> { let check = self.object_tags(editor, None).into_iter().find(|(_, tag)| { @@ -780,10 +846,11 @@ impl PageScene { match object { Content::Date { .. } => { if let Some(editor) = editor - && let Some((field, _)) = self.date_fields(editor).find(|(_, rect)| { - (rect[0]..=rect[2]).contains(&point[0]) - && (rect[1]..=rect[3]).contains(&point[1]) - }) + && let Some((field, _)) = + self.date_fields(editor, column).find(|(_, rect)| { + (rect[0]..=rect[2]).contains(&point[0]) + && (rect[1]..=rect[3]).contains(&point[1]) + }) { return Some(SceneHit::Date(field)); } @@ -851,7 +918,8 @@ impl PageScene { layout, below_title, } => { - let [x, y] = outline_origin(crate::origin(&source.layout), *below_title, None)?; + let [x, y] = + outline_origin(crate::origin(&source.layout), *below_title, None, None)?; cover([x, y, x + layout.size[0], y + layout.size[1]], false); for paragraph in &layout.paragraphs { let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]]; @@ -900,18 +968,20 @@ impl PageScene { offset: [f32; 2], paper: Paper, ) -> Result<(), SceneError> { - self.append_primitives_with(primitives, offset, None, None, paper, |_, _, _| { + self.append_primitives_with(primitives, offset, None, None, None, paper, |_, _, _| { Err(SceneError::MissingOutline) }) } /// `moving` draws one picture at a previewed rectangle instead of its stored layout. /// Outlines the editor does not hold draw their tags' symbols. + #[allow(clippy::too_many_arguments)] pub(crate) fn append_primitives_with<'a, E: From>( &'a self, primitives: &mut Vec>, offset: [f32; 2], editor: Option<&'a CanvasEditor>, + column: Option<&'a crate::reading::Column>, moving: Option<(onestore::ExGuid, [f32; 4])>, paper: Paper, mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec>) -> Result<(), E>, @@ -922,12 +992,17 @@ impl PageScene { source, below_title, .. - } => outline_origin(crate::origin(&source.layout), *below_title, editor)?, + } => outline_origin(crate::origin(&source.layout), *below_title, editor, column)?, Content::Date { below_title } => { let date = editor .and_then(CanvasEditor::date) .ok_or(SceneError::MissingOutline)?; - outline_origin(crate::origin(&date.source().layout), *below_title, editor)? + outline_origin( + crate::origin(&date.source().layout), + *below_title, + editor, + column, + )? } Content::Image(source) => crate::origin(&source.layout), Content::ReadOnly(object) => { @@ -1397,7 +1472,7 @@ mod tests { assert_eq!(editor.active_outline().layout().max_width, None); let bounds = |editor: &CanvasEditor| { scene - .content_bounds(editor) + .content_bounds(editor, None) .map(|(rect, _)| rect) .collect::>() }; @@ -1453,7 +1528,7 @@ mod tests { let changed = bounds(&editor); assert_eq!(changed[0][1], wrapped[0][1]); assert!(changed[0][3] > wrapped[0][3]); - let (field, rect) = scene.date_fields(&editor).next().unwrap(); + let (field, rect) = scene.date_fields(&editor, None).next().unwrap(); assert_eq!(field, DateField::Date); assert_eq!(rect[1], changed[0][1]); assert_eq!(rect[3], changed[0][3]); @@ -1759,6 +1834,7 @@ mod tests { [0.0; 2], Some(editor), None, + None, Paper::WHITE, |_, _, _| Ok(()), ) @@ -1785,7 +1861,7 @@ mod tests { assert!((old[3] + delta - new[3]).abs() < 0.002); assert!( scene - .content_bounds(&editor) + .content_bounds(&editor, None) .any(|(bounds, _)| bounds == *new) ); } @@ -2199,6 +2275,7 @@ mod tests { [0.0; 2], Some(editor), None, + None, Paper::WHITE, |id, offset, primitives| { let outline = editor @@ -2461,7 +2538,7 @@ mod tests { let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); assert!( scene - .content_bounds(&editor) + .content_bounds(&editor, None) .any(|(bounds, _)| bounds == [9.0, 19.0, 41.0, 61.0]) ); let mut primitives = Vec::new(); @@ -2471,6 +2548,7 @@ mod tests { [5.0, 0.0], Some(&editor), None, + None, Paper::WHITE, |_, _, _| Ok(()), ) @@ -2682,6 +2760,7 @@ mod tests { &mut primitives, [0.0; 2], Some(&editor), + None, Some((id, [70.0, 5.0, 120.0, 55.0])), Paper::WHITE, |_, _, _| Ok(()), @@ -2777,6 +2856,7 @@ mod tests { [0.0; 2], Some(&editor), None, + None, Paper::WHITE, |_, _, _| Ok(()), ) diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index 74243d1aaddb8bef62999192323c17ca7fb2e692..793ad7859612e1dc51b885e39d3cf3ea9af19828 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -94,7 +94,9 @@ impl Accessibility { } pub fn read_only_for_node(&self, node: NodeId) -> Option { - self.read_only.iter().position(|(id, _)| *id == node) + self.read_only + .iter() + .position(|(id, value)| *id == node && value.supports_action(Action::Focus)) } pub fn date_for_node(&self, node: NodeId) -> Option { @@ -114,6 +116,24 @@ impl Accessibility { /// The nodes that changed since the last update. After an error, `deactivate` before the /// next update. pub fn update( + &mut self, + view: &super::PageView, + viewport: Viewport, + label: &str, + ) -> Result { + self.update_with( + &view.editor, + view.scene.as_ref(), + viewport, + label, + view.outline_preview(), + view.object_focus().and_then(super::ObjectFocus::read_only), + view.column.as_ref(), + ) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn update_with( &mut self, editor: &CanvasEditor, scene: Option<&(PageScene, [f32; 2])>, @@ -121,6 +141,7 @@ impl Accessibility { label: &str, preview: Option<(ExGuid, [f32; 2])>, read_only_focus: Option, + column: Option<&crate::reading::Column>, ) -> Result { let mut nodes = Vec::new(); let mut focus = ROOT; @@ -129,16 +150,26 @@ impl Accessibility { .drain(..) .map(|field| (field.outline, field)) .collect(); - for (ordinal, outline) in editor + let mut outlines: Vec<_> = editor .visible_outlines() .chain(editor.caret_outline()) - .enumerate() - { + .collect(); + if let Some(column) = column { + outlines.sort_by_key(|outline| { + column + .outlines + .iter() + .position(|(id, _, _)| *id == outline.id) + .unwrap() + }); + } + for (ordinal, outline) in outlines.into_iter().enumerate() { let (id, sent, old) = match previous.remove(&outline.id) { Some(field) => (field.id, Some(field.node), field.paragraphs), None => (self.allocate(), None, Vec::new()), }; - let layouts: Vec<_> = outline.layouts().collect(); + let (geometry, displayed) = crate::reading::geometry(outline, column); + let layouts: Vec<_> = geometry.layouts().collect(); // Only a paragraph with a successor ends its last run with the paragraph break. let same = |old_index: usize, index: usize| { old[old_index].layout == layouts[index].1.text.id() @@ -240,16 +271,26 @@ impl Accessibility { .map(|paragraph| paragraph.id) .collect::>(), ); - let origin = preview + let mut origin = preview .filter(|(id, _)| *id == outline.id) .map(|(_, origin)| origin) - .unwrap_or_else(|| outline.origin()); + .unwrap_or(displayed); + if (column.is_some() || editor.has_page_outline(outline.id)) + && let Some((_, offset)) = scene + { + origin = [origin[0] + offset[0], origin[1] + offset[1]]; + } node.set_transform(Affine::translate(( f64::from(origin[0]), f64::from(origin[1]), ))); let bounds = outline.bounds(); - node.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height())); + let [width, height] = if column.is_some() { + geometry.shaped.size.map(f64::from) + } else { + [bounds.width(), bounds.height()] + }; + node.set_bounds(Rect::new(0.0, 0.0, width, height)); if outline.id == editor.active_outline().id { let Selection { positions: [anchor, caret], @@ -271,7 +312,22 @@ impl Accessibility { paragraphs, }); } - let mut children: Vec<_> = self.fields.iter().map(|field| field.id).collect(); + let mut children: Vec<_> = self + .fields + .iter() + .map(|field| { + let origin = column + .and_then(|column| { + column + .outlines + .iter() + .find(|(id, _, _)| *id == field.outline) + }) + .map(|(_, origin, _)| *origin) + .unwrap_or([0.0; 2]); + (field.id, origin) + }) + .collect(); let sent_read_only = std::mem::take(&mut self.read_only); let sent_dates = std::mem::take(&mut self.dates); if let Some((scene, offset)) = scene { @@ -297,10 +353,27 @@ impl Accessibility { if read_only_focus == Some(index) { focus = id; } - children.push(id); + let bounds = object.rect(); + children.push((id, [bounds[0], bounds[1]])); self.read_only.push((id, node)); } - for (index, (field, bounds)) in scene.date_fields(editor).enumerate() { + if let Some(column) = column { + for (text, bounds) in scene.header_text(editor, column) { + let index = self.read_only.len(); + let mut node = Node::new(Role::Label); + node.set_value(text); + node.set_read_only(); + node.set_bounds(rect(bounds)); + let sent = sent_read_only.get(index); + let id = sent.map_or_else(|| self.allocate(), |(id, _)| *id); + if sent.is_none_or(|(_, sent)| *sent != node) { + nodes.push((id, node.clone())); + } + children.push((id, [bounds[0], bounds[1]])); + self.read_only.push((id, node)); + } + } + for (index, (field, bounds)) in scene.date_fields(editor, column).enumerate() { let mut node = Node::new(Role::Button); node.set_label(super::DATE_LABELS[field as usize]); node.set_value( @@ -317,7 +390,7 @@ impl Accessibility { if sent.is_none_or(|(_, _, sent)| *sent != node) { nodes.push((id, node.clone())); } - children.push(id); + children.push((id, [bounds[0], bounds[1]])); self.dates.push((field, id, node)); } } @@ -330,7 +403,10 @@ impl Accessibility { f64::from(viewport.origin[0]), f64::from(viewport.origin[1]), ])); - page.set_children(children); + if column.is_some() { + children.sort_by(|(_, a), (_, b)| a[1].total_cmp(&b[1]).then(a[0].total_cmp(&b[0]))); + } + page.set_children(children.into_iter().map(|(id, _)| id).collect::>()); let mut root = Node::new(Role::Group); root.set_label(label); root.set_children(vec![PAGE]); @@ -772,7 +848,7 @@ pub(super) mod tests { _ => {} } let update = access - .update(&editor, Some(&scene), viewport, "Test", None, Some(0)) + .update_with(&editor, Some(&scene), viewport, "Test", None, Some(0), None) .unwrap(); let node = nodes(apply(&mut tree, update)) .into_iter() @@ -847,7 +923,7 @@ pub(super) mod tests { scale, }; let update = access - .update(&editor, Some(&scene), viewport, "Test", None, None) + .update_with(&editor, Some(&scene), viewport, "Test", None, None, None) .unwrap(); let state = apply(&mut tree, update); let node = nodes(state) @@ -884,7 +960,7 @@ pub(super) mod tests { "annotation " ); let focused = access - .update(&editor, Some(&scene), viewport, "Test", None, Some(0)) + .update_with(&editor, Some(&scene), viewport, "Test", None, Some(0), None) .unwrap(); assert_eq!(focused.focus, identity.unwrap()); assert_eq!(access.read_only_for_node(focused.focus), Some(0)); @@ -995,7 +1071,7 @@ pub(super) mod tests { editor.undo(&mut engine).unwrap(); } let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let state = apply(&mut tree, update); assert_eq!( @@ -1057,7 +1133,7 @@ pub(super) mod tests { origin: [13.0, 19.0], }; let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let field = apply(&mut tree, update).focus().unwrap(); assert_eq!( @@ -1094,7 +1170,7 @@ pub(super) mod tests { ); editor.select(source).unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); assert_eq!( apply(&mut tree, update) @@ -1115,7 +1191,7 @@ pub(super) mod tests { ) .unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let caret = editor.caret(1.0).unwrap(); assert_eq!(caret.x0, 27.0); @@ -1172,7 +1248,7 @@ pub(super) mod tests { scale: 2.0, }; let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); for (_, node) in &update.nodes { if node.role() == Role::TextRun { @@ -1199,7 +1275,7 @@ pub(super) mod tests { .unwrap(); editor.select(source_selection).unwrap(); let selected = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); assert_eq!( apply(&mut tree, selected) @@ -1211,7 +1287,7 @@ pub(super) mod tests { text ); let zoomed = access - .update( + .update_with( &editor, None, Viewport { @@ -1221,6 +1297,7 @@ pub(super) mod tests { "Test", None, None, + None, ) .unwrap(); assert_eq!( @@ -1230,7 +1307,7 @@ pub(super) mod tests { apply(&mut tree, zoomed); access.deactivate(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let state = apply(&mut tree, update); assert!( @@ -1241,7 +1318,7 @@ pub(super) mod tests { let selection = *state.focus().unwrap().data().text_selection().unwrap(); editor.insert(&mut engine, "replacement").unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); apply(&mut tree, update); assert!( @@ -1251,7 +1328,7 @@ pub(super) mod tests { ); editor.undo(&mut engine).unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); assert_eq!( apply(&mut tree, update) @@ -1317,7 +1394,7 @@ pub(super) mod tests { .unwrap(); let caret = editor.caret(0.0).unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let field = apply(&mut tree, update).focus().unwrap(); assert_eq!( @@ -1394,7 +1471,7 @@ pub(super) mod tests { editor.move_selection(&mut engine, movement, false).unwrap(); let caret = editor.caret(0.0).unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let field = apply(&mut tree, update).focus().unwrap(); let selection = field.text_selection().unwrap(); @@ -1451,13 +1528,13 @@ pub(super) mod tests { .then_some((editor.active_outline().id, [13.5, -7.25])); for repeat in 0..2 { let update = cached - .update(editor, None, viewport, "Test", preview, None) + .update_with(editor, None, viewport, "Test", preview, None, None) .unwrap(); let actual = apply(&mut tree, update); let mut rebuilt = Accessibility::default(); let expected = Tree::new( rebuilt - .update(editor, None, viewport, "Test", preview, None) + .update_with(editor, None, viewport, "Test", preview, None, None) .unwrap(), true, ); @@ -1575,7 +1652,7 @@ pub(super) mod tests { scale: 2.0, }; let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); apply(&mut tree, update); let ids = |update: &TreeUpdate| update.nodes.iter().map(|(id, _)| *id).collect::>(); @@ -1583,7 +1660,7 @@ pub(super) mod tests { viewport.origin[1] += scroll; viewport.scale *= zoom; let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); assert_eq!(ids(&update), [ROOT, PAGE]); apply(&mut tree, update); @@ -1599,13 +1676,13 @@ pub(super) mod tests { ) .unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); assert_eq!(ids(&update), [field, ROOT, PAGE]); apply(&mut tree, update); editor.insert(&mut engine, "x").unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let paragraph = &access.fields[0].paragraphs[150]; assert_eq!( @@ -1616,7 +1693,7 @@ pub(super) mod tests { let state = apply(&mut tree, update); let expected = Tree::new( Accessibility::default() - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(), true, ); @@ -1641,7 +1718,7 @@ pub(super) mod tests { origin: [48.0; 2], }; let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let fields: Vec<_> = update .nodes @@ -1667,13 +1744,14 @@ pub(super) mod tests { let source_selection = access.selection(second, selection).unwrap(); editor.select(source_selection).unwrap(); let preview = access - .update( + .update_with( &editor, None, viewport, "Test", Some((second, [400.0, 50.0])), None, + None, ) .unwrap(); let preview_field = apply(&mut tree, preview).focus().unwrap(); @@ -1683,7 +1761,7 @@ pub(super) mod tests { assert_eq!(editor.active_outline().origin(), [300.0, 40.0]); editor.move_outline(second, [400.0, 50.0]).unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); apply(&mut tree, update); assert_eq!(access.outline_for_node(fields[1].0), Some(second)); @@ -1692,7 +1770,7 @@ pub(super) mod tests { editor.undo(&mut engine).unwrap(); editor.undo(&mut engine).unwrap(); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); assert_eq!( nodes(apply(&mut tree, update)) @@ -1735,7 +1813,7 @@ pub(super) mod tests { scale: 1.0, }; Accessibility::default() - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); } assert!(wrapped); diff --git a/crates/canvas/src/interaction/ink.rs b/crates/canvas/src/interaction/ink.rs index 510e2e2518bf365f43b6e7d6482901bbda2bae6a..e26e83624f47321575d47c6a9c073a6e43adbd0c 100644 --- a/crates/canvas/src/interaction/ink.rs +++ b/crates/canvas/src/interaction/ink.rs @@ -82,6 +82,9 @@ impl PageView { /// Picks what presses on the page do, as the Draw tab's tools do; the lasso's selection /// goes. pub fn set_tool(&mut self, tool: Tool) -> Response { + if tool != Tool::Select { + self.column = None; + } self.ink.tool = tool; self.leave_ink(); Response::redraw() diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 5fb44c1842e00ee50469192b25294081135ba1cf..bb353baff3ea3195187ff6c959cb05b643bbb161 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -248,6 +248,7 @@ pub struct Place { /// keyboard state between events. pub struct PageView { pub editor: CanvasEditor, + pub(crate) column: Option, pub engine: TextEngine, /// The page's noneditable content and where it sits in the view. pub scene: Option<(PageScene, [f32; 2])>, @@ -325,6 +326,7 @@ impl PageView { ) -> Self { let mut view = Self { editor, + column: None, engine, scene, viewport: Viewport { @@ -367,6 +369,7 @@ impl PageView { editor.markdown = self.editor.markdown.take(); let left = std::mem::replace(&mut self.editor, editor); self.scene = scene; + self.column = None; self.drag = None; self.space = false; self.object_focus = None; @@ -380,7 +383,9 @@ impl PageView { /// keeping the scroll, the caret and selection, and the pictures already drawn. Marked /// text must be committed or cancelled first. pub fn refresh(&mut self, page: onestore::page::Page) -> Result { + let width = self.column.take().map(|column| column.width); if !self.editor.refresh(page, &mut self.engine)? { + self.set_reading(width); return Ok(Response::default()); } if let Some((scene, _)) = &mut self.scene { @@ -389,12 +394,15 @@ impl PageView { self.drag = None; self.object_focus = None; self.refresh_ink(); + self.set_reading(width); self.moved() } /// Lays the page out again where it stands, as after fonts arrived. pub fn relayout(&mut self) -> Result { + let width = self.column.take().map(|column| column.width); self.editor.relayout(&mut self.engine)?; + self.set_reading(width); self.moved() } @@ -425,10 +433,12 @@ impl PageView { /// Takes up `parked`, the editor the page `open` just showed was left with, history and /// all, in place of the one it opened with. pub fn resume(&mut self, parked: CanvasEditor) -> Result<()> { + let width = self.column.take().map(|column| column.width); self.editor.resume(parked, &mut self.engine)?; if let Some((scene, _)) = &mut self.scene { scene.refresh(&mut self.editor, &mut self.engine)?; } + self.set_reading(width); Ok(()) } @@ -593,6 +603,7 @@ impl PageView { point, self.pixel(), touched, + self.column.as_ref(), ) } @@ -602,6 +613,20 @@ impl PageView { self.hit_test(self.viewport.document_point(position)) } + fn link_under(&self, id: onestore::ExGuid, point: [f32; 2]) -> Option { + if self.column.is_none() { + return self.editor.link_under(id, point); + } + self.editor + .visible_outlines() + .find(|source| source.id == id) + .and_then(|source| { + crate::reading::geometry(source, self.column.as_ref()) + .0 + .link_under(point) + }) + } + /// The Outlook task icon under the pointer, in view device pixels, for the host to name. pub fn task_under_pointer(&self) -> Option<[f32; 4]> { if !self.pointer_inside || self.drag.is_some() { @@ -611,12 +636,17 @@ impl PageView { let view = |v: f32, axis: usize| v * self.viewport.scale + self.viewport.origin[axis]; self.editor.visible_outlines().find_map(|outline| { let offset = match &self.scene { - Some((_, offset)) if self.editor.has_page_outline(outline.id) => *offset, + Some((_, offset)) + if self.column.is_some() || self.editor.has_page_outline(outline.id) => + { + *offset + } _ => [0.0; 2], }; - let shaped = outline.shaped(); - let left = offset[0] + outline.origin()[0] + shaped.tag_column_offset(); - let top = offset[1] + outline.origin()[1]; + let (geometry, origin) = crate::reading::geometry(outline, self.column.as_ref()); + let shaped = geometry.shaped; + let left = offset[0] + origin[0] + shaped.tag_column_offset(); + let top = offset[1] + origin[1]; shaped.tags().find_map(|(_, origin, tag)| { let x0 = left + origin[0]; let y0 = top + origin[1]; @@ -665,6 +695,11 @@ impl PageView { /// Keeps the view in bounds and restarts the caret blink after a change. fn changed(&mut self) -> Result { + self.reconcile_reading(); + self.finish_change() + } + + fn finish_change(&mut self) -> Result { if !self.host_viewport { self.scroll().clamp(&mut self.viewport); } @@ -686,11 +721,14 @@ impl PageView { /// The caret's rectangle in device pixels, for placing the platform's input method. pub fn caret_area(&self) -> Result<[f32; 4]> { - let mut rect = self.editor.caret(1.0)?; + let outline = self.editor.active_outline(); + let (geometry, displayed) = self.text_geometry(outline.id)?; + let selection = self.editor.selection(); + let mut rect = geometry.caret_at(selection.positions[1], selection.affinities[1], 1.0)?; let origin = self .outline_preview() .map(|(_, origin)| origin) - .unwrap_or_else(|| self.editor.active_outline().origin()); + .unwrap_or(displayed); rect.x0 += f64::from(origin[0]); rect.y0 += f64::from(origin[1]); rect.x1 += f64::from(origin[0]); @@ -714,17 +752,18 @@ impl PageView { let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from); parley::BoundingBox { x0, y0, x1, y1 } } else { - let mut rect = self.editor.caret(1.0)?; - let origin = self.editor.active_outline().origin(); + let outline = self.editor.active_outline(); + let (geometry, origin) = self.text_geometry(outline.id)?; + let selection = self.editor.selection(); + let mut rect = + geometry.caret_at(selection.positions[1], selection.affinities[1], 1.0)?; rect.x0 += f64::from(origin[0]); rect.x1 += f64::from(origin[0]); rect.y0 += f64::from(origin[1]); rect.y1 += f64::from(origin[1]); rect }; - let outline = self - .object_focus - .is_none() + let outline = (self.column.is_none() && self.object_focus.is_none()) .then(|| self.editor.active_outline().bounds()); self.reveal(rect, outline); Ok(()) @@ -781,17 +820,10 @@ impl PageView { && !self.host_viewport && !self.viewport.size.contains(&0) { - let [x, y] = outline.origin().map(f64::from); - let rect = outline - .range_rects(selection)? + let rect = self + .range_rects(outline.id, selection)? .into_iter() - .reduce(|a, b| a.union(b)) - .map(|rect| parley::BoundingBox { - x0: rect.x0 + x, - y0: rect.y0 + y, - x1: rect.x1 + x, - y1: rect.y1 + y, - }); + .reduce(|a, b| a.union(b)); if let Some(rect) = rect { self.reveal(rect, None); return self.moved(); @@ -804,11 +836,15 @@ impl PageView { /// it keeps its place on screen. fn move_page(&mut self, up: bool, extend: bool) -> Result<()> { let movement = if up { Movement::Up } else { Movement::Down }; + if self.editor.prepare_navigation(movement, extend)? { + self.reconcile_reading(); + return Ok(()); + } + self.reconcile_reading(); let start = self.caret_area()?[1]; let mut caret = self.caret_area()?; while (caret[1] - start).abs() < self.viewport.size[1] as f32 { - self.editor - .move_selection(&mut self.engine, movement, extend)?; + self.move_selection_prepared(movement, extend)?; let moved = self.caret_area()?; if moved == caret { break; @@ -821,10 +857,11 @@ impl PageView { /// Reveals the caret or focused object after an edit, then reports the change. fn edited(&mut self) -> Result { + self.reconcile_reading(); if !self.host_viewport { self.reveal_focus()?; } - self.changed() + self.finish_change() } /// The page's zoom, where 1 is OneNote's 100%: 96 pixels per inch. @@ -876,14 +913,28 @@ impl PageView { .visible_outlines() .chain(self.editor.caret_outline()) .map(|outline| { - let rect = outline.bounds(); + let (geometry, origin) = crate::reading::geometry(outline, self.column.as_ref()); + let rect = if self.column.is_some() { + parley::BoundingBox { + x0: f64::from(origin[0]), + y0: f64::from(origin[1]), + x1: f64::from(origin[0] + geometry.shaped.size[0]), + y1: f64::from(origin[1] + geometry.shaped.size[1]), + } + } else { + outline.bounds() + }; let offset = self .scene .as_ref() - .filter(|_| self.editor.has_page_outline(outline.id)) + .filter(|_| self.column.is_some() || self.editor.has_page_outline(outline.id)) .map(|(_, offset)| *offset) .unwrap_or([0.0; 2]); - let [left, top] = reach(outline); + let [left, top] = if self.column.is_some() { + [origin[0] - 7.5, origin[1] - 6.0] + } else { + reach(outline) + }; [ left + offset[0], top + offset[1], @@ -894,7 +945,7 @@ impl PageView { let pad = PAD * self.display_scale; let fixed = self.scene.iter().flat_map(|(scene, offset)| { scene - .content_bounds(&self.editor) + .content_bounds(&self.editor, self.column.as_ref()) .map(move |(rect, padded)| { ( crate::translated(rect, *offset), @@ -907,7 +958,9 @@ impl PageView { editable.map(|rect| (rect, pad)).chain(fixed), self.editor.rtl(), ); - if let Some(reach) = self.reach { + if self.column.is_none() + && let Some(reach) = self.reach + { for axis in 0..2 { scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale); scroll.max[axis] = scroll.max[axis] @@ -949,6 +1002,158 @@ impl PageView { update_pictures(scene, *offset, editor, viewport, paper, waker) } + pub fn set_reading(&mut self, width: Option) -> bool { + self.column = width.and_then(|width| { + crate::reading::Column::build( + &self.editor, + self.scene.as_ref().map(|(scene, _)| scene), + width, + &mut self.engine, + ) + .ok() + .flatten() + }); + self.column.is_some() + } + + pub fn reading_status(&self, width: f32) -> (bool, bool) { + let offered = crate::reading::Column::build( + &self.editor, + self.scene.as_ref().map(|(scene, _)| scene), + width, + &mut self.engine.clone(), + ) + .ok() + .flatten() + .is_some(); + (offered, self.column.is_some()) + } + + pub fn reconcile_reading(&mut self) { + if let Some(column) = &self.column { + self.set_reading(Some(column.width)); + } + } + + fn text_geometry( + &self, + id: onestore::ExGuid, + ) -> Result<(crate::editor::OutlineGeometry<'_>, [f32; 2])> { + let source = self + .editor + .visible_outlines() + .chain(self.editor.caret_outline()) + .find(|outline| outline.id == id) + .ok_or(onestore::page::text::EditError::InvalidRange)?; + let (geometry, mut origin) = crate::reading::geometry(source, self.column.as_ref()); + if (self.column.is_some() || self.editor.has_page_outline(id)) + && let Some((_, offset)) = &self.scene + { + origin = [origin[0] + offset[0], origin[1] + offset[1]]; + } + Ok((geometry, origin)) + } + + pub fn caret_at( + &self, + id: onestore::ExGuid, + position: crate::document::TextPosition, + affinity: parley::Affinity, + width: f32, + ) -> Result { + let (geometry, origin) = self.text_geometry(id)?; + let rect = geometry.caret_at(position, affinity, width)?; + Ok(parley::BoundingBox { + x0: rect.x0 + f64::from(origin[0]), + x1: rect.x1 + f64::from(origin[0]), + y0: rect.y0 + f64::from(origin[1]), + y1: rect.y1 + f64::from(origin[1]), + }) + } + + pub fn range_rects( + &self, + id: onestore::ExGuid, + selection: Selection, + ) -> Result> { + let (geometry, origin) = self.text_geometry(id)?; + Ok(geometry + .range_rects(selection)? + .into_iter() + .map(|rect| parley::BoundingBox { + x0: rect.x0 + f64::from(origin[0]), + x1: rect.x1 + f64::from(origin[0]), + y0: rect.y0 + f64::from(origin[1]), + y1: rect.y1 + f64::from(origin[1]), + }) + .collect()) + } + + pub fn selection_at( + &self, + id: onestore::ExGuid, + point: [f32; 2], + unit: SelectionUnit, + ) -> Result { + let (geometry, origin) = self.text_geometry(id)?; + Ok(geometry.selection_at(point[0] - origin[0], point[1] - origin[1], unit)?) + } + + pub fn text_block(&self, point: [f32; 2]) -> Option<[f32; 4]> { + self.editor + .visible_outlines() + .chain( + self.editor + .caret_outline() + .filter(|_| self.column.is_some()), + ) + .filter_map(|source| { + let (geometry, origin) = self.text_geometry(source.id).ok()?; + let rect = if self.column.is_some() { + [ + origin[0], + origin[1], + origin[0] + geometry.shaped.size[0], + origin[1] + geometry.shaped.size[1], + ] + } else { + let bounds = source.bounds(); + [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(|value| value as f32) + }; + let dx = (rect[0] - point[0]).max(point[0] - rect[2]).max(0.0); + let dy = (rect[1] - point[1]).max(point[1] - rect[3]).max(0.0); + Some((dx.hypot(dy), rect)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, rect)| rect) + } + + fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<()> { + if self.editor.prepare_navigation(movement, extend)? { + self.reconcile_reading(); + return Ok(()); + } + self.reconcile_reading(); + self.move_selection_prepared(movement, extend) + } + + fn move_selection_prepared(&mut self, movement: Movement, extend: bool) -> Result<()> { + if let Some(column) = &self.column { + let id = self.editor.active_outline().id; + let (_, _, shaped) = column + .outlines + .iter() + .find(|(source, _, _)| *source == id) + .expect("active source is projected"); + self.editor.move_selection_in(shaped, movement, extend)?; + } else { + self.editor + .move_selection_prepared(&mut self.engine, movement, extend)?; + self.reconcile_reading(); + } + Ok(()) + } + /// Everything to draw this frame. pub fn primitives(&self, colors: TextColors) -> Result>> { let preview = match &self.drag { @@ -973,11 +1178,13 @@ impl PageView { if !self.pointer_inside || self.space { return None; } - match page_hit_test( + match page_hit( &self.editor, self.scene.as_ref(), self.viewport.document_point(self.pointer), self.pixel(), + None, + self.column.as_ref(), ) { Some( Hit::Text { id, .. } | Hit::Handle { id, .. } | Hit::Resize { id, .. }, @@ -1000,6 +1207,7 @@ impl PageView { primitives.extend(page_primitives( &self.editor, self.scene.as_ref(), + self.column.as_ref(), preview, self.object_focus, Paint { @@ -1089,9 +1297,7 @@ impl PageView { (Some(Drag::Column { .. }), _) | (None, Some(Hit::Column { .. })) => Cursor::ColResize, (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, (None, Some(Hit::Date(_))) => Cursor::Pointer, - (None, Some(Hit::Text { id, point })) - if self.editor.link_under(id, point).is_some() => - { + (None, Some(Hit::Text { id, point })) if self.link_under(id, point).is_some() => { Cursor::Pointer } ( @@ -1170,10 +1376,7 @@ impl PageView { Some(Drag::Text { anchor, unit }) => { let (anchor, unit) = (*anchor, *unit); let point = self.viewport.document_point(self.pointer); - let origin = self.editor.active_outline().origin(); - let target = - self.editor - .selection_at(point[0] - origin[0], point[1] - origin[1], unit)?; + let target = self.selection_at(self.editor.active_outline().id, point, unit)?; let pair = |selection: Selection| { [0, 1].map(|end| (selection.positions[end], selection.affinities[end])) }; @@ -1357,7 +1560,7 @@ impl PageView { Some(Hit::Text { id, point }) if unit == SelectionUnit::Grapheme && !self.modifiers.shift - && let Some(address) = self.editor.link_under(id, point) => + && let Some(address) = self.link_under(id, point) => { self.drag = None; return Ok(Response { @@ -1365,15 +1568,21 @@ impl PageView { ..Response::default() }); } - Some(Hit::Text { id, point }) => { + Some(Hit::Text { id, point: local }) => { + let reading = self.column.is_some(); let extend = self.modifiers.shift && self.object_focus.is_none() && id == self.editor.active_outline().id; self.set_object_focus(None); self.editor.focus_outline(id)?; let previous = self.editor.selection(); - self.editor.select_below(&mut self.engine, id, point)?; - let selection = self.editor.selection_at(point[0], point[1], unit)?; + self.reconcile_reading(); + let selection = if reading { + self.selection_at(id, point, unit)? + } else { + self.editor.select_below(&mut self.engine, id, local)?; + self.editor.selection_at(local[0], local[1], unit)? + }; let selection = if extend { Selection { positions: [previous.positions[0], selection.positions[1]], @@ -1402,6 +1611,29 @@ impl PageView { /// A fresh click places text 7 px above the pointer, on the grid. fn place_caret(&mut self, point: [f32; 2]) -> Result<()> { + if let Some(column) = &self.column { + let id = column + .outlines + .iter() + .rev() + .find(|(id, _, _)| { + self.editor + .visible_outlines() + .chain(self.editor.caret_outline()) + .any(|outline| outline.id == *id && !outline.title) + }) + .map(|(id, _, _)| *id); + if let Some(id) = id { + self.editor.focus_outline(id)?; + self.reconcile_reading(); + let selection = self.selection_at(id, point, SelectionUnit::Grapheme)?; + self.editor.select(selection)?; + return Ok(()); + } + self.editor.leave_title(&mut self.engine)?; + self.reconcile_reading(); + return Ok(()); + } let position = [point[0], point[1] - 7.0 * self.pixel()]; let position = self.grid(position); self.editor @@ -1625,8 +1857,8 @@ impl PageView { /// `None` off text, pictures and files. pub fn context(&mut self) -> Result> { let point = self.viewport.document_point(self.pointer); - let (id, point) = match self.hit_test(point) { - Some(Hit::Text { id, point }) => (id, point), + let id = match self.hit_test(point) { + Some(Hit::Text { id, .. }) => id, Some(Hit::File(id)) | Some(Hit::Image { id, .. }) => { let focus = if self.editor.attachment(id).is_some() { ObjectFocus::File(id) @@ -1645,9 +1877,8 @@ impl PageView { let outline = self.editor.active_outline().id; let [anchor, focus] = self.editor.selection().positions; self.editor.focus_outline(id)?; - let at = self - .editor - .selection_at(point[0], point[1], SelectionUnit::Grapheme)?; + self.reconcile_reading(); + let at = self.selection_at(id, point, SelectionUnit::Grapheme)?; let inside = outline == id && anchor.min(focus) <= at.positions[0] && at.positions[0] <= anchor.max(focus); @@ -1727,8 +1958,8 @@ impl PageView { let (id, word, correction) = { let mut outlines: Vec<&TextOutline> = self.editor.outlines().iter().collect(); outlines.sort_by(|a, b| { - let [ax, ay] = a.origin(); - let [bx, by] = b.origin(); + let [ax, ay] = crate::reading::geometry(a, self.column.as_ref()).1; + let [bx, by] = crate::reading::geometry(b, self.column.as_ref()).1; ay.total_cmp(&by).then(ax.total_cmp(&bx)) }); let active = self.editor.active_outline().id; @@ -1962,14 +2193,16 @@ impl PageView { /// Puts the caret where a drop at `position` lands, as a click there would. fn drop_caret(&mut self, position: [f32; 2]) -> Result<()> { let point = self.viewport.document_point(position); + let reading = self.column.is_some(); self.set_object_focus(None); match self.hit_test(point) { - Some(Hit::Text { id, point }) => { + Some(Hit::Text { id, point: local }) => { self.editor.focus_outline(id)?; - self.editor.select_below(&mut self.engine, id, point)?; - let at = self - .editor - .selection_at(point[0], point[1], SelectionUnit::Grapheme)?; + self.reconcile_reading(); + if !reading { + self.editor.select_below(&mut self.engine, id, local)?; + } + let at = self.selection_at(id, point, SelectionUnit::Grapheme)?; self.editor.select(at)?; } None => self.place_caret(point)?, @@ -2118,8 +2351,7 @@ impl PageView { | (_, Key::Named(NamedKey::Tab)) => return Ok(Response::default()), // Enter leaves the caret at the selection's end, as Right does. (_, Key::Named(NamedKey::Enter)) => { - self.editor - .move_selection(&mut self.engine, Movement::Right, false)?; + self.move_selection(Movement::Right, false)?; return self.edited(); } _ => {} @@ -2199,8 +2431,7 @@ impl PageView { } let chord = Command::from_key(key, self.modifiers); if let Some(Command::Move(movement)) = chord { - self.editor - .move_selection(&mut self.engine, movement, shift)?; + self.move_selection(movement, shift)?; } else if let Some(Command::ScrollPage { up }) = chord { // AppKit keeps ten points of the last page in view. let page = (self.viewport.size[1] as f32 - 10.0 * self.display_scale).max(0.0); @@ -2306,13 +2537,14 @@ pub enum Hit { } /// What a document point lands on; `pixel` is document points per device pixel. -pub(crate) fn page_hit_test( +#[cfg(test)] +fn page_hit_test( editor: &CanvasEditor, scene: Option<&(PageScene, [f32; 2])>, point: [f32; 2], pixel: f32, ) -> Option { - page_hit(editor, scene, point, pixel, None) + page_hit(editor, scene, point, pixel, None, None) } /// OneNote 2010's play button: a blue disc with a white triangle, in `rect`. @@ -2342,7 +2574,57 @@ fn page_hit( point: [f32; 2], pixel: f32, touched: Option, + column: Option<&crate::reading::Column>, ) -> Option { + if let Some(column) = column { + let hit = |id| { + let source = editor + .visible_outlines() + .chain(editor.caret_outline()) + .find(|outline| outline.id == id)?; + let (geometry, mut origin) = crate::reading::geometry(source, Some(column)); + if let Some((_, offset)) = scene { + origin = [origin[0] + offset[0], origin[1] + offset[1]]; + } + let local = [point[0] - origin[0], point[1] - origin[1]]; + if let Some((paragraph, ..)) = geometry.shaped.tags().find(|(_, origin, tag)| { + let x = geometry.shaped.tag_column_offset() + origin[0]; + let y = origin[1]; + tag.icon.checkable() + && (x..=x + tag.size).contains(&local[0]) + && (y..=y + tag.size).contains(&local[1]) + }) { + return Some(Hit::Check { + outline: id, + paragraph, + }); + } + if local[0] >= 0.0 + && local[0] <= column.width + && local[1] >= 0.0 + && local[1] <= geometry.shaped.size[1] + { + Some(Hit::Text { id, point: local }) + } else { + None + } + }; + if let Some((scene, offset)) = scene + && let Some(hit) = scene.hit_test_with( + [point[0] - offset[0], point[1] - offset[1]], + Some(editor), + Some(column), + hit, + ) + { + return Some(match hit { + crate::gpu::page::SceneHit::Outline(hit) => hit, + crate::gpu::page::SceneHit::Date(field) => Hit::Date(field), + _ => return None, + }); + } + return column.outlines.iter().rev().find_map(|(id, _, _)| hit(*id)); + } /// Grips and text resolve front to back before any outline's padding, as a native /// width handle stays reachable under the next outline's left padding; the typing room /// below an outline comes last. @@ -2548,6 +2830,7 @@ pub(crate) fn rule_primitives( fn page_primitives<'a>( editor: &'a CanvasEditor, scene: Option<&'a (PageScene, [f32; 2])>, + column: Option<&'a crate::reading::Column>, preview: Option>, object_focus: Option, paint: Paint, @@ -2563,6 +2846,11 @@ fn page_primitives<'a>( None => false, }; let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| { + let offset = if column.is_some() { + scene.map_or([0.0; 2], |(_, offset)| *offset) + } else { + offset + }; // An emptied page outline leaves the editor but keeps its paint slot for undo. let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else { return Ok(()); @@ -2571,7 +2859,8 @@ fn page_primitives<'a>( Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized, _ => outline, }; - let [x, y] = outline.origin(); + let (geometry, displayed) = crate::reading::geometry(outline, column); + let [x, y] = displayed; let origin = match preview { Some(PointerFeedback::Move(id, [dx, dy])) if id == outline.id || whole == Some(Whole::Page) && !outline.title => @@ -2581,11 +2870,12 @@ fn page_primitives<'a>( _ => [x, y], }; // OneNote frames the title whether or not it is focused or hovered. - if outline.title - || selected(outline) - || (paint.chrome && object_focus.is_none() && outline.id == active) - || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) - || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) + if column.is_none() + && (outline.title + || selected(outline) + || (paint.chrome && object_focus.is_none() && outline.id == active) + || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) + || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)) { append_outline_chrome( outline, @@ -2596,13 +2886,27 @@ fn page_primitives<'a>( primitives, ); } + if column.is_some() && outline.title { + let [x, y] = [origin[0] + offset[0], origin[1] + offset[1]]; + append_title_frame( + [ + x - 8.25, + y - 3.75, + x + geometry.shaped.size[0] + 2.25, + y + geometry.shaped.size[1] + 3.75, + ], + paint.pixel, + paint.colors.paper, + primitives, + ); + } append_outline( (object_focus.is_none() && outline.id == active && !matches!(preview, Some(PointerFeedback::Resize(_)))) .then_some(editor), selected(outline), - outline, + geometry, [origin[0] + offset[0], origin[1] + offset[1]], paint, primitives, @@ -2633,6 +2937,7 @@ fn page_primitives<'a>( &mut primitives, *origin, Some(editor), + column, moving, paint.colors.paper, &draw_outline, @@ -2650,8 +2955,16 @@ fn page_primitives<'a>( append_outline( Some(editor), false, - outline, - outline.origin(), + crate::reading::geometry(outline, column).0, + { + let origin = crate::reading::geometry(outline, column).1; + let offset = if column.is_some() { + scene.map_or([0.0; 2], |(_, offset)| *offset) + } else { + [0.0; 2] + }; + [origin[0] + offset[0], origin[1] + offset[1]] + }, paint, &mut primitives, )?; @@ -2926,14 +3239,7 @@ fn append_outline_chrome( let [left, top, right, bottom] = crate::translated(bounds, [dx, dy]); let body_top = body_top + dy; if outline.title { - primitives.push(Primitive::RoundedRect { - rect: [left, top, right, bottom], - radius: [4.5, (bottom - top) * 0.5], - // A page pixel at 100% zoom, so it scales when zoomed in but stays a screen pixel - // when zoomed out. - stroke: Some(Stroke::Dashed(pixel.max(0.75))), - color: paper.shade(crate::gpu::colorref(0x007f7f7f)), - }); + append_title_frame([left, top, right, bottom], pixel, paper, primitives); return; } primitives.push(Primitive::RoundedRect { @@ -2991,16 +3297,33 @@ fn append_outline_chrome( } } +fn append_title_frame( + [left, top, right, bottom]: [f32; 4], + pixel: f32, + paper: Paper, + primitives: &mut Vec>, +) { + primitives.push(Primitive::RoundedRect { + rect: [left, top, right, bottom], + radius: [4.5, (bottom - top) * 0.5], + // A page pixel at 100% zoom, so it scales when zoomed in but stays a screen pixel + // when zoomed out. + stroke: Some(Stroke::Dashed(pixel.max(0.75))), + color: paper.shade(crate::gpu::colorref(0x007f7f7f)), + }); +} + /// `editor` paints its selection and caret in the focused outline; `selected` highlights all /// the text of another. fn append_outline<'a>( editor: Option<&'a CanvasEditor>, selected: bool, - outline: &'a TextOutline, + geometry: crate::editor::OutlineGeometry<'a>, origin: [f32; 2], paint: Paint, primitives: &mut Vec>, ) -> Result<()> { + let outline = geometry.source; let [x, y] = origin; let Paint { caret, @@ -3015,14 +3338,14 @@ fn append_outline<'a>( .. } = paint; let rows = [visible[0] - y, visible[1] - y]; - outline - .shaped() + geometry + .shaped .append_table_primitives(primitives, origin, colors.paper); - outline - .shaped() + geometry + .shaped .append_block_tag_primitives(primitives, origin, paint.tag_art); - outline - .shaped() + geometry + .shaped .append_background_primitives(primitives, origin, rows, colors.paper); // OneNote's search highlight: yellow, deepened to gold under light text on dark paper. let [red, green, blue, _] = colors.paper.color; @@ -3032,7 +3355,7 @@ fn append_outline<'a>( draw::srgb(0xff, 0xe6, 0x00) }; for (_, selection) in found.iter().filter(|(id, _)| *id == outline.id) { - for rect in outline.range_rects(*selection)? { + for rect in geometry.range_rects(*selection)? { primitives.push(Primitive::Rect { rect: [ rect.x0 as f32 + x, @@ -3045,13 +3368,13 @@ fn append_outline<'a>( } } let mut highlight = match editor { - Some(editor) => editor.selection_rects()?, - None if selected => outline.range_rects(outline.whole())?, + Some(editor) => geometry.range_rects(editor.selection())?, + None if selected => geometry.range_rects(outline.whole())?, None => Vec::new(), }; // OneNote highlights the note playing as it highlights a selection. if let Some((_, selection)) = played.filter(|(id, _)| *id == outline.id) { - highlight.extend(outline.range_rects(*selection)?); + highlight.extend(geometry.range_rects(*selection)?); } for rect in highlight { primitives.push(Primitive::Rect { @@ -3064,8 +3387,8 @@ fn append_outline<'a>( color: colors.selection, }); } - for (index, paragraph) in outline.shaped().visible(rows) { - outline.shaped().append_paragraph_primitives( + for (index, paragraph) in geometry.shaped.visible(rows) { + geometry.shaped.append_paragraph_primitives( index, paragraph, origin, @@ -3081,7 +3404,7 @@ fn append_outline<'a>( .map(|(_, at)| at); append_marks( spelling, - outline, + geometry, typing, origin, rows, @@ -3091,7 +3414,17 @@ fn append_outline<'a>( )?; } if let Some(editor) = editor { - for rect in editor.marked_rects()? { + for rect in editor + .marked_range() + .map(|range| { + geometry.range_rects(Selection { + positions: [range.start, range.end], + affinities: [parley::Affinity::Downstream, parley::Affinity::Upstream], + }) + }) + .transpose()? + .unwrap_or_default() + { primitives.push(Primitive::Rect { rect: [ rect.x0 as f32 + x, @@ -3105,7 +3438,8 @@ fn append_outline<'a>( let [anchor, focus] = editor.selection().positions; if caret > 0.0 && anchor == focus { // AppKit centres the caret on the insertion point. - let rect = editor.caret(0.0)?; + let selection = editor.selection(); + let rect = geometry.caret_at(selection.positions[1], selection.affinities[1], 0.0)?; let half = edit::CARET_WIDTH / 2.0 * pixel; let middle = rect.x0 as f32 + x; primitives.push(Primitive::RoundedRect { @@ -3131,7 +3465,7 @@ fn append_outline<'a>( #[allow(clippy::too_many_arguments)] fn append_marks( spelling: &crate::spelling::Spelling, - outline: &TextOutline, + geometry: crate::editor::OutlineGeometry<'_>, typing: Option, origin: [f32; 2], rows: [f32; 2], @@ -3139,9 +3473,10 @@ fn append_marks( pixel: f32, primitives: &mut Vec>, ) -> Result<()> { + let outline = geometry.source; let color = draw::srgb(0xff, 0x00, 0x00); let dot = (pixel * scale).round().max(1.0); - let shown = outline.layouts().filter(|(_, paragraph)| { + let shown = geometry.layouts().filter(|(_, paragraph)| { paragraph.origin[1] + paragraph.text.height() >= rows[0] && paragraph.origin[1] <= rows[1] }); for (index, _) in shown { @@ -3154,7 +3489,7 @@ fn append_marks( if typing.is_some_and(|at| at.paragraph == index && at.offset == end) { continue; } - for [left, right, baseline] in outline.underlines(index, start..end)? { + for [left, right, baseline] in geometry.underlines(index, start..end)? { let right = ((origin[0] + right) * scale + device_x).round(); let mut x = ((origin[0] + left) * scale + device_x).round(); let top = ((origin[1] + baseline) * scale + device_y).round() + dot; diff --git a/crates/canvas/src/interaction/profile.rs b/crates/canvas/src/interaction/profile.rs index 4fc5122099783164c0dbe7b5ed1364ec29143ccd..51d0eead0528a4bc4aea5fb30a31346d4bef5f03 100644 --- a/crates/canvas/src/interaction/profile.rs +++ b/crates/canvas/src/interaction/profile.rs @@ -60,7 +60,7 @@ fn editing_cost( let mut access = accessibility::Accessibility::default(); let update = timed(case, "ax_first", 0, || { access - .update(editor, scene, viewport, "Profile", None, None) + .update_with(editor, scene, viewport, "Profile", None, None, None) .unwrap() }); eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len()); @@ -107,6 +107,7 @@ fn editing_cost( scene, None, None, + None, super::tests::paint(true, viewport.scale, 1.0), ) .unwrap(); @@ -117,7 +118,7 @@ fn editing_cost( timed(case, "ax_insert", sample, || { black_box( access - .update(editor, scene, viewport, "Profile", None, None) + .update_with(editor, scene, viewport, "Profile", None, None, None) .unwrap(), ); }); @@ -125,7 +126,7 @@ fn editing_cost( timed(case, "ax_scroll", sample, || { black_box( access - .update(editor, scene, viewport, "Profile", None, None) + .update_with(editor, scene, viewport, "Profile", None, None, None) .unwrap(), ); }); @@ -136,7 +137,7 @@ fn editing_cost( timed(case, "ax_undo", sample, || { black_box( access - .update(editor, scene, viewport, "Profile", None, None) + .update_with(editor, scene, viewport, "Profile", None, None, None) .unwrap(), ); }); @@ -188,6 +189,7 @@ fn canvas_pipeline_cost() { Some(&scene), None, None, + None, super::tests::paint(false, 96.0 / 72.0, 1.0), ) .unwrap(), diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 844b91ba18aad584a532019ad4d88dfce53a427b..d7c1ac3658e4b9205b1f2f4374c14248185fe2a1 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -90,7 +90,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() .unwrap(); } let update = access - .update(&editor, Some(&scene), viewport, "Header", None, None) + .update_with(&editor, Some(&scene), viewport, "Header", None, None, None) .unwrap(); let buttons: Vec<_> = accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update)) @@ -177,7 +177,8 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id) ); } - let primitives = page_primitives(&editor, None, None, None, paint(false, 1.0, 1.0)).unwrap(); + let primitives = + page_primitives(&editor, None, None, None, None, paint(false, 1.0, 1.0)).unwrap(); let borders = primitives .iter() .filter_map(|p| match p { @@ -189,7 +190,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { assert!(primitives.iter().any(|primitive| matches!(primitive, Primitive::RoundedRect { rect, radius, stroke: Some(Stroke::Dashed(_)), .. } if radius[0] == 4.5 && radius[1] * 2.0 == rect[3] - rect[1]))); let mut access = accessibility::Accessibility::default(); let update = access - .update( + .update_with( &editor, None, Viewport { @@ -200,6 +201,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { "Header", None, None, + None, ) .unwrap(); assert!(update.nodes.iter().any(|(_, node)| node.role() @@ -232,7 +234,7 @@ fn title_frame_shows_unfocused_and_its_pen_scales_only_when_zoomed_in() { for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] { let scale = zoom * 96.0 / 72.0; let primitives = - page_primitives(&editor, None, None, None, paint(false, scale, 1.0)).unwrap(); + page_primitives(&editor, None, None, None, None, paint(false, scale, 1.0)).unwrap(); let dashed = primitives .iter() .filter_map(|primitive| match primitive { @@ -265,7 +267,8 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { .move_selection(&mut engine, Movement::Down, false) .unwrap(); } - let primitives = page_primitives(&editor, None, None, None, paint(true, 1.0, 1.0)).unwrap(); + let primitives = + page_primitives(&editor, None, None, None, None, paint(true, 1.0, 1.0)).unwrap(); assert_eq!( primitives .iter() @@ -279,7 +282,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { ); let mut access = accessibility::Accessibility::default(); let update = access - .update( + .update_with( &editor, None, Viewport { @@ -290,6 +293,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { "Test", None, None, + None, ) .unwrap(); let fields = update @@ -334,21 +338,28 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { origin: [48.0; 2], }; let initial = access - .update(&editor, None, viewport, "Page", None, None) + .update_with(&editor, None, viewport, "Page", None, None, None) .unwrap(); let id = editor.active_outline().id; assert_eq!(access.outline_for_node(initial.focus), Some(id)); let rectangles = |editor: &CanvasEditor| { - page_primitives(editor, None, None, None, paint(true, viewport.scale, 1.0)) - .unwrap() - .into_iter() - .filter_map(|p| match p { - Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => { - Some((rect, color)) - } - _ => None, - }) - .collect::>() + page_primitives( + editor, + None, + None, + None, + None, + paint(true, viewport.scale, 1.0), + ) + .unwrap() + .into_iter() + .filter_map(|p| match p { + Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => { + Some((rect, color)) + } + _ => None, + }) + .collect::>() }; assert_eq!(rectangles(&editor).len(), 1); assert_eq!( @@ -363,12 +374,12 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret) ); let preedit = access - .update(&editor, None, viewport, "Page", None, None) + .update_with(&editor, None, viewport, "Page", None, None, None) .unwrap(); assert_eq!(preedit.focus, initial.focus); editor.commit_text(&mut engine, "ๆ—ฅๆœฌ".into()).unwrap(); let committed = access - .update(&editor, None, viewport, "Page", None, None) + .update_with(&editor, None, viewport, "Page", None, None, None) .unwrap(); assert_eq!(committed.focus, initial.focus); assert!(rectangles(&editor).len() > 1); @@ -376,7 +387,7 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { assert!(editor.outlines().is_empty()); assert_eq!(rectangles(&editor).len(), 1); let empty = access - .update(&editor, None, viewport, "Page", None, None) + .update_with(&editor, None, viewport, "Page", None, None, None) .unwrap(); assert_eq!(empty.focus, committed.focus); assert_eq!(access.outline_for_node(committed.focus), Some(id)); @@ -386,14 +397,14 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { assert!(editor.outlines().is_empty()); assert_eq!(rectangles(&editor).len(), 1); let retired = access - .update(&editor, None, viewport, "Page", None, None) + .update_with(&editor, None, viewport, "Page", None, None, None) .unwrap(); assert_eq!(retired.focus, committed.focus); assert_eq!(access.outline_for_node(retired.focus), Some(id)); editor.undo(&mut engine).unwrap(); assert!(rectangles(&editor).len() > 1); let restored = access - .update(&editor, None, viewport, "Page", None, None) + .update_with(&editor, None, viewport, "Page", None, None, None) .unwrap(); assert_eq!(restored.focus, committed.focus); editor.redo(&mut engine).unwrap(); @@ -463,6 +474,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() { Some(&scene), None, None, + None, paint(true, viewport.scale, 1.0), ) .unwrap() @@ -493,7 +505,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() { ) .unwrap(); let before = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); let before = accessibility::tests::apply(&mut tree, before) .focus() @@ -503,7 +515,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() { editor.insert(&mut engine, "CANVAS CHECK ").unwrap(); { let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); assert_eq!( accessibility::tests::apply(&mut tree, update) @@ -518,6 +530,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() { Some(&scene), None, None, + None, paint(true, viewport.scale, 1.0), ) .unwrap(); @@ -532,7 +545,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() { assert_eq!(editor.active_outline().document(), &original); assert_eq!(tags(&editor), original_tags); let update = access - .update(&editor, None, viewport, "Test", None, None) + .update_with(&editor, None, viewport, "Test", None, None, None) .unwrap(); assert_eq!( accessibility::tests::apply(&mut tree, update) @@ -622,7 +635,7 @@ fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() { let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap(); let second = editor.outlines()[1].id; let borders = |feedback| { - page_primitives(&editor, None, feedback, None, paint(false, 1.0, 1.0)) + page_primitives(&editor, None, None, feedback, None, paint(false, 1.0, 1.0)) .unwrap() .into_iter() .filter_map(|p| match p { @@ -900,7 +913,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { append_outline( Some(&editor), false, - editor.active_outline(), + crate::reading::geometry(editor.active_outline(), None).0, [24.0, 48.0], Paint { caret: 0.0, @@ -978,7 +991,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { append_outline( Some(&editor), false, - editor.active_outline(), + crate::reading::geometry(editor.active_outline(), None).0, [24.0, 48.0], Paint { caret: 0.0, @@ -1047,6 +1060,7 @@ fn resize_preview_paints_reflowed_text_without_changing_the_editor() { let primitives = page_primitives( &editor, None, + None, Some(PointerFeedback::Resize(&resized)), None, paint(false, 96.0 / 72.0, 1.0), @@ -1141,7 +1155,7 @@ fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and None ); let primitives = - page_primitives(&editor, None, None, None, paint(false, scale, dpr)).unwrap(); + page_primitives(&editor, None, None, None, None, paint(false, scale, dpr)).unwrap(); assert!( matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top]) ); @@ -1266,15 +1280,21 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { .1 .text .id(); - let texts: Vec<_> = - page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)) - .unwrap() - .into_iter() - .filter_map(|p| match p { - Primitive::Text { text, .. } => Some(painted_layout(text).id()), - _ => None, - }) - .collect(); + let texts: Vec<_> = page_primitives( + &editor, + Some(&scene), + None, + None, + None, + paint(false, 1.0, 1.0), + ) + .unwrap() + .into_iter() + .filter_map(|p| match p { + Primitive::Text { text, .. } => Some(painted_layout(text).id()), + _ => None, + }) + .collect(); assert_eq!(texts.len(), 2); assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout); let expected = if readonly_on_top { @@ -1318,15 +1338,21 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { grab: [10.0, -8.0] }) ); - let texts: Vec<_> = - page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)) - .unwrap() - .into_iter() - .filter_map(|p| match p { - Primitive::Text { text, .. } => Some(painted_layout(text).id()), - _ => None, - }) - .collect(); + let texts: Vec<_> = page_primitives( + &editor, + Some(&scene), + None, + None, + None, + paint(false, 1.0, 1.0), + ) + .unwrap() + .into_iter() + .filter_map(|p| match p { + Primitive::Text { text, .. } => Some(painted_layout(text).id()), + _ => None, + }) + .collect(); assert_eq!( *texts.last().unwrap(), editor @@ -1480,7 +1506,15 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { }; for scale in [1.0, 2.0] { let text = rectangles( - page_primitives(&editor, Some(&scene), None, None, paint(true, scale, 1.0)).unwrap(), + page_primitives( + &editor, + Some(&scene), + None, + None, + None, + paint(true, scale, 1.0), + ) + .unwrap(), ); assert!(text.iter().any(|(_, color)| *color == COLORS.caret)); let selected = rectangles( @@ -1488,6 +1522,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { &editor, Some(&scene), None, + None, Some(ObjectFocus::ReadOnly(0)), paint(true, scale, 1.0), ) @@ -1501,6 +1536,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { &editor, Some(&scene), None, + None, Some(ObjectFocus::ReadOnly(0)), paint(false, scale, 1.0) ) @@ -1514,7 +1550,15 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { } editor.select_all().unwrap(); let text = rectangles( - page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)).unwrap(), + page_primitives( + &editor, + Some(&scene), + None, + None, + None, + paint(false, 1.0, 1.0), + ) + .unwrap(), ); assert!(text.iter().any(|(_, color)| *color == COLORS.selection)); let selected = rectangles( @@ -1522,6 +1566,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { &editor, Some(&scene), None, + None, Some(ObjectFocus::ReadOnly(0)), paint(true, 1.0, 1.0), ) @@ -3639,3 +3684,589 @@ fn copied(response: Response) -> Option { _ => None, } } + +fn reader_page() -> Page { + let mut engine = TextEngine::default(); + let mut outlines = [ + vec!["A title that wraps across several displayed lines on a narrow phone"], + vec!["Monday", "6:14 AM"], + vec!["Retained header"], + vec!["Body ๐Ÿ˜€ with enough words to wrap across many displayed lines. Body words continue here.", "", "Last paragraph"], + ].map(|text| TextOutline::new( + &mut engine, + TextDocument::new(text.into_iter().map(|text| Paragraph::new(text.into(), Default::default())).collect()).unwrap(), + 468.0, + [36.0, 240.0], + ).unwrap().snapshot()); + outlines[0].title = true; + outlines[0].layout.x = Some(0.0); + outlines[0].layout.y = Some(0.0); + outlines[1].layout.x = Some(0.0); + outlines[1].layout.y = Some(8.0); + outlines[1].layout.width_set_by_user = Some(false); + outlines[2].layout.x = Some(0.0); + outlines[2].layout.y = Some(30.0); + outlines[2].layout.max_width = Some(120.0); + outlines[2].layout.width_set_by_user = Some(false); + let [title, date, header, body] = outlines; + Page { + identity: None, + title: "Reader".into(), + created: Some(1), + margin_origin: [36.0, 14.4], + rtl: false, + color: None, + rule_lines: None, + definitions: Default::default(), + objects: vec![ + onestore::page::PageObject::Title(onestore::page::Title { + id: onestore::ExGuid::default(), + date: Some(date.id), + layout: Default::default(), + outlines: vec![title, date, header], + }), + onestore::page::PageObject::Outline(body), + ], + } +} + +fn reader_view(page: Page) -> PageView { + let mut engine = TextEngine::default(); + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let mut view = PageView::new( + editor, + engine, + Some((scene, [20.0, 40.0])), + [360, 320], + 1.0, + Duration::ZERO, + ); + view.host_viewport = true; + view.viewport.scale = 1.0; + view.viewport.origin = [0.0; 2]; + view +} + +#[test] +fn reader_consumers_share_title_header_date_and_body_geometry() { + let mut view = reader_view(reader_page()); + let original = view.editor.page().unwrap(); + let mut access = accessibility::Accessibility::default(); + let mut tree = None; + let mut dates = Vec::new(); + let mut body_tops = Vec::new(); + let mut hanging_whitespace = false; + for width in [240.0, 160.0] { + assert!(view.set_reading(Some(width))); + let body = view + .editor + .visible_outlines() + .find(|outline| !outline.title) + .unwrap() + .id; + let column = view.column.as_ref().unwrap(); + for (_, _, shaped) in &column.outlines { + for paragraph in &shaped.paragraphs { + for (line, _) in paragraph.text.lines() { + let metrics = line.metrics(); + assert!( + paragraph.origin[0] + metrics.advance - metrics.trailing_whitespace + <= width + 1.0 + ); + hanging_whitespace |= paragraph.origin[0] + metrics.advance > width + 1.0; + } + } + } + let (_, origin, shaped) = column + .outlines + .iter() + .find(|(id, _, _)| *id == body) + .unwrap(); + body_tops.push(origin[1]); + let (scene, offset) = view.scene.as_ref().unwrap(); + assert!( + scene + .content_bounds(&view.editor, Some(column)) + .all(|(rect, _)| rect[3] < origin[1]) + ); + let primitives = view.primitives(COLORS).unwrap(); + assert!(primitives.iter().any(|primitive| matches!(primitive, + Primitive::Text { text, origin: painted, .. } + if painted_layout(*text).id() == shaped.paragraphs[0].text.id() + && *painted == [origin[0] + offset[0] + shaped.paragraphs[0].origin[0], origin[1] + offset[1] + shaped.paragraphs[0].origin[1]]))); + let header = view + .editor + .objects + .iter() + .find_map(|object| match object { + crate::editor::page::Content::Outline { layout, .. } => { + Some(layout.paragraphs[0].text.id()) + } + _ => None, + }) + .unwrap(); + assert!(primitives.iter().any(|primitive| matches!(primitive, + Primitive::Text { text, .. } if painted_layout(*text).id() == header))); + drop(primitives); + let nodes = accessibility::tests::nodes(accessibility::tests::apply( + &mut tree, + access.update(&view, view.viewport, "Reader").unwrap(), + )); + let field = nodes + .iter() + .find(|node| access.outline_for_node(node.locate().0) == Some(body)) + .unwrap(); + assert!( + field + .data() + .supports_action(accesskit::Action::ReplaceSelectedText) + ); + assert_eq!( + field.bounding_box().unwrap().y0, + f64::from(origin[1] + offset[1]) + ); + let header = nodes + .iter() + .find(|node| { + node.role() == accesskit::Role::Label + && node.data().value() == Some("Retained header") + }) + .unwrap(); + assert!(header.bounding_box().unwrap().y1 < field.bounding_box().unwrap().y0); + let buttons: Vec<_> = nodes + .iter() + .filter(|node| node.role() == accesskit::Role::Button) + .collect(); + assert_eq!(buttons.len(), 2); + for (index, node) in buttons.iter().enumerate() { + let rect = node.bounding_box().unwrap(); + assert_eq!( + view.hit([ + ((rect.x0 + rect.x1) / 2.0) as f32, + ((rect.y0 + rect.y1) / 2.0) as f32 + ]), + Some(Hit::Date([DateField::Date, DateField::Time][index])) + ); + if dates.len() < 2 { + dates.push(node.locate().0); + } else { + assert_eq!(node.locate().0, dates[index]); + } + } + assert_eq!(view.editor.page().unwrap(), original); + assert_eq!(view.editor.history_depth(), [0, 0]); + assert!(view.editor.take_ops().unwrap().is_empty()); + } + assert!(body_tops[1] > body_tops[0]); + assert!(hanging_whitespace); + let mut page = reader_page(); + let onestore::page::PageObject::Title(title) = &mut page.objects[0] else { + unreachable!() + }; + title.outlines[1].layout.width_set_by_user = Some(true); + let mut wide_date = reader_view(page); + assert_eq!(wide_date.editor.date().unwrap().layout().size[0], 468.0); + assert!(!wide_date.reading_status(240.0).0); + assert!(!wide_date.set_reading(Some(240.0))); + assert!(wide_date.editor.take_ops().unwrap().is_empty()); +} + +#[test] +fn reader_width_changes_preserve_original_ops_undo_and_source_coordinates() { + let page = reader_page(); + let mut results = Vec::new(); + for width in [160.0, 240.0] { + let mut view = reader_view(page.clone()); + let original = view.editor.page().unwrap(); + let body = view + .editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + view.editor.focus_outline(body).unwrap(); + view.editor + .move_selection(&mut view.engine, Movement::DocumentEnd, false) + .unwrap(); + assert!(view.set_reading(Some(width))); + assert!(view.commit_text(" accepted ๐Ÿ˜€".into()).unwrap().changed); + let committed = view.editor.page().unwrap(); + let ops = view.editor.take_ops().unwrap(); + assert!(!ops.is_empty()); + let history = view.editor.history_depth(); + let selection = view.editor.selection(); + assert!(view.set_reading(Some(200.0))); + assert_eq!(view.editor.selection(), selection); + assert_eq!(view.editor.history_depth(), history); + assert!(view.editor.take_ops().unwrap().is_empty()); + assert!(view.undo(false).unwrap().changed); + assert_eq!(view.editor.page().unwrap(), original); + assert!(view.undo(true).unwrap().changed); + assert_eq!(view.editor.page().unwrap(), committed); + assert_eq!(view.editor.selection(), selection); + results.push((committed, ops, history)); + } + assert_eq!(results[0], results[1]); +} + +#[test] +fn reader_page_navigation_prepares_a_marked_draft_before_its_first_geometry_query() { + for up in [false, true] { + let mut view = reader_view(reader_page()); + let body = view + .editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + view.editor.focus_outline(body).unwrap(); + let bottom = view.editor.active_outline().shaped().size[1]; + assert!( + view.editor + .select_below(&mut view.engine, body, [10.0, bottom + 20.0]) + .unwrap() + ); + let first_blank = view + .editor + .outlines() + .iter() + .find(|outline| outline.id == body) + .unwrap() + .document() + .nodes() + .len(); + view.editor + .select( + [TextPosition { + paragraph: first_blank, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + assert!(view.set_reading(Some(160.0))); + assert!(view.compose("IME ๐Ÿ˜€".into(), None).unwrap().changed); + let before = view.editor.active_outline().document().nodes().len(); + let marked = view.editor.marked_range().unwrap(); + let preview = view.editor.page().unwrap(); + assert!(view.set_reading(Some(180.0))); + assert_eq!(view.editor.marked_range(), Some(marked.clone())); + assert_eq!(view.editor.page().unwrap(), preview); + assert!(view.relayout().unwrap().moved); + assert_eq!(view.editor.marked_range(), Some(marked.clone())); + assert!(view.editor.take_ops().unwrap().is_empty()); + view.move_page(up, true).unwrap(); + assert!(view.editor.active_outline().document().nodes().len() < before); + assert!(view.editor.marked_range().is_none()); + assert_eq!(view.editor.history_depth(), [1, 0]); + assert!(!view.editor.take_ops().unwrap().is_empty()); + let source = view.editor.active_outline(); + let geometry = crate::reading::geometry(source, view.column.as_ref()).0; + assert_eq!( + geometry + .layouts() + .map(|(index, _)| source.document().leaf(index).unwrap().2.id) + .collect::>(), + geometry + .shaped + .paragraphs + .iter() + .map(|paragraph| paragraph.id) + .collect::>() + ); + view.caret_area().unwrap(); + view.primitives(COLORS).unwrap(); + let committed = view.editor.page().unwrap(); + view.move_page(up, false).unwrap(); + assert_eq!(view.editor.page().unwrap(), committed); + assert_eq!(view.editor.history_depth(), [1, 0]); + assert!(view.editor.take_ops().unwrap().is_empty()); + } +} + +#[test] +fn reader_projection_failure_after_an_accepted_edit_keeps_ops_and_full_canvas() { + let mut view = reader_view(reader_page()); + let body = view + .editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + view.editor.focus_outline(body).unwrap(); + assert!(view.set_reading(Some(200.0))); + view.column.as_mut().unwrap().width = f32::NAN; + assert!( + view.commit_text(" accepted before failed shaping".into()) + .unwrap() + .changed + ); + assert!(view.column.is_none()); + assert!( + view.editor + .active_outline() + .shown_text() + .contains("accepted before failed shaping") + ); + assert_eq!(view.editor.history_depth(), [1, 0]); + assert!(!view.editor.take_ops().unwrap().is_empty()); + view.primitives(COLORS).unwrap(); + assert!(view.set_reading(Some(200.0))); + assert!(view.insert_table(2, 2).unwrap().changed); + assert!(view.column.is_none()); + assert!(!view.editor.take_ops().unwrap().is_empty()); + view.primitives(COLORS).unwrap(); +} + +#[test] +fn reader_native_queries_and_accessibility_selection_use_source_utf16() { + let mut view = reader_view(reader_page()); + let body = view + .editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + view.editor.focus_outline(body).unwrap(); + assert!(view.set_reading(Some(160.0))); + let mut access = accessibility::Accessibility::default(); + let mut tree = None; + for offset in [0, 8, 25, 50] { + let at = view.editor.active_outline().utf16_position(offset).unwrap(); + view.editor.select([at; 2].into()).unwrap(); + assert!(view.focus_text().unwrap().changed); + let caret = view + .caret_at(body, at, parley::Affinity::Downstream, 0.0) + .unwrap(); + let hit = view + .selection_at( + body, + [caret.x0 as f32, (caret.y0 + caret.y1) as f32 / 2.0], + SelectionUnit::Grapheme, + ) + .unwrap(); + assert_eq!(hit.positions, [at; 2]); + let nodes = accessibility::tests::nodes(accessibility::tests::apply( + &mut tree, + access.update(&view, view.viewport, "Reader").unwrap(), + )); + let field = nodes + .iter() + .find(|node| access.outline_for_node(node.locate().0) == Some(body)) + .unwrap(); + assert_eq!( + access + .selection(body, *field.data().text_selection().unwrap()) + .unwrap(), + view.editor.selection() + ); + let rect = view.caret_area().unwrap(); + assert_eq!(rect[0], caret.x0 as f32); + assert_eq!(rect[1], caret.y0 as f32); + } + for movement in [ + Movement::DocumentStart, + Movement::Left, + Movement::Up, + Movement::DocumentEnd, + Movement::Right, + Movement::Down, + ] { + view.move_selection(movement, false).unwrap(); + } + assert_eq!(view.editor.outlines().len(), 2); + assert!(view.editor.caret_outline().is_none()); + assert!(view.editor.take_ops().unwrap().is_empty()); +} + +#[test] +fn reader_preserves_nested_visibility_rich_text_and_link_glyph_hits() { + let mut page = reader_page(); + let onestore::page::PageObject::Outline(body) = &mut page.objects[1] else { + unreachable!() + }; + body.paragraphs[0].collapsed = true; + body.paragraphs[1].parent = Some(body.paragraphs[0].id); + body.paragraphs[1].level = 2; + body.paragraphs[1].text_mut().unwrap().text = Paragraph::new( + "Hidden ๐Ÿ˜€ child".into(), + Format { + bold: Some(true), + font_size: Some(18.0), + ..Default::default() + }, + ); + body.paragraphs[2].text_mut().unwrap().text = Paragraph::new( + "Last rich paragraph".into(), + Format { + bold: Some(true), + font_size: Some(18.0), + ..Default::default() + }, + ); + let mut view = reader_view(page); + let body = view + .editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + view.editor.focus_outline(body).unwrap(); + assert!(view.set_reading(Some(160.0))); + let source = view.editor.active_outline(); + let geometry = crate::reading::geometry(source, view.column.as_ref()).0; + assert_eq!( + geometry + .layouts() + .map(|(index, _)| index) + .collect::>(), + [0, 2] + ); + let mut access = accessibility::Accessibility::default(); + let mut tree = None; + let update = access.update(&view, view.viewport, "Reader").unwrap(); + let nodes = accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update)); + let field = nodes + .iter() + .find(|node| access.outline_for_node(node.locate().0) == Some(body)) + .unwrap(); + assert!(!field.data().value().unwrap().contains("Hidden")); + let text = view + .editor + .active_outline() + .document() + .paragraph(0) + .unwrap(); + let end = text.utf16_offset(text.text().len()).unwrap(); + view.editor + .select( + [ + TextPosition { + paragraph: 0, + offset: 0, + }, + TextPosition { + paragraph: 0, + offset: end, + }, + ] + .into(), + ) + .unwrap(); + assert!(view.link_prefill().is_some()); + assert!( + view.set_link( + "A hyperlink label with several wrapped words", + "https://example.test/reader", + ) + .unwrap() + .changed + ); + assert_eq!( + view.editor + .link_at(view.editor.selection().positions[1]) + .unwrap() + .target, + "https://example.test/reader" + ); + assert!(!view.editor.take_ops().unwrap().is_empty()); + let source = view.editor.active_outline(); + let (geometry, _) = crate::reading::geometry(source, view.column.as_ref()); + let paragraph = &geometry.shaped.paragraphs[0]; + assert!(paragraph.text.lines().count() > 1); + for (line, bounds) in paragraph.text.lines() { + assert_eq!( + geometry + .link_under([ + paragraph.origin[0] + line.metrics().advance / 2.0, + paragraph.origin[1] + bounds.top + bounds.height / 2.0 + ]) + .as_deref(), + Some("https://example.test/reader") + ); + assert!( + geometry + .link_under([ + paragraph.origin[0] + line.metrics().advance + 8.0, + paragraph.origin[1] + bounds.top + bounds.height / 2.0 + ]) + .is_none() + ); + } +} + +#[test] +fn reader_empty_title_only_and_remote_unsupported_content_follow_current_source() { + let mut page = reader_page(); + page.objects.truncate(1); + let mut title_only = reader_view(page.clone()); + assert!(title_only.reading_status(160.0).0); + assert!(title_only.set_reading(Some(160.0))); + assert!(title_only.editor.take_ops().unwrap().is_empty()); + page.objects.clear(); + page.title.clear(); + page.created = None; + let mut empty = reader_view(page); + assert!(empty.reading_status(160.0).0); + assert!(empty.set_reading(Some(160.0))); + let outline = empty.editor.active_outline().id; + let caret = empty + .caret_at( + outline, + empty.editor.selection().positions[1], + parley::Affinity::Downstream, + 1.0, + ) + .unwrap(); + assert!( + matches!(empty.hit([caret.x0 as f32 + 0.5, (caret.y0 + caret.y1) as f32 / 2.0]), Some(Hit::Text { id, .. }) if id == outline) + ); + assert!(empty.editor.take_ops().unwrap().is_empty()); + let mut view = reader_view(reader_page()); + assert!(view.set_reading(Some(160.0))); + assert!( + view.commit_text(" preserved history".into()) + .unwrap() + .changed + ); + view.editor.take_ops().unwrap(); + let title = view.editor.active_outline().id; + let history = view.editor.history_depth(); + let mut remote = view.editor.page().unwrap(); + let mut engine = TextEngine::default(); + remote.objects.push(onestore::page::PageObject::Outline( + TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new( + "Remote annotation".into(), + Default::default(), + )]) + .unwrap(), + 120.0, + [600.0, 20.0], + ) + .unwrap() + .snapshot(), + )); + assert!(view.refresh(remote).unwrap().moved); + assert!(view.column.is_none()); + assert_eq!(view.editor.history_depth(), history); + assert_eq!(view.editor.active_outline().id, title); + assert!( + view.editor + .active_outline() + .shown_text() + .contains("preserved history") + ); + view.primitives(COLORS).unwrap(); + assert!(!view.reading_status(160.0).0); + assert!(view.editor.take_ops().unwrap().is_empty()); + let mut rtl = reader_page(); + rtl.rtl = true; + assert!(!reader_view(rtl).reading_status(160.0).0); +} diff --git a/crates/canvas/src/reading.rs b/crates/canvas/src/reading.rs index 64d0647af3416d9d010e216b89d8158e66b0571f..dae89ea8e16673d79b3d743f2b1b68623350882c 100644 --- a/crates/canvas/src/reading.rs +++ b/crates/canvas/src/reading.rs @@ -9,7 +9,7 @@ //! it. Text rewraps to the column, pictures shrink to it, and everything else keeps its size. use crate::{ - editor::{CanvasEditor, EditorError, TextOutline, page::Content}, + editor::{CanvasEditor, EditorError}, layout::TextEngine, }; use onestore::{ @@ -18,6 +18,184 @@ use onestore::{ }; use std::{collections::BTreeMap, ops::Range}; +#[cfg(feature = "gpu")] +use crate::editor::TextOutline; +#[cfg(feature = "interaction")] +use crate::editor::page::Content; + +#[cfg(feature = "gpu")] +pub(crate) struct Column { + #[cfg(feature = "interaction")] + pub(crate) width: f32, + pub(crate) outlines: Vec<(ExGuid, [f32; 2], crate::outline::OutlineLayout)>, +} + +#[cfg(feature = "gpu")] +pub(crate) fn geometry<'a>( + source: &'a TextOutline, + column: Option<&'a Column>, +) -> (crate::editor::OutlineGeometry<'a>, [f32; 2]) { + let (shaped, origin) = match column { + Some(column) => { + let (_, origin, shaped) = column + .outlines + .iter() + .find(|(id, _, _)| *id == source.id) + .expect("active column contains every current outline"); + (shaped, *origin) + } + None => (source.shaped(), source.origin()), + }; + (crate::editor::OutlineGeometry { source, shaped }, origin) +} + +#[cfg(feature = "interaction")] +impl Column { + pub(crate) fn build( + editor: &CanvasEditor, + scene: Option<&crate::gpu::page::PageScene>, + width: f32, + engine: &mut TextEngine, + ) -> Result, crate::layout::LayoutError> { + use crate::outline::{OutlineLayout, ParagraphLayout}; + if !width.is_finite() || width <= 0.0 { + return Err(crate::layout::LayoutError::InvalidWidth); + } + if editor.rtl() { + return Ok(None); + } + let title = editor + .visible_outlines() + .find(|outline| outline.title) + .map(|outline| outline.id); + if editor.objects.iter().any(|object| { + if let Content::Date { below_title } | Content::Outline { below_title, .. } = object + && (below_title.is_none() || *below_title != title) + { + return true; + } + if let Content::Outline { source, .. } = object + && source + .paragraphs + .iter() + .any(|paragraph| !matches!(paragraph.content, ParagraphContent::Text(_))) + { + return true; + } + !matches!( + object, + Content::Editable(_) + | Content::Date { .. } + | Content::Outline { + below_title: Some(_), + .. + } + ) + }) { + return Ok(None); + } + if scene.is_none() + && editor + .objects + .iter() + .any(|object| !matches!(object, Content::Editable(_))) + { + return Ok(None); + } + let mut sources: Vec<_> = editor + .visible_outlines() + .chain(editor.caret_outline()) + .collect(); + if sources.iter().filter(|outline| !outline.title).count() > 1 + || sources.iter().filter(|outline| outline.title).count() > 1 + { + return Ok(None); + } + sources.sort_by_key(|outline| !outline.title); + let mut column = Self { + width, + outlines: Vec::new(), + }; + let mut top = 14.4; + for source in sources { + if source + .document() + .nodes() + .iter() + .any(|node| !matches!(node.content, ParagraphContent::Text(_))) + { + return Ok(None); + } + if source.document().paragraphs().any(|text| { + text.spans() + .iter() + .any(|span| span.format.math == Some(true) || span.format.math_object.is_some()) + }) { + return Ok(None); + } + let shaped = OutlineLayout::flow( + source.document().nodes().iter(), + &source.indents, + width, + false, + 0, + None, + &mut |node, previous, width, indents| { + ParagraphLayout::shape( + engine, + node, + previous, + width, + indents, + &editor.definitions, + ) + }, + )?; + if shaped.size[0] > width + 1.0 + || shaped.paragraphs.iter().any(|paragraph| { + paragraph.origin[0] < 0.0 + || paragraph.origin[0] + + paragraph + .text + .lines() + .map(|(line, _)| { + line.metrics().advance - line.metrics().trailing_whitespace + }) + .fold(0.0_f32, f32::max) + > width + 1.0 + }) + { + return Ok(None); + } + let origin = [36.0, top]; + top += shaped.size[1] + GAP; + column.outlines.push((source.id, origin, shaped)); + } + if let Some(scene) = scene { + let mut bottom = column + .outlines + .iter() + .find(|(id, _, _)| Some(*id) == title) + .map(|(_, origin, shaped)| origin[1] + shaped.size[1] + GAP) + .unwrap_or(14.4); + for (rect, _) in scene.content_bounds(editor, Some(&column)) { + if rect[0] < 36.0 || rect[2] > 36.0 + width + 1.0 { + return Ok(None); + } + bottom = bottom.max(rect[3] + GAP); + } + if let Some((_, origin, _)) = column + .outlines + .iter_mut() + .find(|(id, _, _)| Some(*id) != title) + { + origin[1] = bottom; + } + } + Ok(Some(column)) + } +} + /// Points between blocks in the column: OneNote's grid row. const GAP: f32 = 18.0; /// Strokes this near one another are one drawing, as handwriting is many strokes. diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index 164ce1fd01a5b884d3e7e9814dc600f0d3e18fd5..da846c1fe30b7d1bd6cad4f3f7e1719e6c234a33 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -29,4 +29,5 @@ objc2-foundation = { version = "0.2.2", default-features = false, features = ["s [dev-dependencies] relay = { path = "../relay" } +rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] } tempfile = "3" diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index d8a84a2ff5a329b598dfd9e015d2967e8827d44c..b6b4148054aa8d170116cee288fe687b48ba5b44 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -100,8 +100,11 @@ bool sb_view_frame_pending(const View *view); void sb_view_set_dark(View *view, bool dark); bool sb_view_read_only(const View *view); char *sb_view_reading(const View *view, float width); -bool sb_view_set_reading(View *view, float width); -bool sb_view_reload(View *view, bool discard); +// Preserves the nearest source caret's host offset from the page-point anchor at scale 1. +bool sb_view_set_reading(View *view, float width, float anchor[2]); +bool sb_view_reload_ready(const View *view); +// A non-null page-point anchor follows its source caret across the refresh. +bool sb_view_reload(View *view, bool discard, float anchor[2]); 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]); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index ff21f881ae8eb2aa11b183d9c21b8b6452e5fb1f..2380aa3f3484aa673913a23af004b52ed57e5022 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -33,7 +33,7 @@ use onestore::{ op::{Edit, Op, PageOp}, page::{Page, ink::ShapeKind}, }; -use parley::{Affinity, BoundingBox}; +use parley::Affinity; use std::{ error::Error, ffi::{CStr, CString, c_char, c_void}, @@ -228,14 +228,6 @@ impl Canvas { self.page.viewport.origin = corner.map(|value| -value * self.scale * zoom); } - fn outline(&self, id: ExGuid) -> Option<&TextOutline> { - let editor = &self.page.editor; - editor - .visible_outlines() - .chain(editor.caret_outline()) - .find(|outline| outline.id == id) - } - fn target(&self, point: [f32; 2]) -> Target { let device = self.device(point); let [x, y] = self.page.viewport.document_point(device); @@ -254,10 +246,13 @@ impl Canvas { { Target::Grip } - Some(Hit::Text { id, point }) - if !self - .outline(id) - .is_some_and(|outline| outline.contains_extension(point)) => + Some(Hit::Text { id, .. }) + if self + .page + .text_block([x, y]) + .is_some_and(|[x0, y0, x1, y1]| { + (x0..=x1).contains(&x) && (y0..=y1).contains(&y) + }) => { if id == self.page.editor.active_outline().id { Target::ActiveText @@ -301,25 +296,11 @@ impl Canvas { Ok(changed) } - /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom. + /// The nearest displayed text outline, 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 - .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)) + self.page + .text_block(self.page.viewport.document_point(self.device(point))) + .map(|rect| rect.map(|value| value * POINT)) } fn active(&self) -> &TextOutline { @@ -562,14 +543,6 @@ impl Canvas { .map(|rect| self.document_rect(rect)) } - /// 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 [x, y] = self.active().origin(); - let [x0, y0, x1, y1] = [rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32); - self.document_rect([x0 + x, y0 + y, x1 + x, y1 + y]) - } - /// Page rectangle `[x0, y0, x1, y1]` in the view's points as `[x, y, width, height]`. fn document_rect(&self, [x0, y0, x1, y1]: [f32; 4]) -> [f32; 4] { let viewport = self.page.viewport; @@ -583,30 +556,33 @@ impl Canvas { fn caret_rect(&self, offset: u32) -> Result<[f32; 4]> { let position = self.active().utf16_position(offset)?; - let caret = self + let rect = self .page - .editor - .caret_at(position, Affinity::Downstream, 1.0)?; - Ok(self.view_rect(caret)) + .caret_at(self.active().id, position, Affinity::Downstream, 1.0)?; + Ok(self.document_rect([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32))) } - /// The selection rectangles of a range, one or more per line. fn range_rects(&self, range: [u32; 2]) -> Result> { - let rects = self.active().range_rects(Selection { - positions: self.positions(range)?, - affinities: [Affinity::Downstream, Affinity::Upstream], - })?; - Ok(rects.into_iter().map(|rect| self.view_rect(rect)).collect()) + let rects = self.page.range_rects( + self.active().id, + Selection { + positions: self.positions(range)?, + affinities: [Affinity::Downstream, Affinity::Upstream], + }, + )?; + Ok(rects + .into_iter() + .map(|rect| { + self.document_rect([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32)) + }) + .collect()) } fn closest(&self, point: [f32; 2]) -> Result { let point = self.page.viewport.document_point(self.device(point)); - let origin = self.active().origin(); - let selection = self.page.editor.selection_at( - point[0] - origin[0], - point[1] - origin[1], - SelectionUnit::Grapheme, - )?; + let selection = self + .page + .selection_at(self.active().id, point, SelectionUnit::Grapheme)?; Ok(self.active().utf16_offset(selection.positions[1])?) } } @@ -803,6 +779,44 @@ impl Presentation { } } +#[derive(Default)] +struct LocalEdits { + pending: usize, + failed: bool, +} + +/// Settles the original apply even when failed ingress drops its callback. +struct ApplyReply(Option>>); + +impl ApplyReply { + fn new(edits: &Arc>) -> Self { + if let Ok(mut edits) = edits.lock() { + edits.pending += 1; + } + Self(Some(Arc::clone(edits))) + } + + fn complete(mut self, result: std::result::Result) { + if let Some(edits) = self.0.take() + && let Ok(mut edits) = edits.lock() + { + edits.pending -= 1; + edits.failed |= result.is_err(); + } + } +} + +impl Drop for ApplyReply { + fn drop(&mut self) { + if let Some(edits) = self.0.take() + && let Ok(mut edits) = edits.lock() + { + edits.pending -= 1; + edits.failed = true; + } + } +} + pub struct View { /// Taken apart on drop, which returns its text engine to `ENGINES`. canvas: ManuallyDrop, @@ -811,8 +825,9 @@ pub struct View { section: Arc, /// A conflict page, which shows what is stored and takes no edits. read_only: bool, - /// The column, in page points, a reading view reflows the page into; it takes no edits. - reading: Option, + reload_pending: bool, + /// Original applies stay counted across an explicit discard. + local_edits: Arc>, } impl View { @@ -829,11 +844,17 @@ impl View { // SAFETY: the caller supplies the platform window's lifetime contract. let presentation = unsafe { Presentation::new(window, pixels) }?; let (mut page, read_only) = section.shared.page(space)?; + let local_edits = Arc::new(Mutex::new(LocalEdits::default())); // The page wears its theme: style objects it gives otherwise are restyled first. let sheet = section.sheet(page.identity); let restyle = onestore::op::restyle(&page, &sheet)?; if !read_only && !restyle.is_empty() { - section.shared.apply(page_edit(space, restyle))?; + let reply = ApplyReply::new(&local_edits); + section + .shared + .apply_with(page_edit(space, restyle), move |result| { + reply.complete(result) + })?; page = section.shared.page(space)?.0; } let frame = Arc::::default(); @@ -852,7 +873,8 @@ impl View { frame, section: Arc::clone(§ion.shared), read_only, - reading: None, + reload_pending: false, + local_edits, }) } @@ -901,10 +923,9 @@ impl View { /// conflict page returns to what is stored instead. fn stored(&mut self, result: Result) -> bool { let changed = report(result).unwrap_or(false); - match report(self.canvas.edit()) { - Some(Some(_)) if self.read_only || self.reading.is_some() => { - let _ = report(self.reload()); - } + self.canvas.page.reconcile_reading(); + let submitted = match report(self.canvas.edit()) { + Some(Some(_)) if self.read_only => report(self.reload(true)).is_some(), Some(Some(edit)) => { // Pictures that came or went, by insertion or undo, are drawn or dropped. if edit.ops.iter().any(|op| { @@ -922,53 +943,128 @@ impl View { let _ = report(refreshed.map_err(Into::into)); } } - let _ = report(self.section.apply(edit)); + self.canvas.page.reconcile_reading(); + let reply = ApplyReply::new(&self.local_edits); + report( + self.section + .apply_with(edit, move |result| reply.complete(result)), + ) + .is_some() + } + Some(None) => true, + None => { + if let Ok(mut edits) = self.local_edits.lock() { + edits.failed = true; + } + false + } + }; + let refreshed = + submitted && sb_view_reload_ready(self) && report(self.reload(false)).unwrap_or(false); + changed || refreshed + } + + fn anchored( + &mut self, + anchor: Option<&mut [f32; 2]>, + target_zoom: Option, + change: impl FnOnce(&mut Self) -> T, + ) -> T { + let Some(anchor) = anchor else { + return change(self); + }; + let point = (*anchor).map(|value| value / POINT); + let zoom = self.canvas.page.viewport.scale / (self.canvas.scale * POINT); + let target_zoom = target_zoom.unwrap_or(zoom); + let page = &self.canvas.page; + let source = point + .iter() + .all(|value| value.is_finite()) + .then(|| { + page.editor + .visible_outlines() + .chain(page.editor.caret_outline()) + .filter_map(|outline| { + let selection = page + .selection_at(outline.id, point, SelectionUnit::Grapheme) + .ok()?; + let position = selection.positions[1]; + let affinity = selection.affinities[1]; + let rect = page.caret_at(outline.id, position, affinity, 0.0).ok()?; + let dx = (rect.x0 - f64::from(point[0])).abs(); + let dy = (rect.y0 - f64::from(point[1])) + .max(f64::from(point[1]) - rect.y1) + .max(0.0); + Some(( + dx.hypot(dy), + outline, + position, + affinity, + [rect.x0, rect.y0], + )) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, outline, position, affinity, before)| { + ( + outline.id, + outline.document().clone(), + position, + affinity, + before, + ) + }) + }) + .flatten(); + let result = change(self); + if let Some((id, document, position, affinity, before)) = source { + let page = &self.canvas.page; + let outline = page + .editor + .visible_outlines() + .chain(page.editor.caret_outline()) + .find(|outline| outline.id == id) + .unwrap_or_else(|| page.editor.active_outline()); + let position = canvas::editor::follow(&document, outline.document(), position); + if let Ok(rect) = page.caret_at(outline.id, position, affinity, 0.0) { + for (axis, after) in [rect.x0, rect.y0].into_iter().enumerate() { + let relative = (before[axis] * f64::from(POINT) - f64::from(anchor[axis])) + * f64::from(zoom); + anchor[axis] = + (after * f64::from(POINT) - relative / f64::from(target_zoom)) as f32; + } } - _ => {} } - changed + result } - /// Shows the page as stored, in place: the caret, selection and drawn pictures stay. - /// Waits for marked text to end. - fn reload(&mut self) -> Result { - if self.canvas.page.editor.marked_range().is_some() { + /// Defers automatic replacement until marking ends and original local replies succeed. + fn reload(&mut self, discard: bool) -> Result { + self.reload_pending = true; + if self.canvas.page.editor.marked_range().is_some() + || (!discard && !sb_view_reload_ready(self)) + { return Ok(false); } - let (page, read_only) = self.section.page(self.canvas.space)?; - self.read_only = read_only; - if let Some(column) = self.reading { - return self.show(page, Some(column)); + // Reset before reading so an outstanding reply's failure cannot be cleared afterward. + let failed = discard + && self + .local_edits + .lock() + .is_ok_and(|mut edits| std::mem::replace(&mut edits.failed, false)); + let result = (|| { + let (page, read_only) = self.section.page(self.canvas.space)?; + self.read_only = read_only; + let response = self.canvas.page.refresh(page)?; + self.reload_pending = false; + Ok(moved(response)) + })(); + if result.is_err() + && failed + && let Ok(mut edits) = self.local_edits.lock() + { + edits.failed = true; } - Ok(moved(self.canvas.page.refresh(page)?)) - } - - /// Shows `page` anew, reflowed into `column` where given and the page reflows; whether - /// it shows reflowed. - fn show(&mut self, page: Page, column: Option) -> Result { - let mut engine = pooled_engine(); - let reflowed = match column { - Some(column) => canvas::reading::read(&page, column, &mut engine)?.page, - None => None, - }; - pool_engine(engine); - self.reading = column.filter(|_| reflowed.is_some()); - let pixels = self.canvas.page.viewport.size; - let mut canvas = Canvas::new( - self.canvas.space, - reflowed.unwrap_or(page), - pixels, - self.canvas.scale, - )?; - canvas.paper = self.canvas.paper; - canvas.page.spelling = self.canvas.page.spelling.take(); - let _ = canvas.page.focus_changed(false)?; - canvas.page.editor.styles = std::mem::take(&mut self.canvas.page.editor.styles); - // SAFETY: `self.canvas` is replaced at once and not used in between. - let old = unsafe { ManuallyDrop::take(&mut self.canvas) }; - self.canvas = ManuallyDrop::new(canvas); - pool_engine(old.page.engine); - Ok(self.reading.is_some()) + result } } @@ -1105,49 +1201,58 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) { view.canvas.set_dark(dark); } -/// A conflict page or a reading view: it shows what is stored, and any edit returns it there. +/// A conflict page shows what is stored, and any edit returns it there. #[unsafe(no_mangle)] pub extern "C" fn sb_view_read_only(view: &View) -> bool { - view.read_only || view.reading.is_some() + view.read_only } -/// Whether the page has a reading view `width` points wide, as JSON: `offered`, and the -/// `verdict` saying how it reflows or why not. +/// Reading eligibility and active projection as JSON: `offered`, `shown`, and `verdict`. #[unsafe(no_mangle)] pub extern "C" fn sb_view_reading(view: &View, width: f32) -> *mut c_char { - let result = view.section.page(view.canvas.space).and_then(|(page, _)| { - let mut engine = pooled_engine(); - let read = canvas::reading::read(&page, width / POINT, &mut engine); - pool_engine(engine); - Ok(read?.verdict) - }); - report(result).map_or(std::ptr::null_mut(), |verdict| { - let json = - serde_json::json!({ "offered": verdict.offered(), "verdict": format!("{verdict:?}") }); - owned(json.to_string()) + let (offered, shown) = view.canvas.page.reading_status(width / POINT); + let json = serde_json::json!({ "offered": offered, "shown": shown, "verdict": if offered { "Reflows" } else { "Refused(Unsupported)" } }); + owned(json.to_string()) +} + +/// Shows editable text reflowed into a column `width` points wide, or with 0 as laid +/// out; whether it now shows reflowed. Moves the page-point viewport `anchor` so its +/// nearest source caret keeps its relative host offset at scale 1; unchanged without text. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_set_reading(view: &mut View, width: f32, anchor: &mut [f32; 2]) -> bool { + view.anchored(Some(anchor), Some(1.0), |view| { + view.canvas + .page + .set_reading((width > 0.0).then_some(width / POINT)) }) } -/// Shows the page reflowed into a column `width` points wide, read-only, or with 0 as laid -/// out; whether it now shows reflowed. +/// Whether an existing deferred reload can read without replacing unconfirmed local edits. #[unsafe(no_mangle)] -pub extern "C" fn sb_view_set_reading(view: &mut View, width: f32) -> bool { - let column = (width > 0.0).then_some(width / POINT); - let shown = view - .section - .page(view.canvas.space) - .and_then(|(page, _)| view.show(page, column)); - report(shown).unwrap_or(false) +pub extern "C" fn sb_view_reload_ready(view: &View) -> bool { + view.reload_pending + && view.canvas.page.editor.marked_range().is_none() + && view + .local_edits + .lock() + .is_ok_and(|edits| edits.pending == 0 && !edits.failed) } /// Shows the page as stored after a change made elsewhere, keeping the caret; with /// `discard`, after the section refused an edit, drops what the page took since. +/// A supplied page-point anchor follows its source caret across the refresh. #[unsafe(no_mangle)] -pub extern "C" fn sb_view_reload(view: &mut View, discard: bool) -> bool { - if discard { - let _ = view.canvas.page.editor.take_ops(); - } - report(view.reload()).unwrap_or(false) +pub extern "C" fn sb_view_reload( + view: &mut View, + discard: bool, + anchor: Option<&mut [f32; 2]>, +) -> bool { + view.anchored(anchor, None, |view| { + if discard { + let _ = view.canvas.page.editor.take_ops(); + } + report(view.reload(discard)).unwrap_or(false) + }) } /// Whether the view has input focus: the focused outline shows its frame and grips only then. @@ -1212,7 +1317,11 @@ pub extern "C" fn sb_view_pressure(view: &mut View, pressure: f32) { #[unsafe(no_mangle)] pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool { - report(view.canvas.drag([x, y])).unwrap_or(false) + let result = view.canvas.drag([x, y]); + if view.canvas.page.inking() { + return report(result).unwrap_or(false); + } + view.stored(result) } #[unsafe(no_mangle)] @@ -1350,7 +1459,8 @@ pub extern "C" fn sb_view_title(view: &View) -> *mut c_char { /// Puts the caret at the end of the page title; false on a page without one. #[unsafe(no_mangle)] pub extern "C" fn sb_view_focus_title(view: &mut View) -> bool { - report(view.canvas.focus_title()).unwrap_or(false) + let result = view.canvas.focus_title(); + view.stored(result) } /// Selects the first place `query` occurs on the page, ignoring case. @@ -1359,7 +1469,8 @@ pub extern "C" fn sb_view_focus_title(view: &mut View) -> bool { /// `query` is NUL-terminated UTF-8. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_view_find(view: &mut View, query: *const c_char) -> bool { - report(view.canvas.find(&string(query))).unwrap_or(false) + let result = view.canvas.find(&string(query)); + view.stored(result) } /// Selects paragraph `id` of the page; false where the page has none. @@ -1372,7 +1483,7 @@ pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_ .parse() .map_err(Into::into) .and_then(|id| view.canvas.select_paragraph(id)); - report(result).unwrap_or(false) + view.stored(result) } /// Copy, or Cut with `cut`: the selected text for the clipboard, a page selection's @@ -1664,20 +1775,24 @@ pub extern "C" fn sb_selection(view: &View, range: &mut [u32; 2]) { #[unsafe(no_mangle)] pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool { - report(view.canvas.select([start, end])).is_some() + let result = view.canvas.select([start, end]); + let success = result.is_ok(); + view.stored(result.map(|()| false)); + success } /// Widens the selection as OneNote's Ctrl+A does, one enclosing unit per call. #[unsafe(no_mangle)] pub extern "C" fn sb_select_more(view: &mut View) -> bool { - report( - view.canvas - .page - .editor - .widen_selection() - .map_err(Into::into), - ) - .is_some() + let result = view + .canvas + .page + .editor + .widen_selection() + .map_err(Into::into); + let success = result.is_ok(); + view.stored(result.map(|()| false)); + success } #[unsafe(no_mangle)] diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index a5e578218f4f8d258ad6d83131cc8394234e935f..b8b1a6ce169bfeceeffc07e81ba90d93ce9d4e63 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -810,8 +810,20 @@ impl Shared { } pub fn apply(&self, edit: Edit) -> Result<()> { - self.section.apply(&self.author(), edit)?; + self.apply_with(edit, |_| {}) + } + + pub fn apply_with( + &self, + edit: Edit, + reply: impl FnOnce(std::result::Result) + Send + 'static, + ) -> Result<()> { self.status.store(Status::Saving as u8, Ordering::Relaxed); + if let Err(error) = self.section.apply_with(&self.author(), edit, reply) { + self.status + .store(Status::NotSaving as u8, Ordering::Relaxed); + return Err(error.into()); + } Ok(()) } diff --git a/crates/mobile/src/recording.rs b/crates/mobile/src/recording.rs index 605180f002c014588f0e1eac1ae8388a5ccff9e1..1f21c1aa18def7eda07c86baecbea4fe3a2f3f68 100644 --- a/crates/mobile/src/recording.rs +++ b/crates/mobile/src/recording.rs @@ -117,7 +117,7 @@ pub unsafe extern "C" fn sb_view_start_recording( date: *const c_char, time: *const c_char, ) -> bool { - if view.read_only || view.reading.is_some() { + if view.read_only { return false; } let started = view diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 6d88eff11839e4452a320081f4643c7cd4d7213f..98db26cbe323b927d58576b880bbf7c38a10ba80 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -1748,7 +1748,8 @@ fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() { frame: Arc::default(), section: Arc::clone(§ion.shared), read_only, - reading: None, + reload_pending: false, + local_edits: Arc::new(Mutex::new(LocalEdits::default())), }; assert!(sb_view_focus_title(&mut view)); assert!(unsafe { sb_insert(&mut view, c" retained after surface loss".as_ptr()) }); @@ -2364,3 +2365,979 @@ fn hosted_credentials_reject_unknown_versions_without_echoing_secret_input() { } assert!(!Path::new(cache.to_str().unwrap()).exists()); } + +#[test] +fn reader_native_selection_commits_marked_ops_and_keeps_the_original_offline_editor() { + let (directory, section) = features(); + sb_library_set_offline(§ion.library, true); + let file = directory.path().join("Features.one"); + let canonical = std::fs::read(&file).unwrap(); + let space = section.new_page(None, "Mon", "6:14 AM").unwrap(); + let (page, read_only) = section.shared.page(space).unwrap(); + let mut view = View { + canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()), + presentation: None, + frame: Arc::default(), + section: Arc::clone(§ion.shared), + read_only, + reload_pending: false, + local_edits: Arc::new(Mutex::new(LocalEdits::default())), + }; + view.canvas.set_transform(1.0, [0.0; 2]); + let reading = |view: &View| { + let query = sb_view_reading(view, 240.0); + let status = serde_json::from_str::(&string(query)).unwrap(); + unsafe { sb_string_free(query) }; + status + }; + assert_eq!(reading(&view)["offered"], true); + assert_eq!(reading(&view)["shown"], false); + assert!(sb_view_focus_title(&mut view)); + assert!(unsafe { sb_insert(&mut view, c"A title that wraps on a narrow phone".as_ptr()) }); + let title = view.canvas.active().id; + view.canvas.set_transform(1.5, [0.0; 2]); + let before = view + .canvas + .page + .caret_at( + title, + TextPosition { + paragraph: 0, + offset: 0, + }, + Affinity::Downstream, + 0.0, + ) + .unwrap(); + let mut anchor = [ + before.x0 as f32 * POINT - 4.0, + before.y0 as f32 * POINT - 7.0, + ]; + assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor)); + let after = view + .canvas + .page + .caret_at( + title, + TextPosition { + paragraph: 0, + offset: 0, + }, + Affinity::Downstream, + 0.0, + ) + .unwrap(); + for (axis, caret) in [after.x0, after.y0].into_iter().enumerate() { + assert!( + (caret * f64::from(POINT) - f64::from(anchor[axis]) - [4.0, 7.0][axis] * 1.5).abs() + < 0.001 + ); + } + view.canvas.set_transform(1.0, [0.0; 2]); + assert!(!sb_view_read_only(&view)); + assert_eq!(reading(&view)["offered"], true); + assert_eq!(reading(&view)["shown"], true); + assert!(unsafe { sb_insert(&mut view, c"\n".as_ptr()) }); + assert!(unsafe { + sb_insert( + &mut view, + c"Body ๐Ÿ˜€ with several wrapped words before the last line".as_ptr(), + ) + }); + let body = view.canvas.active().id; + let original = view.canvas.page.editor.page().unwrap(); + let history = view.canvas.page.editor.history_depth(); + let mut anchored_moved = false; + for (source, offset) in [(title, 0), (body, 35)] { + for width in [320.0, 240.0] { + let before = view + .canvas + .page + .caret_at( + source, + TextPosition { + paragraph: 0, + offset, + }, + Affinity::Downstream, + 0.0, + ) + .unwrap(); + anchor = [ + before.x0 as f32 * POINT, + (before.y0 + (before.y1 - before.y0) / 2.0) as f32 * POINT, + ]; + let old_anchor = anchor; + assert!(sb_view_set_reading(&mut view, width, &mut anchor)); + let after = view + .canvas + .page + .caret_at( + source, + TextPosition { + paragraph: 0, + offset, + }, + Affinity::Downstream, + 0.0, + ) + .unwrap(); + assert!( + (f64::from(anchor[1] - old_anchor[1]) - (after.y0 - before.y0) * f64::from(POINT)) + .abs() + < 0.001 + ); + eprintln!( + "Reader anchor source={source} utf16={offset} width={width} before=({},{}) after=({},{}) anchor_delta_y={}", + before.x0, + before.y0, + after.x0, + after.y0, + anchor[1] - old_anchor[1] + ); + anchored_moved |= (after.y0 - before.y0).abs() > 0.1; + for offset in [0, 8, 24] { + let mut rect = [0.0; 4]; + assert!(sb_caret_rect(&view, offset, &mut rect)); + assert_eq!( + view.canvas + .closest([rect[0], rect[1] + rect[3] / 2.0]) + .unwrap(), + offset + ); + assert_eq!( + view.canvas.target([rect[0] + 1.0, rect[1] + rect[3] / 2.0]), + Target::ActiveText + ); + } + assert!( + view.canvas + .range_rects([0, view.canvas.length()]) + .unwrap() + .len() + > 1 + ); + assert_eq!(view.canvas.page.editor.page().unwrap(), original); + assert_eq!(view.canvas.page.editor.history_depth(), history); + assert_eq!(view.canvas.active().id, body); + } + } + assert!(anchored_moved); + assert!(sb_select(&mut view, 0, 0)); + assert!(unsafe { sb_set_marked(&mut view, c"marked ๐Ÿ˜€ ".as_ptr(), 10, 10) }); + let mut marked = [0; 2]; + assert!(sb_marked(&view, &mut marked)); + let preview = view.canvas.page.editor.page().unwrap(); + let selection = view.canvas.page.editor.selection(); + let history = view.canvas.page.editor.history_depth(); + for width in [320.0, 0.0, 240.0] { + assert_eq!( + sb_view_set_reading(&mut view, width, &mut anchor), + width > 0.0 + ); + let mut unchanged = [0; 2]; + assert!(sb_marked(&view, &mut unchanged)); + assert_eq!(unchanged, marked); + assert_eq!(view.canvas.page.editor.selection(), selection); + assert_eq!(view.canvas.page.editor.history_depth(), history); + assert_eq!(view.canvas.page.editor.page().unwrap(), preview); + assert_eq!(view.canvas.active().id, body); + assert!(!sb_view_reload(&mut view, false, Some(&mut anchor))); + assert!(sb_marked(&view, &mut unchanged)); + assert_eq!(unchanged, marked); + assert_eq!(view.canvas.page.editor.selection(), selection); + assert_eq!(view.canvas.page.editor.history_depth(), history); + assert_eq!(view.canvas.page.editor.page().unwrap(), preview); + assert!(view.canvas.page.editor.take_ops().unwrap().is_empty()); + } + assert!(sb_select(&mut view, 0, 0)); + assert!(!sb_marked(&view, &mut marked)); + assert!(view.canvas.page.editor.take_ops().unwrap().is_empty()); + let stored = section.shared.page(space).unwrap().0; + let mut engine = pooled_engine(); + let stored = canvas::editor::CanvasEditor::from_page(stored, &mut engine).unwrap(); + pool_engine(engine); + assert!( + stored + .outlines() + .iter() + .any(|outline| outline.id == body && outline.shown_text().contains("marked ๐Ÿ˜€")) + ); + assert!(sb_view_undo(&mut view, false)); + assert!(!view.canvas.active().shown_text().contains("marked ๐Ÿ˜€")); + assert!(sb_view_undo(&mut view, true)); + assert!(view.canvas.active().shown_text().contains("marked ๐Ÿ˜€")); + for route in 0..6 { + assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) }); + assert!(sb_select(&mut view, 0, 0)); + let paragraph = CString::new( + view.canvas + .active() + .document() + .text_nodes() + .next() + .unwrap() + .id + .to_string(), + ) + .unwrap(); + if route == 5 { + let mut rect = [0.0; 4]; + assert!(sb_caret_rect(&view, 0, &mut rect)); + assert!(sb_view_press(&mut view, rect[0], rect[1] + rect[3] / 2.0)); + } + let text = format!("route{route} ๐Ÿ˜€ "); + let marked_text = CString::new(text.clone()).unwrap(); + let end = u32::try_from(text.encode_utf16().count()).unwrap(); + assert!(unsafe { sb_set_marked(&mut view, marked_text.as_ptr(), end, end) }); + assert!(sb_marked(&view, &mut marked)); + match route { + 0 => assert!(sb_view_focus_title(&mut view)), + 1 => assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) }), + 2 => assert!(unsafe { sb_view_select_paragraph(&mut view, paragraph.as_ptr()) }), + 3 => assert!(sb_select_more(&mut view)), + 4 => sb_unmark(&mut view), + _ => { + let mut rect = [0.0; 4]; + assert!(sb_caret_rect(&view, end, &mut rect)); + assert!(sb_view_drag(&mut view, rect[0], rect[1] + rect[3] / 2.0)); + sb_view_release(&mut view); + } + } + assert!(!sb_marked(&view, &mut marked)); + assert!(view.canvas.page.editor.take_ops().unwrap().is_empty()); + let mut rect = [0.0; 4]; + assert!(sb_caret_rect(&view, 0, &mut rect)); + let mut engine = pooled_engine(); + let stored = canvas::editor::CanvasEditor::from_page( + section.shared.page(space).unwrap().0, + &mut engine, + ) + .unwrap(); + pool_engine(engine); + assert!( + stored + .outlines() + .iter() + .any(|outline| outline.id == body && outline.shown_text().contains(text.trim())) + ); + assert_eq!(reading(&view)["shown"], true); + } + let title_text = view + .canvas + .page + .editor + .visible_outlines() + .find(|outline| outline.title) + .unwrap() + .document() + .text_nodes() + .next() + .unwrap() + .text() + .unwrap() + .id; + for shown in [true, false] { + for select in [true, false] { + assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) }); + assert!(sb_select(&mut view, 0, 0)); + assert_eq!( + sb_view_set_reading(&mut view, if shown { 240.0 } else { 0.0 }, &mut anchor), + shown + ); + if sb_view_format(&view) & 1 == 0 { + assert!(sb_view_apply(&mut view, 0)); + } + assert_ne!(sb_view_format(&view) & 1, 0); + let local_before = view.canvas.active().shown_text(); + let text = view + .canvas + .active() + .document() + .text_nodes() + .next() + .unwrap() + .text() + .unwrap() + .id; + let history = view.canvas.page.editor.history_depth(); + let local = format!("retained {shown} {select} ๐Ÿ˜€ "); + let local_text = CString::new(local.clone()).unwrap(); + let end = u32::try_from(local.encode_utf16().count()).unwrap(); + assert!(unsafe { sb_set_marked(&mut view, local_text.as_ptr(), end, end) }); + let preview = view.canvas.page.editor.page().unwrap(); + assert_eq!( + view.canvas.active().shown_text(), + format!("{local}{local_before}") + ); + let local_nodes = view.canvas.active().document().nodes().to_vec(); + let selection = view.canvas.page.editor.selection(); + let peer = format!("peer {shown} {select} "); + section + .shared + .section + .apply( + "Reader peer", + page_edit( + space, + vec![PageOp::Text { + text: title_text, + range: 0..0, + with: peer.clone(), + }], + ), + ) + .unwrap(); + section.shared.section.written().unwrap(); + let pending_before = section.shared.section.pending().unwrap(); + let mut predicted = section.shared.page(space).unwrap().0; + assert!(!sb_view_reload(&mut view, false, Some(&mut anchor))); + assert!(view.reload_pending); + assert!(sb_marked(&view, &mut marked)); + assert_eq!(view.canvas.page.editor.page().unwrap(), preview); + assert_eq!(view.canvas.page.editor.selection(), selection); + assert_eq!(view.canvas.page.editor.history_depth(), history); + if select { + assert!(sb_select(&mut view, 0, 0)); + } else { + sb_unmark(&mut view); + } + assert!(!sb_marked(&view, &mut marked)); + let deadline = Instant::now() + Duration::from_secs(10); + while view.reload_pending && !sb_view_reload_ready(&view) { + let _ = section.poll(); + assert!(Instant::now() < deadline, "Original apply did not complete"); + std::thread::sleep(Duration::from_millis(10)); + } + if sb_view_reload_ready(&view) { + sb_view_reload(&mut view, false, Some(&mut anchor)); + } + assert!(!view.reload_pending); + assert_eq!(reading(&view)["shown"], shown); + assert_eq!(view.canvas.active().id, body); + assert!(view.canvas.page.editor.history_depth()[0] <= history[0] + 1); + let expected_selection = if select { + Selection::from( + [TextPosition { + paragraph: 0, + offset: 0, + }; 2], + ) + } else { + selection + }; + assert_eq!(view.canvas.page.editor.selection(), expected_selection); + assert!( + view.canvas.page.editor.visible_outlines().any(|outline| { + outline.id == title && outline.shown_text().starts_with(&peer) + }) + ); + section.shared.section.written().unwrap(); + let pending = section.shared.section.pending().unwrap(); + assert_eq!(pending.len(), pending_before.len() + 1); + assert_eq!(&pending[..pending_before.len()], pending_before.as_slice()); + let row = pending.last().unwrap(); + assert_eq!(row.author, "Clover Test"); + let edit = &row.edit; + assert!(edit.ops.iter().any(|op| matches!(op, + Op::Page { space: target, op: PageOp::Text { text: target_text, .. } } + if *target == space && *target_text == text + ))); + for op in &edit.ops { + let Op::Page { space: target, op } = op else { + panic!("Composition changed the section") + }; + assert_eq!(*target, space); + onestore::op::predict(&mut predicted, op).unwrap(); + } + let mut normalized = preview.clone(); + for node in &local_nodes { + let text = node.text().unwrap(); + onestore::op::predict( + &mut normalized, + &PageOp::Format { + text: text.id, + range: 0..text.text.utf16_offset(text.text.text().len()).unwrap(), + set: Vec::new(), + clear: Vec::new(), + }, + ) + .unwrap(); + } + let [stored_body, marked_body] = [&predicted, &normalized].map(|page| { + page.objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Outline(outline) if outline.id == body => { + Some(&outline.paragraphs) + } + _ => None, + }) + .unwrap() + }); + assert_eq!(stored_body, marked_body); + assert_eq!(section.shared.page(space).unwrap().0, predicted); + assert_eq!(view.canvas.active().document().nodes(), local_nodes); + assert!(view.canvas.page.editor.take_ops().unwrap().is_empty()); + assert!(sb_view_undo(&mut view, false)); + assert_eq!(view.canvas.active().shown_text(), local_before); + assert!( + view.canvas.page.editor.visible_outlines().any(|outline| { + outline.id == title && outline.shown_text().starts_with(&peer) + }) + ); + assert!(sb_view_undo(&mut view, true)); + assert_eq!( + view.canvas.active().shown_text(), + format!("{local}{local_before}") + ); + assert_eq!(reading(&view)["shown"], shown); + } + } + assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor)); + let before = view + .canvas + .page + .caret_at( + body, + TextPosition { + paragraph: 0, + offset: 8, + }, + Affinity::Downstream, + 0.0, + ) + .unwrap(); + anchor = [ + before.x0 as f32 * POINT, + (before.y0 + (before.y1 - before.y0) / 2.0) as f32 * POINT, + ]; + let old_anchor = anchor; + let selection = view.canvas.page.editor.selection(); + let history = view.canvas.page.editor.history_depth(); + section + .shared + .section + .apply( + "Reader peer", + page_edit( + space, + vec![PageOp::Text { + text: title_text, + range: 0..0, + with: "Peer title text before the original title ".into(), + }], + ), + ) + .unwrap(); + section.shared.section.written().unwrap(); + let pending_before_reload = section.shared.section.pending().unwrap(); + assert!(sb_view_reload(&mut view, false, Some(&mut anchor))); + let after = view + .canvas + .page + .caret_at( + body, + TextPosition { + paragraph: 0, + offset: 8, + }, + Affinity::Downstream, + 0.0, + ) + .unwrap(); + assert!((after.y0 - before.y0).abs() > 0.1); + assert!( + (f64::from(anchor[1] - old_anchor[1]) - (after.y0 - before.y0) * f64::from(POINT)).abs() + < 0.001 + ); + assert_eq!(view.canvas.page.editor.selection(), selection); + assert!(view.canvas.page.editor.history_depth()[0] <= history[0]); + assert_eq!(view.canvas.active().id, body); + assert_eq!(reading(&view)["shown"], true); + assert_eq!( + section.shared.section.pending().unwrap(), + pending_before_reload + ); + assert!(view.canvas.page.editor.take_ops().unwrap().is_empty()); + let page = view.canvas.page.editor.page().unwrap(); + let created = page.created.unwrap(); + let fields: Vec<_> = view + .canvas + .page + .editor + .date() + .unwrap() + .source() + .paragraphs + .iter() + .map(|node| { + let text = node.text().unwrap(); + (text.id, text.text.text().to_owned()) + }) + .collect(); + for overwide in [true, false] { + let mut next = fields.clone(); + if overwide { + next[0].1 = "W".repeat(80); + } + section + .shared + .section + .apply( + "Reader peer", + page_edit( + space, + vec![PageOp::Date { + created, + fields: next, + }], + ), + ) + .unwrap(); + section.shared.section.written().unwrap(); + let pending_before_reload = section.shared.section.pending().unwrap(); + let history = view.canvas.page.editor.history_depth(); + assert!(sb_view_reload(&mut view, false, Some(&mut anchor))); + assert_eq!(reading(&view)["shown"], false); + assert_eq!(reading(&view)["offered"], !overwide); + assert_eq!(view.canvas.page.editor.selection(), selection); + assert_eq!(view.canvas.page.editor.history_depth(), history); + assert_eq!(view.canvas.active().id, body); + assert_eq!( + section.shared.section.pending().unwrap(), + pending_before_reload + ); + assert!(view.canvas.page.editor.take_ops().unwrap().is_empty()); + } + assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor)); + assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) }); + assert!(!sb_view_set_reading(&mut view, 0.0, &mut anchor)); + assert_eq!(reading(&view)["shown"], false); + assert_eq!(view.canvas.active().id, body); + assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor)); + sb_view_set_tool(&mut view, 1, 0, 0); + assert_eq!(reading(&view)["shown"], false); + assert_eq!(reading(&view)["offered"], true); + sb_view_set_tool(&mut view, 0, 0, 0); + assert_eq!(reading(&view)["shown"], false); + assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor)); + section.shared.section.written().unwrap(); + let mut predicted = section.shared.page(space).unwrap().0; + let pending_before = section.shared.section.pending().unwrap(); + let result = view.canvas.page.insert_table(2, 2).map(moved); + assert!(view.stored(result)); + assert_eq!(reading(&view)["shown"], false); + assert_eq!(reading(&view)["offered"], false); + assert!(!sb_view_read_only(&view)); + assert!(view.canvas.page.editor.take_ops().unwrap().is_empty()); + section.shared.section.written().unwrap(); + let pending = section.shared.section.pending().unwrap(); + assert_eq!(pending.len(), pending_before.len() + 1); + assert_eq!(&pending[..pending_before.len()], pending_before.as_slice()); + let table = view + .canvas + .active() + .document() + .nodes() + .iter() + .find(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_))) + .unwrap(); + let edit = &pending.last().unwrap().edit; + assert!(edit.ops.iter().any(|op| matches!(op, Op::Page { space: target, op: PageOp::Insert { container, paragraphs, .. } } + if *target == space && *container == body && paragraphs.iter().any(|node| node.id == table.id)))); + let mut normalized = predicted.clone(); + let definitions = &view.canvas.page.editor.page().unwrap().definitions; + for op in + onestore::op::lower(body, &[], std::slice::from_ref(table), None, definitions).unwrap() + { + onestore::op::predict(&mut normalized, &op).unwrap(); + } + for op in &edit.ops { + let Op::Page { space: target, op } = op else { + panic!("Table insertion changed the section") + }; + assert_eq!(*target, space); + onestore::op::predict(&mut predicted, op).unwrap(); + } + let tables = [&normalized, &predicted].map(|page| { + page.objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Outline(outline) if outline.id == body => { + outline.paragraphs.iter().find(|node| node.id == table.id) + } + _ => None, + }) + .unwrap() + }); + assert_eq!(tables[0], tables[1]); + assert_eq!(section.shared.page(space).unwrap().0, predicted); + assert!(pending.iter().all(|edit| { + section.shared.section.status(edit.id).unwrap() == Some(notebook::EditStatus::Pending) + })); + assert_eq!(std::fs::read(file).unwrap(), canonical); +} + +#[test] +fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() { + use std::sync::mpsc; + + let (directory, file) = copy("media-edit/candidate/Features.one"); + let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap()); + sb_library_set_offline(&library, true); + let section = open(&library, file.to_str().unwrap()); + let space = space(§ion, "Paragraph controls"); + let (page, read_only) = section.shared.page(space).unwrap(); + let mut view = View { + canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()), + presentation: None, + frame: Arc::default(), + section: Arc::clone(§ion.shared), + read_only, + reload_pending: true, + local_edits: Arc::new(Mutex::new(LocalEdits::default())), + }; + focus(&mut view.canvas, "Collapsed parent"); + assert!(sb_view_reload_ready(&view)); + let before = section.shared.section.pending().unwrap(); + let (started, start) = mpsc::channel(); + let (release, released) = mpsc::channel(); + assert!(view.canvas.insert("first ๐Ÿฆ€ ".into()).unwrap()); + let first = view.canvas.edit().unwrap().unwrap(); + let expected_first = first.clone(); + let first_reply = ApplyReply::new(&view.local_edits); + section + .shared + .apply_with(first, move |result| { + started.send(()).unwrap(); + released.recv_timeout(Duration::from_secs(10)).unwrap(); + first_reply.complete(result); + }) + .unwrap(); + start.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(!sb_view_reload_ready(&view)); + assert!(view.canvas.insert("second ๐Ÿ˜€ ".into()).unwrap()); + let second = view.canvas.edit().unwrap().unwrap(); + let expected_second = second.clone(); + let second_reply = ApplyReply::new(&view.local_edits); + let (started, start) = mpsc::channel(); + let (release_second, released_second) = mpsc::channel(); + section + .shared + .apply_with(second, move |result| { + started.send(()).unwrap(); + released_second + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + second_reply.complete(result); + }) + .unwrap(); + assert_eq!(view.local_edits.lock().unwrap().pending, 2); + release.send(()).unwrap(); + start.recv_timeout(Duration::from_secs(10)).unwrap(); + assert_eq!(view.local_edits.lock().unwrap().pending, 1); + assert!(!sb_view_reload_ready(&view)); + release_second.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !sb_view_reload_ready(&view) { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(10)); + } + sb_view_reload(&mut view, false, None); + assert!(!view.reload_pending); + assert!(!sb_view_reload_ready(&view)); + let pending = section.shared.section.pending().unwrap(); + assert_eq!(pending.len(), before.len() + 2); + assert_eq!(&pending[..before.len()], before.as_slice()); + assert_eq!(pending[before.len()].edit, expected_first); + assert_eq!(pending[before.len() + 1].edit, expected_second); + assert!(pending[before.len()].id < pending[before.len() + 1].id); + assert!(view.canvas.active().shown_text().contains("first ๐Ÿฆ€")); + assert!(view.canvas.active().shown_text().contains("second ๐Ÿ˜€")); + + let (started, start) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let (finished, finish) = mpsc::channel(); + assert!(view.canvas.insert("late reply ".into()).unwrap()); + let third = view.canvas.edit().unwrap().unwrap(); + let third_reply = ApplyReply::new(&view.local_edits); + let weak = Arc::downgrade(&view.local_edits); + section + .shared + .apply_with(third, move |result| { + started.send(()).unwrap(); + released.recv_timeout(Duration::from_secs(10)).unwrap(); + third_reply.complete(result); + finished.send(()).unwrap(); + }) + .unwrap(); + start.recv_timeout(Duration::from_secs(10)).unwrap(); + drop(view); + assert!(weak.upgrade().is_some()); + release.send(()).unwrap(); + finish.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(weak.upgrade().is_none()); + section.shared.section.written().unwrap(); + assert_eq!( + section.shared.section.pending().unwrap().len(), + before.len() + 3 + ); + drop((section, directory)); +} + +#[test] +fn reader_dropped_and_synchronous_failed_apply_callbacks_never_release_the_reload_fence() { + for synchronous in [false, true] { + let edits = Arc::new(Mutex::new(LocalEdits::default())); + let reply = ApplyReply::new(&edits); + let callback: Box)> = + Box::new(move |result| reply.complete(result)); + if synchronous { + callback(Err( + std::io::Error::other("Disposable ingress failure").into() + )); + } else { + drop(callback); + } + assert_eq!(edits.lock().unwrap().pending, 0); + assert!(edits.lock().unwrap().failed); + } +} + +#[test] +fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_failure() { + for select in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("Reader fault.one"); + let cache = directory.path().join("cache"); + let original = + onestore::create_section("Reader fault.one", "Original body", "Fixture").unwrap(); + std::fs::write(&file, &original).unwrap(); + let library = Arc::new(Library::open(&file, &cache, true).unwrap()); + sb_library_set_offline(&library, true); + let section = Section::open( + Arc::clone(&library), + file.to_str().unwrap(), + "Fixture".into(), + || {}, + ) + .unwrap(); + let identity = section.shared.section.identity().unwrap(); + let space = section.shared.section.pages().unwrap()[0].0; + let (page, read_only) = section.shared.page(space).unwrap(); + let mut view = View { + canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()), + presentation: None, + frame: Arc::default(), + section: Arc::clone(§ion.shared), + read_only, + reload_pending: false, + local_edits: Arc::new(Mutex::new(LocalEdits::default())), + }; + focus(&mut view.canvas, "Original body"); + assert!(sb_select(&mut view, 0, 0)); + if sb_view_format(&view) & 1 == 0 { + assert!(sb_view_apply(&mut view, 0)); + } + assert!(unsafe { sb_insert(&mut view, c"SEED ".as_ptr()) }); + section.shared.section.written().unwrap(); + assert_eq!(view.local_edits.lock().unwrap().pending, 0); + assert!(!view.local_edits.lock().unwrap().failed); + let seed = section.shared.section.pending().unwrap(); + assert_eq!(seed.len(), 1); + let location = notebook::location::local(&file).unwrap(); + let root = onestore::RevisionIndex::parse(&onestore::Store::parse(&original).unwrap()) + .unwrap() + .root + .guid; + let database = notebook::location::folder(&cache, &location).join(format!( + "{}.sqlite", + root.iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + )); + assert!(database.is_file()); + let shared = Arc::clone(§ion.shared); + drop((view, section, library)); + Arc::into_inner(shared) + .expect("No other seed writer") + .section + .close() + .unwrap(); + let sql = rusqlite::Connection::open(&database).unwrap(); + sql.busy_timeout(Duration::from_secs(1)).unwrap(); + sql.execute_batch( + "CREATE TRIGGER reader_fault BEFORE INSERT ON edits WHEN NEW.author = 'Fixture' + BEGIN SELECT RAISE(ABORT, 'Disposable Reader write failure'); END;", + ) + .unwrap(); + sql.close().unwrap(); + + let library = Arc::new(Library::open(&file, &cache, true).unwrap()); + sb_library_set_offline(&library, true); + let section = Section::open( + Arc::clone(&library), + file.to_str().unwrap(), + "Fixture".into(), + || {}, + ) + .unwrap(); + assert_eq!(section.shared.section.identity().unwrap(), identity); + assert_eq!(section.shared.section.pending().unwrap(), seed); + let (page, read_only) = section.shared.page(space).unwrap(); + let mut view = View { + canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()), + presentation: None, + frame: Arc::default(), + section: Arc::clone(§ion.shared), + read_only, + reload_pending: false, + local_edits: Arc::new(Mutex::new(LocalEdits::default())), + }; + focus(&mut view.canvas, "Original body"); + assert!(sb_select(&mut view, 0, 0)); + let mut anchor = [0.0; 2]; + assert_eq!( + sb_view_set_reading(&mut view, if select { 0.0 } else { 240.0 }, &mut anchor), + !select + ); + assert_ne!(sb_view_format(&view) & 1, 0); + assert!(unsafe { sb_set_marked(&mut view, c"FAILED rich ๐Ÿ˜€ ".as_ptr(), 15, 15) }); + let nodes = view.canvas.active().document().nodes().to_vec(); + let preview = view.canvas.page.editor.page().unwrap(); + section + .shared + .section + .apply( + "Reader peer", + page_edit(space, vec![PageOp::Color(Some(0x0011_2233))]), + ) + .unwrap(); + section.shared.section.written().unwrap(); + let before = section.shared.section.pending().unwrap(); + assert_eq!(before.len(), seed.len() + 1); + assert_eq!(&before[..seed.len()], seed.as_slice()); + assert!(!sb_view_reload(&mut view, false, Some(&mut anchor))); + assert!(view.reload_pending); + assert!(!sb_view_reload_ready(&view)); + if select { + assert!(sb_select(&mut view, 0, 0)); + } else { + sb_unmark(&mut view); + } + let history = view.canvas.page.editor.history_depth(); + let selection = view.canvas.page.editor.selection(); + let deadline = Instant::now() + Duration::from_secs(10); + let mut flags = 0; + while section.shared.status.load(Ordering::Relaxed) != library::Status::NotSaving as u8 { + flags |= section.poll(); + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(10)); + } + flags |= section.poll(); + assert_eq!(flags & 4, 0); + assert!(view.local_edits.lock().unwrap().failed); + assert_eq!(view.canvas.active().document().nodes(), nodes); + assert_eq!(view.canvas.page.editor.page().unwrap(), preview); + assert!(view.canvas.active().shown_text().contains("FAILED rich ๐Ÿ˜€")); + assert!(view.canvas.edit().unwrap().is_none()); + section.shared.section.written().unwrap(); + assert_eq!(section.shared.section.pending().unwrap(), before); + assert!(!sb_view_reload(&mut view, false, Some(&mut anchor))); + assert!(!sb_view_reload_ready(&view)); + assert!(view.reload_pending); + assert_eq!(view.canvas.page.editor.page().unwrap(), preview); + assert_eq!(view.canvas.page.editor.history_depth(), history); + assert_eq!(view.canvas.page.editor.selection(), selection); + assert_eq!(std::fs::read(&file).unwrap(), original); + + section + .shared + .section + .apply( + "Reader peer", + page_edit(space, vec![PageOp::Color(Some(0x0044_5566))]), + ) + .unwrap(); + section.shared.section.written().unwrap(); + let _ = section.poll(); + assert!(!sb_view_reload_ready(&view)); + assert!(!sb_view_reload(&mut view, false, None)); + assert_eq!(view.canvas.page.editor.page().unwrap(), preview); + unsafe { library::sb_section_set_author(§ion, c"Reader peer".as_ptr()) }; + assert!(sb_view_set_paper(&mut view, 0x77, 0x88, 0x99, -1)); + section.shared.section.written().unwrap(); + let successful_later = section.shared.section.pending().unwrap(); + assert_eq!(successful_later.len(), before.len() + 2); + assert_eq!(successful_later.last().unwrap().author, "Reader peer"); + assert!(view.local_edits.lock().unwrap().failed); + assert!(!sb_view_reload_ready(&view)); + assert!(view.canvas.active().shown_text().contains("FAILED rich ๐Ÿ˜€")); + let outstanding = ApplyReply::new(&view.local_edits); + let (returned, returned_reply) = std::sync::mpsc::channel(); + section + .shared + .section + .apply_with( + "Fixture", + page_edit(space, vec![PageOp::Color(Some(0x0010_2030))]), + move |result| returned.send((outstanding, result)).unwrap(), + ) + .unwrap(); + let (outstanding, result) = returned_reply + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + assert!(result.is_err()); + assert_eq!(section.shared.section.pending().unwrap(), successful_later); + sb_view_reload(&mut view, true, None); + assert!(!view.canvas.active().shown_text().contains("FAILED rich ๐Ÿ˜€")); + assert_eq!(view.local_edits.lock().unwrap().pending, 1); + assert!(!view.local_edits.lock().unwrap().failed); + sb_view_reload(&mut view, false, None); + assert!(!sb_view_reload_ready(&view)); + outstanding.complete(result); + assert!(view.local_edits.lock().unwrap().failed); + assert!(!sb_view_reload_ready(&view)); + sb_view_reload(&mut view, true, None); + assert!(!view.local_edits.lock().unwrap().failed); + assert_eq!(view.local_edits.lock().unwrap().pending, 0); + assert!(!view.reload_pending); + let _ = section.poll(); + assert!(sb_view_set_paper(&mut view, 0x11, 0x22, 0x33, -1)); + section.shared.section.written().unwrap(); + section + .shared + .section + .apply( + "Reader peer", + page_edit(space, vec![PageOp::Color(Some(0x0012_3456))]), + ) + .unwrap(); + section.shared.section.written().unwrap(); + sb_view_reload(&mut view, false, None); + assert!(!view.reload_pending); + assert!(!view.local_edits.lock().unwrap().failed); + assert_eq!( + view.canvas.page.editor.page().unwrap(), + section.shared.page(space).unwrap().0 + ); + assert_eq!(std::fs::read(&file).unwrap(), original); + let preserved = section.shared.section.pending().unwrap(); + let shared = Arc::clone(§ion.shared); + drop((view, section, library)); + Arc::into_inner(shared) + .expect("No other failed writer") + .section + .close() + .unwrap(); + let sql = rusqlite::Connection::open(&database).unwrap(); + sql.busy_timeout(Duration::from_secs(1)).unwrap(); + sql.execute_batch("DROP TRIGGER reader_fault").unwrap(); + sql.close().unwrap(); + let cold = notebook::Replica::open(&database).unwrap(); + assert_eq!(cold.identity().unwrap(), identity); + assert_eq!(cold.pending().unwrap(), preserved); + assert!(!canvas::search::page_text(&cold.page(space).unwrap()).contains("FAILED rich ๐Ÿ˜€")); + } +} diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index a5b020f693bf8ed1000045c1013bb8facb6bf87f..e08d7a8470b4b6fb82cd903c74418d5958846172 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -2471,6 +2471,16 @@ impl Section { /// Applies an edit without waiting: it becomes durable on the section thread, which /// reports a refusal as `Event::Rejected`. Edits apply in the order they arrive. pub fn apply(&self, author: &str, edit: Edit) -> Result<()> { + self.apply_with(author, edit, |_| {}) + } + + /// Submits without waiting; on completion, calls `reply` before waking the host. + pub fn apply_with( + &self, + author: &str, + edit: Edit, + reply: impl FnOnce(Result) + Send + 'static, + ) -> Result<()> { let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify)); let root = self.replica.root; let spaces = crate::queue::spaces(&edit, root); @@ -2478,15 +2488,20 @@ impl Section { author, edit, Box::new(move |result| { - let event = match result { - Ok(_) => return, - Err(Error::Rejected(error)) => Event::Rejected { + let event = match &result { + Ok(_) => None, + Err(Error::Rejected(error)) => Some(Event::Rejected { spaces, error: error.to_string(), - }, - Err(error) => Event::Failed(error.to_string()), + }), + Err(error) => Some(Event::Failed(error.to_string())), }; - if sender.send(event).is_ok() { + reply(result); + if let Some(event) = event { + if sender.send(event).is_ok() { + notify(); + } + } else { notify(); } }), diff --git a/crates/notebook/src/session/tests.rs b/crates/notebook/src/session/tests.rs index 3a0aebeddff713a6abd37e2d6b30a36e4f2f7a4b..69ac021f7e0e5bd774cbb1400da20b80860b7fc7 100644 --- a/crates/notebook/src/session/tests.rs +++ b/crates/notebook/src/session/tests.rs @@ -624,3 +624,49 @@ fn initially_offline_workers_keep_the_original_queue_until_explicitly_woken() { assert!(Replica::open(&cache).unwrap().pending().unwrap().is_empty()); } } + +#[test] +fn associated_apply_reply_precedes_success_wake_without_a_publication_event() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let temporary = tempfile::tempdir().unwrap(); + let file = temporary.path().join("Reply.one"); + let source = onestore::create_section("Reply.one", "Original", "Fixture").unwrap(); + fs::write(&file, &source).unwrap(); + let (woken, wake) = mpsc::channel(); + let completed = Arc::new(AtomicBool::new(false)); + let observed = Arc::clone(&completed); + let section = Section::open(&file, temporary.path().join("cache"), true, move || { + let _ = woken.send(observed.load(Ordering::Acquire)); + }) + .unwrap(); + while wake.try_recv().is_ok() {} + let creation = PageCreation::new(None, Some("Associated original"), "Fixture").unwrap(); + let space = creation.space(); + let edit = Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::Create(creation))], + }; + let original = edit.clone(); + let (replied, reply) = mpsc::channel(); + section + .apply_with("Original author", edit, move |result| { + completed.store(true, Ordering::Release); + replied.send(result).unwrap(); + }) + .unwrap(); + let id = reply + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap(); + assert!(wake.recv_timeout(Duration::from_secs(10)).unwrap()); + let pending = section.pending().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, id); + assert_eq!(pending[0].author, "Original author"); + assert_eq!(pending[0].edit, original); + assert_eq!(section.page(space).unwrap().title, "Associated original"); + assert!(section.events().is_empty()); + section.close().unwrap(); + assert_eq!(fs::read(file).unwrap(), source); +} diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 017f6f0783b4fe526d646446786040ee22ea1fa1..9bda34fa4fadf3edd166d7ba07f6efaf58bb50a2 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -4957,14 +4957,7 @@ fn page_tree( scale: view.viewport.scale * ratio, origin: [0, 1].map(|axis| (view.viewport.origin[axis] + corner[axis] * scale) * ratio), }; - let update = access.update( - &view.editor, - view.scene.as_ref(), - viewport, - "Page", - view.outline_preview(), - view.object_focus().and_then(|focus| focus.read_only()), - )?; + let update = access.update(view, viewport, "Page")?; Ok(accesskit::TreeUpdate { tree_id: PAGE_TREE, ..update -- 2.54.0