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