authorclover caruso <git@paperclover.net> 2026-10-12 12:19:23-07:00
committerclover caruso <git@paperclover.net> 2026-10-12 12:21:54-07:00
log76298065bb1d6758c3bce206ebf9a981c49759ed
treefa33adfc93f7c82b318337ad754b69529d4a94a6
parent42549de7a888385d3d586ea863ec248122fe2564
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(mobile): keep Reader views editable through local save completion

Retain source geometry, original history and associated save receipts while Reader reflows. Use native image history items with existing undo and redo actions; remove unused legacy verdict presentation from the Swift host. Refs #128 Refs #136 Assisted-by: gpt-6.1-sol

25 files changed, 4303 insertions(+), 960 deletions(-)

Cargo.lock+1
......@@ -2070,6 +2070,7 @@ dependencies = [
20702070 "pollster",
20712071 "relay",
20722072 "resvg",
2073 "rusqlite",
20732074 "serde",
20742075 "serde_json",
20752076 "tempfile",
apps/ios/Snowbound/CanvasView.swift+139-43
......@@ -90,7 +90,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
9090 private var left: CGFloat = 0
9191 /// The zoom a double tap returns to.
9292 private var resting: CGFloat = 1
93 private var tapZooming = false
93 private var canvasPlace: (zoom: CGFloat, resting: CGFloat, corner: CGPoint, left: CGFloat, scrolled: Bool, edited: Bool)?
9494 private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
9595 private lazy var doubleTap: UITapGestureRecognizer = {
9696 let recognizer = UITapGestureRecognizer(target: self, action: #selector(doubleTapped))
......@@ -230,7 +230,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
230230 // Scrolling lays the view out too; only a new size reconfigures the surface.
231231 guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return }
232232 sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale))
233 transform()
233 if readingShown { reflowReading(readingWidth) } else { transform() }
234234 } else {
235235 handle = sb_view_new(
236236 Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page,
......@@ -251,6 +251,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
251251 }
252252 }
253253
254 override func safeAreaInsetsDidChange() {
255 super.safeAreaInsetsDidChange()
256 if readingShown { reflowReading(readingWidth) }
257 }
258
254259 override func didMoveToWindow() {
255260 super.didMoveToWindow()
256261 link?.invalidate()
......@@ -288,9 +293,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
288293 /// reveal know neither the navigation bar nor the keyboard, so `transform` overrides them.
289294 private func sync() {
290295 guard let handle else { return }
296 let shown = readingShown
297 var restored: CGPoint?
298 if !shown, let place = canvasPlace {
299 canvasPlace = nil
300 syncing = true
301 minimumZoomScale = 0.25
302 maximumZoomScale = 4
303 zoomScale = place.zoom
304 resting = place.resting
305 left = place.left
306 scrolled = place.scrolled
307 restored = place.corner
308 syncing = false
309 }
291310 var bounds: [Float] = [0, 0, 0, 0]
292311 sb_view_content(handle, &bounds)
293 let corner = CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale)
312 let corner = restored ?? CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale)
294313 origin = CGPoint(x: CGFloat(bounds[0]), y: CGFloat(bounds[1]))
295314 let size = CGSize(width: CGFloat(bounds[2]) - origin.x, height: CGFloat(bounds[3]) - origin.y)
296315 syncing = true
......@@ -304,6 +323,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
304323
305324 private func transform() {
306325 guard let handle, !syncing else { return }
326 if readingShown {
327 syncing = true
328 zoomScale = 1
329 contentOffset.x = margin - adjustedContentInset.left
330 syncing = false
331 }
307332 sb_view_set_transform(
308333 handle, Float(zoomScale), Float(origin.x + contentOffset.x / zoomScale),
309334 Float(origin.y + contentOffset.y / zoomScale))
......@@ -338,7 +363,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
338363 }
339364
340365 private func home() {
341 if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) }
366 if readingShown { transform() }
367 else if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) }
342368 }
343369
344370 /// 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
362388
363389 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
364390 scrolled = true
391 if readingShown { drag = nil; return }
365392 let moved = panGestureRecognizer.translation(in: self)
366393 let cross: WritableKeyPath<CGPoint, CGFloat>? =
367394 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
369396 }
370397
371398 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
399 guard !readingShown else { return }
372400 scrolled = true
373401 drag = nil
374402 pinchStart = spread.map { (zoomScale, $0) }
......@@ -376,9 +404,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
376404 detentTick.prepare()
377405 }
378406
379 func viewForZooming(in scrollView: UIScrollView) -> UIView? { content }
407 func viewForZooming(in scrollView: UIScrollView) -> UIView? { readingShown ? nil : content }
380408
381409 func scrollViewDidScroll(_ scrollView: UIScrollView) {
410 guard !syncing else { return }
382411 if var drag, isDragging || isDecelerating, !isZooming {
383412 let cross = drag.cross
384413 let along = cross == \CGPoint.x ? \CGPoint.y : \CGPoint.x
......@@ -419,6 +448,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
419448 /// Pinching near 100% sticks there: within `detent` of it the zoom follows the fingers
420449 /// on a curve flat at 100% that meets them again at the band's edges.
421450 func scrollViewDidZoom(_ scrollView: UIScrollView) {
451 guard !syncing else { return }
452 if readingShown {
453 transform()
454 inputDelegate?.selectionDidChange(self)
455 return
456 }
422457 if let pinch = pinchGestureRecognizer, pinch.state == .changed, let pinchStart, let spread {
423458 // The scroll view's pinch reports the zoom it shows, so the fingers give the raw one.
424459 let raw = log(pinchStart.zoom * spread / pinchStart.spread)
......@@ -441,15 +476,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
441476 }
442477
443478 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
479 let pinched = pinchStart != nil
444480 pinchStart = nil
445 if tapZooming {
446 tapZooming = false
447 } else {
448 // A pinch let go within the detent settles on 100%.
449 resting = abs(log(scale)) < Self.detent ? 1 : scale
450 if resting != scale { setZoomScale(resting, animated: true) }
451 UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey)
452 }
481 guard !readingShown, pinched else { return }
482 // A pinch let go within the detent settles on 100%.
483 resting = abs(log(scale)) < Self.detent ? 1 : scale
484 if resting != scale { setZoomScale(resting, animated: true) }
485 UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey)
453486 }
454487
455488 @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) {
......@@ -459,8 +492,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
459492 /// Zooms to fit the outline nearest `point` across the view, or back out, as a double
460493 /// tap does in Safari.
461494 func zoom(at point: CGPoint) {
462 guard let handle else { return }
463 tapZooming = true
495 guard let handle, !readingShown else { return }
464496 var block: [Float] = [0, 0, 0, 0]
465497 let local = visible(point)
466498 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
601633 /// Gives the canvas `tool` for the touch starting, where it has another: switching lets
602634 /// picked drawings go.
603635 private func use(_ tool: InkTool) {
604 guard let handle, tool != appliedTool else { return }
636 guard let handle else { return }
637 if tool != .select, readingShown { showReading(false) }
638 guard tool != appliedTool else { return }
605639 appliedTool = tool
606640 sb_view_set_tool(handle, tool.code.tool, tool.code.detail, sectionColor)
607641 dirty = true
......@@ -652,6 +686,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
652686 func showPicker(_ shown: Bool, fingerDraws draws: Bool = true) {
653687 guard shown != pickerShown, let host = superview else { return }
654688 if shown {
689 if readingShown { showReading(false) }
655690 picker.translatesAutoresizingMaskIntoConstraints = false
656691 host.addSubview(picker)
657692 NSLayoutConstraint.activate([
......@@ -770,15 +805,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
770805 inputDelegate?.textWillChange(self)
771806 }
772807 let changed = change()
773 if external {
774 inputDelegate?.textDidChange(self)
775 inputDelegate?.selectionDidChange(self)
776 }
777808 dirty = true
778809 if changed {
810 canvasPlace?.edited = true
779811 sync()
780812 revealCaret()
781813 }
814 if external {
815 inputDelegate?.textDidChange(self)
816 inputDelegate?.selectionDidChange(self)
817 }
782818 changedSelection()
783819 return changed
784820 }
......@@ -950,30 +986,62 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
950986 /// The column a reading view fills: the screen's width less the room tags hang in.
951987 private var readingWidth: Float { Float(bounds.inset(by: safeAreaInsets).width - 34 - 12) }
952988
953 /// Whether the page has a reading view here, and how it reads or why not.
954 var reading: (offered: Bool, verdict: String)? {
955 struct Reading: Decodable {
956 let offered: Bool
957 let verdict: String
958 }
959 guard let handle, let read = decode(Reading.self, sb_view_reading(handle, readingWidth)) else { return nil }
960 return (read.offered, read.verdict)
989 private var readingAnchor: [Float] {
990 [Float(origin.x + (contentOffset.x + adjustedContentInset.left + 34) / zoomScale),
991 Float(origin.y + (contentOffset.y + adjustedContentInset.top) / zoomScale)]
961992 }
962993
963 /// Whether the page shows reflowed, as `showReading` left it.
964 private(set) var readingShown = false
994 struct Reading: Decodable {
995 let offered: Bool
996 let shown: Bool
997 }
998
999 var reading: Reading? {
1000 guard let handle else { return nil }
1001 return decode(Reading.self, sb_view_reading(handle, readingWidth))
1002 }
1003
1004 var readingShown: Bool {
1005 guard let handle else { return false }
1006 return decode(Reading.self, sb_view_reading(handle, 0))?.shown ?? false
1007 }
9651008
966 /// Shows the page reflowed into the screen's width at 100%, read-only, or as laid out.
1009 /// Shows the page reflowed into the screen's width at 100%, or as laid out.
9671010 func showReading(_ on: Bool) {
1011 guard on != readingShown else { return }
1012 reflowReading(on ? readingWidth : 0)
1013 }
1014
1015 private func reflowReading(_ width: Float) {
9681016 guard let handle else { return }
969 _ = resignFirstResponder()
970 readingShown = sb_view_set_reading(handle, on ? readingWidth : 0)
971 zoomScale = 1
972 resting = 1
973 scrolled = false
1017 let wasShown = readingShown
1018 let place = (zoom: zoomScale, resting: resting,
1019 corner: CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale),
1020 left: left, scrolled: scrolled, edited: false)
1021 let edited = canvasPlace?.edited ?? false
1022 var anchor = readingAnchor
1023 inputDelegate?.selectionWillChange(self)
1024 let shown = sb_view_set_reading(handle, width, &anchor)
1025 if shown, !wasShown {
1026 canvasPlace = place
1027 syncing = true
1028 minimumZoomScale = 1
1029 maximumZoomScale = 1
1030 zoomScale = 1
1031 resting = 1
1032 scrolled = true
1033 drag = nil
1034 pinchStart = nil
1035 syncing = false
1036 }
9741037 sync()
975 left = margin
976 home()
1038 if shown {
1039 let y = CGFloat(anchor[1]) - origin.y - adjustedContentInset.top
1040 contentOffset.y = max(-adjustedContentInset.top, min(y, contentSize.height - bounds.height + adjustedContentInset.bottom))
1041 } else if edited {
1042 revealCaret()
1043 }
1044 inputDelegate?.selectionDidChange(self)
9771045 dirty = true
9781046 onChange?()
9791047 }
......@@ -1018,6 +1086,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
10181086 /// Insert Space: the next drag moves what lies below or right of where it starts.
10191087 func insertSpace() {
10201088 guard let handle else { return }
1089 if readingShown { showReading(false) }
10211090 sb_view_insert_space(handle)
10221091 spaceHint.show("Drag down or right to add space", in: self)
10231092 }
......@@ -1056,9 +1125,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
10561125 var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 }
10571126
10581127 /// Shows the page as stored; with `discard`, after an edit was refused.
1059 func reload(discard: Bool) {
1128 func reload(discard: Bool, pendingOnly: Bool = false) {
10601129 guard let handle else { return }
1061 edit(external: true) { sb_view_reload(handle, discard) }
1130 if pendingOnly && !sb_view_reload_ready(handle) { return }
1131 guard readingShown else {
1132 edit(external: true) { sb_view_reload(handle, discard, nil) }
1133 return
1134 }
1135 var anchor = readingAnchor
1136 inputDelegate?.selectionWillChange(self)
1137 inputDelegate?.textWillChange(self)
1138 let changed = sb_view_reload(handle, discard, &anchor)
1139 sync()
1140 if readingShown {
1141 let y = CGFloat(anchor[1]) - origin.y - adjustedContentInset.top
1142 contentOffset.y = max(-adjustedContentInset.top, min(y, contentSize.height - bounds.height + adjustedContentInset.bottom))
1143 } else if changed {
1144 revealCaret()
1145 }
1146 inputDelegate?.textDidChange(self)
1147 inputDelegate?.selectionDidChange(self)
1148 dirty = true
1149 changedSelection()
10621150 }
10631151
10641152 /// Selects the first place `query` occurs and brings it into view.
......@@ -1163,9 +1251,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
11631251 /// Commits marked text, so it is stored before the app leaves the screen.
11641252 func commitComposition() {
11651253 guard markedTextRange != nil else { return }
1166 inputDelegate?.textWillChange(self)
11671254 unmarkText()
1168 inputDelegate?.textDidChange(self)
11691255 }
11701256
11711257 override var keyCommands: [UIKeyCommand]? {
......@@ -1264,7 +1350,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
12641350 }
12651351 set {
12661352 guard let newValue else { return }
1267 _ = choose(newValue)
1353 if choose(newValue) { canvasPlace?.edited = true }
1354 sync()
1355 revealCaret()
12681356 dirty = true
12691357 changedSelection()
12701358 }
......@@ -1289,8 +1377,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
12891377
12901378 func unmarkText() {
12911379 guard let handle else { return }
1380 inputDelegate?.selectionWillChange(self)
1381 inputDelegate?.textWillChange(self)
12921382 sb_unmark(handle)
1383 canvasPlace?.edited = true
1384 sync()
1385 revealCaret()
1386 inputDelegate?.textDidChange(self)
1387 inputDelegate?.selectionDidChange(self)
12931388 dirty = true
1389 changedSelection()
12941390 }
12951391
12961392 var beginningOfDocument: UITextPosition { Position(0) }
apps/ios/Snowbound/Lists.swift+5-3
......@@ -150,7 +150,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
150150 }
151151 updateSelectionActions()
152152 for name in [Sync.changed, Section.changed] {
153 NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil)
153 NotificationCenter.default.addObserver(self, selector: #selector(refreshSync(_:)), name: name, object: nil)
154154 }
155155 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
156156 guard let self else { return }
......@@ -244,7 +244,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
244244
245245 /// Reads every notebook's sync status, marks the rows it changed, and tells of a notebook
246246 /// that has just stopped syncing with edits waiting.
247 @objc private func refreshSync() {
247 @objc private func refreshSync(_ notification: Notification? = nil) {
248 guard notification?.userInfo?["unchanged"] as? Bool != true else { return }
248249 Sync.status { [weak self] notebooks in
249250 guard let self else { return }
250251 var changed: [Item] = []
......@@ -1176,7 +1177,8 @@ final class PagesViewController: UITableViewController {
11761177 }
11771178
11781179 @objc private func changed(_ notification: Notification) {
1179 guard notification.object as AnyObject? === section else { return }
1180 guard notification.object as AnyObject? === section,
1181 notification.userInfo?["unchanged"] as? Bool != true else { return }
11801182 reload()
11811183 }
11821184
apps/ios/Snowbound/Notebooks.swift+4-2
......@@ -701,10 +701,12 @@ final class Section {
701701 var status: UInt8 = 0
702702 let flags = sb_section_poll(handle, &status)
703703 let changed = SaveStatus(rawValue: status) ?? .unknown
704 guard flags != 0 || changed != self.status else { return }
704 let unchanged = flags == 0 && changed == self.status
705705 self.status = changed
706706 if flags & Self.listed != 0 { reloadRows() }
707 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": flags])
707 NotificationCenter.default.post(
708 name: Self.changed, object: self,
709 userInfo: unchanged ? ["flags": flags, "unchanged": true] : ["flags": flags])
708710 }
709711
710712 /// A page's title as typed, shown in the list before the section reports it.
apps/ios/Snowbound/PageViewController.swift+7-4
......@@ -32,9 +32,9 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
3232 private lazy var done = UIBarButtonItem(
3333 systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() })
3434 private lazy var undo = UIBarButtonItem(
35 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
35 title: "undo", image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
3636 private lazy var redo = UIBarButtonItem(
37 systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
37 title: "redo", image: UIImage(systemName: "arrow.uturn.forward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
3838 /// Shows the drawing tools, which a finger then draws with too.
3939 private lazy var draw = UIBarButtonItem(
4040 title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"),
......@@ -231,11 +231,11 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
231231 ]
232232 }
233233
234 /// Reading View: the page reflowed into the screen's width, read-only, where it reflows.
234 /// Reading View: the page reflowed into the screen's width where it reflows.
235235 private func readingAction() -> UIAction {
236236 let reading = canvas.reading
237237 return UIAction(
238 title: "Reading View", subtitle: reading.flatMap { Prototype.readingNote($0.verdict) },
238 title: "Reading View",
239239 image: UIImage(systemName: "text.justify.left"),
240240 attributes: reading?.offered == true || canvas.readingShown ? [] : .disabled,
241241 state: canvas.readingShown ? .on : .off
......@@ -414,6 +414,9 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
414414 alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.")
415415 } else if flags & Section.remote != 0 {
416416 canvas.reload(discard: false)
417 } else {
418 canvas.reload(discard: false, pendingOnly: true)
419 if notification.userInfo?["unchanged"] as? Bool == true { return }
417420 }
418421 if flags & Section.listed != 0 {
419422 if section.row(of: page) == nil {
apps/ios/Snowbound/Prototypes.swift+1-19
......@@ -4,28 +4,10 @@ import UIKit
44/// `-SnowboundReading YES`, `-SnowboundWelcome YES`, `-SnowboundSectionStrip YES` or
55/// `-SnowboundQuickNote YES` (Xcode's scheme, `simctl launch`, or `devicectl … --arguments`).
66enum Prototype {
7 /// Page menu: Reading View, the page reflowed into the screen's width, read-only.
7 /// Page menu: Reading View, the page reflowed into the screen's width.
88 static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") }
99}
1010
11extension Prototype {
12 /// What the page menu says under Reading View for a verdict from `sb_view_reading`.
13 static func readingNote(_ verdict: String) -> String? {
14 let notes = [
15 ("Fits", "Page already fits"),
16 ("RightToLeft", "Right-to-left pages not yet"),
17 ("Drawn", "Mostly drawings"),
18 ("Arranged", "Laid out as a canvas"),
19 ("Asides", "Side notes follow their lines"),
20 ("SideBySide", "Columns read left to right"),
21 ("Group", "Overlapping parts stay together"),
22 ("Wide", "Some parts stay wide"),
23 ]
24 let found = notes.filter { verdict.contains($0.0) }.map(\.1)
25 return found.isEmpty ? nil : found.joined(separator: " · ")
26 }
27}
28
2911extension Prototype {
3012 /// First launch: a welcome where the list would be empty, the first notebook found opened
3113 /// at its first page, and the name asked at the first edit rather than the first open.
apps/ios/Snowbound/Sync.swift+3-2
......@@ -203,7 +203,7 @@ final class SyncViewController: UITableViewController {
203203 Sync.offline = self?.offline.isOn ?? false
204204 }, for: .valueChanged)
205205 for name in [Sync.changed, Section.changed] {
206 NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
206 NotificationCenter.default.addObserver(self, selector: #selector(refresh(_:)), name: name, object: nil)
207207 }
208208 }
209209
......@@ -219,7 +219,8 @@ final class SyncViewController: UITableViewController {
219219 timer?.invalidate()
220220 }
221221
222 @objc private func refresh() {
222 @objc private func refresh(_ notification: Notification? = nil) {
223 guard notification?.userInfo?["unchanged"] as? Bool != true else { return }
223224 Sync.status { [weak self] notebooks in
224225 self?.notebooks = notebooks.filter { !$0.1.isEmpty }
225226 self?.tableView.reloadData()
crates/canvas/src/editor.rs+1255-474
......@@ -220,7 +220,7 @@ pub struct TextOutline {
220220 min_width: Option<f32>,
221221 layout: onestore::document::Layout,
222222 document: TextDocument,
223 indents: Vec<f32>,
223 pub(crate) indents: Vec<f32>,
224224 shaped: OutlineLayout,
225225 selection: Selection,
226226}
......@@ -428,9 +428,149 @@ impl TextOutline {
428428 }
429429 }
430430
431 fn geometry(&self) -> OutlineGeometry<'_> {
432 OutlineGeometry {
433 source: self,
434 shaped: &self.shaped,
435 }
436 }
437
431438 pub fn layouts(&self) -> impl Iterator<Item = (usize, &ParagraphLayout)> {
439 self.geometry().layouts()
440 }
441
442 fn visible_index(&self, source: usize) -> Result<usize, EditError> {
443 self.geometry().visible_index(source)
444 }
445
446 fn source_index(&self, visible: usize) -> usize {
447 self.geometry().source_index(visible)
448 }
449
450 fn next_sibling_start(&self, index: usize) -> Option<TextPosition> {
451 self.geometry().next_sibling_start(index)
452 }
453
454 pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {
455 self.geometry().paragraph_layout(source)
456 }
457
458 /// The shown paragraphs with their source text: those a collapsed parent hides have no
459 /// layout and are left out.
460 fn shown(&self) -> impl Iterator<Item = (usize, &Paragraph)> {
461 self.layouts()
462 .filter_map(|(index, _)| Some((index, self.document.paragraph(index)?)))
463 }
464
465 /// The outline's text as platform text input sees it: the shown paragraphs joined by
466 /// `\n`, addressed in UTF-16 units by `utf16_offset` and `utf16_position`.
467 pub fn shown_text(&self) -> String {
468 self.shown()
469 .map(|(_, paragraph)| paragraph.text())
470 .collect::<Vec<_>>()
471 .join("\n")
472 }
473
474 /// All the outline's shown text.
475 pub(crate) fn whole(&self) -> Selection {
476 let paragraph = self.source_index(self.shaped.paragraphs.len() - 1);
477 let last = self.document.paragraph(paragraph).unwrap();
478 [
479 TextPosition {
480 paragraph: 0,
481 offset: 0,
482 },
483 TextPosition {
484 paragraph,
485 offset: last
486 .utf16_offset(last.text().len())
487 .expect("paragraph text fits UTF-16 offsets"),
488 },
489 ]
490 .into()
491 }
492
493 /// The caret at `focus`, in the outline's coordinates.
494 pub fn caret_at(
495 &self,
496 focus: TextPosition,
497 affinity: Affinity,
498 width: f32,
499 ) -> Result<BoundingBox, EditError> {
500 self.geometry().caret_at(focus, affinity, width)
501 }
502
503 /// Highlight rectangles of `selection` in the outline, in its coordinates.
504 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
505 self.geometry().range_rects(selection)
506 }
507
508 /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each
509 /// line's left, right and baseline.
510 #[cfg(test)]
511 pub(crate) fn underlines(
512 &self,
513 index: usize,
514 range: Range<u32>,
515 ) -> Result<Vec<[f32; 3]>, EditError> {
516 self.geometry().underlines(index, range)
517 }
518
519 /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the
520 /// next shown one.
521 pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> {
522 let mut offset = 0;
523 for (index, paragraph) in self.shown() {
524 if index == position.paragraph {
525 return Ok(offset + position.offset);
526 }
527 if index > position.paragraph {
528 return Ok(offset);
529 }
530 offset += paragraph.utf16_offset(paragraph.text().len())? + 1;
531 }
532 Ok(offset.saturating_sub(1))
533 }
534
535 /// The source position at `offset` in `shown_text`, clamped to its end.
536 pub fn utf16_position(&self, offset: u32) -> Result<TextPosition, EditError> {
537 let mut rest = offset;
538 let mut end = None;
539 for (index, paragraph) in self.shown() {
540 let length = paragraph.utf16_offset(paragraph.text().len())?;
541 if rest <= length {
542 return Ok(TextPosition {
543 paragraph: index,
544 offset: rest,
545 });
546 }
547 rest -= length + 1;
548 end = Some(TextPosition {
549 paragraph: index,
550 offset: length,
551 });
552 }
553 end.ok_or(EditError::InvalidRange)
554 }
555}
556
557#[derive(Clone, Copy)]
558pub(crate) struct OutlineGeometry<'a> {
559 pub(crate) source: &'a TextOutline,
560 pub(crate) shaped: &'a OutlineLayout,
561}
562
563struct Navigation {
564 selection: Selection,
565 preferred_x: Option<f32>,
566 boundary: bool,
567}
568
569impl<'a> OutlineGeometry<'a> {
570 pub(crate) fn layouts(self) -> impl Iterator<Item = (usize, &'a ParagraphLayout)> + 'a {
432571 let mut shaped = self.shaped.paragraphs.iter().peekable();
433 self.document
572 self.source
573 .document
434574 .text_nodes()
435575 .enumerate()
436576 .filter_map(move |(index, node)| {
......@@ -445,10 +585,10 @@ impl TextOutline {
445585 })
446586 }
447587
448 /// Layouts follow the text leaves minus hidden ones, so an index shared by a leaf and its
449 /// layout needs no search.
450 fn visible_index(&self, source: usize) -> Result<usize, EditError> {
588 /// Hidden leaves have no layout; matching IDs avoid searching the common flat case.
589 fn visible_index(self, source: usize) -> Result<usize, EditError> {
451590 let id = self
591 .source
452592 .document
453593 .leaf(source)
454594 .ok_or(EditError::InvalidRange)?
......@@ -469,76 +609,41 @@ impl TextOutline {
469609 .ok_or(EditError::InvalidRange)
470610 }
471611
472 fn source_index(&self, visible: usize) -> usize {
612 fn source_index(self, visible: usize) -> usize {
473613 let id = self.shaped.paragraphs[visible].id;
474614 if self
615 .source
475616 .document
476617 .leaf(visible)
477618 .is_some_and(|(_, _, node)| node.id == id)
478619 {
479620 return visible;
480621 }
481 self.document
622 self.source
623 .document
482624 .text_nodes()
483625 .position(|paragraph| paragraph.id == id)
484626 .unwrap()
485627 }
486628
487 /// The start of the paragraph after visible paragraph `index` when it follows in the same
488 /// container, where a whole-paragraph selection ends so that deleting it takes the break.
489 fn next_sibling_start(&self, index: usize) -> Option<TextPosition> {
629 /// Including a sibling's start lets whole-paragraph deletion take the break.
630 fn next_sibling_start(self, index: usize) -> Option<TextPosition> {
490631 let next =
491632 (index + 1 < self.shaped.paragraphs.len()).then(|| self.source_index(index + 1))?;
492 let (container, local, _) = self.document.leaf(self.source_index(index))?;
493 let (next_container, next_local, _) = self.document.leaf(next)?;
633 let (container, local, _) = self.source.document.leaf(self.source_index(index))?;
634 let (next_container, next_local, _) = self.source.document.leaf(next)?;
494635 (container == next_container && next_local == local + 1).then_some(TextPosition {
495636 paragraph: next,
496637 offset: 0,
497638 })
498639 }
499640
500 pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {
641 fn paragraph_layout(self, source: usize) -> Result<&'a ParagraphLayout, EditError> {
501642 Ok(&self.shaped.paragraphs[self.visible_index(source)?])
502643 }
503644
504 /// The shown paragraphs with their source text: those a collapsed parent hides have no
505 /// layout and are left out.
506 fn shown(&self) -> impl Iterator<Item = (usize, &Paragraph)> {
507 self.layouts()
508 .filter_map(|(index, _)| Some((index, self.document.paragraph(index)?)))
509 }
510
511 /// The outline's text as platform text input sees it: the shown paragraphs joined by
512 /// `\n`, addressed in UTF-16 units by `utf16_offset` and `utf16_position`.
513 pub fn shown_text(&self) -> String {
514 self.shown()
515 .map(|(_, paragraph)| paragraph.text())
516 .collect::<Vec<_>>()
517 .join("\n")
518 }
519
520 /// All the outline's shown text.
521 pub(crate) fn whole(&self) -> Selection {
522 let paragraph = self.source_index(self.shaped.paragraphs.len() - 1);
523 let last = self.document.paragraph(paragraph).unwrap();
524 [
525 TextPosition {
526 paragraph: 0,
527 offset: 0,
528 },
529 TextPosition {
530 paragraph,
531 offset: last
532 .utf16_offset(last.text().len())
533 .expect("paragraph text fits UTF-16 offsets"),
534 },
535 ]
536 .into()
537 }
538
539 /// The caret at `focus`, in the outline's coordinates.
540 pub fn caret_at(
541 &self,
645 pub(crate) fn caret_at(
646 self,
542647 focus: TextPosition,
543648 affinity: Affinity,
544649 width: f32,
......@@ -553,8 +658,7 @@ impl TextOutline {
553658 Ok(rect)
554659 }
555660
556 /// Highlight rectangles of `selection` in the outline, in its coordinates.
557 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
661 pub(crate) fn range_rects(self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
558662 let [anchor, focus] = selection.positions;
559663 if anchor == focus {
560664 return Ok(Vec::new());
......@@ -574,7 +678,7 @@ impl TextOutline {
574678 .enumerate()
575679 .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph)
576680 {
577 let source = self.document.paragraph(index).unwrap();
681 let source = self.source.document.paragraph(index).unwrap();
578682 let first = if index == start.paragraph {
579683 paragraph.cursor(start.offset, affinities[0])?
580684 } else {
......@@ -611,10 +715,8 @@ impl TextOutline {
611715 Ok(rectangles)
612716 }
613717
614 /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each
615 /// line's left, right and baseline.
616718 pub(crate) fn underlines(
617 &self,
719 self,
618720 index: usize,
619721 range: Range<u32>,
620722 ) -> Result<Vec<[f32; 3]>, EditError> {
......@@ -632,44 +734,48 @@ impl TextOutline {
632734 .collect())
633735 }
634736
635 /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the
636 /// next shown one.
637 pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> {
638 let mut offset = 0;
639 for (index, paragraph) in self.shown() {
640 if index == position.paragraph {
641 return Ok(offset + position.offset);
642 }
643 if index > position.paragraph {
644 return Ok(offset);
645 }
646 offset += paragraph.utf16_offset(paragraph.text().len())? + 1;
737 pub(crate) fn selection_at(
738 self,
739 x: f32,
740 y: f32,
741 unit: SelectionUnit,
742 ) -> Result<Selection, EditError> {
743 if !x.is_finite() || !y.is_finite() {
744 return Err(EditError::InvalidRange);
647745 }
648 Ok(offset.saturating_sub(1))
649 }
650
651 /// The source position at `offset` in `shown_text`, clamped to its end.
652 pub fn utf16_position(&self, offset: u32) -> Result<TextPosition, EditError> {
653 let mut rest = offset;
654 let mut end = None;
655 for (index, paragraph) in self.shown() {
656 let length = paragraph.utf16_offset(paragraph.text().len())?;
657 if rest <= length {
658 return Ok(TextPosition {
659 paragraph: index,
660 offset: rest,
661 });
662 }
663 rest -= length + 1;
664 end = Some(TextPosition {
665 paragraph: index,
666 offset: length,
667 });
746 let index = self.paragraph_at(x, y)?;
747 let paragraph = &self.shaped.paragraphs[index];
748 let layout = &paragraph.text.shaped;
749 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
750 let local = edit::selection_at(
751 layout,
752 paragraph.text.hit_test(point[0], point[1]),
753 point[0],
754 unit,
755 );
756 let position = |cursor: Cursor| -> Result<TextPosition, EditError> {
757 let visible = paragraph.projection.text().utf16_offset(cursor.index())?;
758 Ok(TextPosition {
759 paragraph: self.source_index(index),
760 offset: paragraph
761 .projection
762 .source_offset(visible, crate::affinity(cursor.affinity()))?,
763 })
764 };
765 let mut selection = Selection {
766 positions: [position(local.anchor())?, position(local.focus())?],
767 affinities: [local.anchor().affinity(), local.focus().affinity()],
768 };
769 if unit == SelectionUnit::Paragraph
770 && let Some(next) = self.next_sibling_start(index)
771 {
772 selection.positions[1] = next;
773 selection.affinities[1] = Affinity::Downstream;
668774 }
669 end.ok_or(EditError::InvalidRange)
775 Ok(selection)
670776 }
671777
672 fn column(&self, x: f32, current: ExGuid) -> Result<Vec<usize>, EditError> {
778 fn column(self, x: f32, current: ExGuid) -> Result<Vec<usize>, EditError> {
673779 let paragraphs = self
674780 .shaped
675781 .paragraphs
......@@ -684,7 +790,7 @@ impl TextOutline {
684790 .map(|table| (table.id, table))
685791 .collect::<BTreeMap<_, _>>();
686792 let mut result = Vec::new();
687 let mut pending = vec![visible_paragraphs(self.document.nodes().iter())];
793 let mut pending = vec![visible_paragraphs(self.source.document.nodes().iter())];
688794 while let Some(nodes) = pending.last_mut() {
689795 let Some(node) = nodes.next() else {
690796 pending.pop();
......@@ -742,37 +848,237 @@ impl TextOutline {
742848 Ok(result)
743849 }
744850
745 fn paragraph_at(&self, x: f32, y: f32) -> Result<usize, EditError> {
746 if self.shaped.tables.is_empty() {
747 return Ok(self
748 .shaped
749 .paragraphs
750 .partition_point(|paragraph| paragraph.origin[1] <= y)
751 .saturating_sub(1));
752 }
753 let paragraphs = self
754 .shaped
755 .paragraphs
756 .iter()
757 .enumerate()
758 .map(|(index, paragraph)| (paragraph.id, (index, paragraph)))
759 .collect::<BTreeMap<_, _>>();
760 let tables = self
761 .shaped
762 .tables
763 .iter()
764 .map(|table| (table.id, table))
765 .collect::<BTreeMap<_, _>>();
766 let mut nodes = self.document.nodes();
767 loop {
768 let mut target = None;
769 for node in visible_paragraphs(nodes.iter()) {
770 let top = match &node.content {
771 onestore::page::ParagraphContent::Text(_) => {
772 paragraphs
773 .get(&node.id)
774 .ok_or(EditError::InvalidStructure)?
775 .1
851 fn navigate(
852 self,
853 mut selection: Selection,
854 movement: Movement,
855 extend: bool,
856 mut preferred_x: Option<f32>,
857 ) -> Result<Navigation, EditError> {
858 let [anchor, focus] = selection.positions;
859 if matches!(
860 movement,
861 Movement::ParagraphStart
862 | Movement::ParagraphEnd
863 | Movement::DocumentStart
864 | Movement::DocumentEnd
865 ) {
866 let current = self.visible_index(focus.paragraph)?;
867 let end = matches!(movement, Movement::ParagraphEnd | Movement::DocumentEnd);
868 let current_end = self.source.document.paragraph(focus.paragraph).unwrap();
869 let current_end = current_end.utf16_offset(current_end.text().len())?;
870 let index = match movement {
871 Movement::DocumentStart => 0,
872 Movement::DocumentEnd => self.shaped.paragraphs.len() - 1,
873 Movement::ParagraphStart if focus.offset == 0 => current.saturating_sub(1),
874 Movement::ParagraphEnd if focus.offset == current_end => {
875 (current + 1).min(self.shaped.paragraphs.len() - 1)
876 }
877 _ => current,
878 };
879 let source = self.source_index(index);
880 let paragraph = self.source.document.paragraph(source).unwrap();
881 let position = TextPosition {
882 paragraph: source,
883 offset: if end {
884 paragraph.utf16_offset(paragraph.text().len())?
885 } else {
886 0
887 },
888 };
889 let affinity = if end {
890 Affinity::Upstream
891 } else {
892 Affinity::Downstream
893 };
894 if !extend {
895 selection.positions[0] = position;
896 selection.affinities[0] = affinity;
897 }
898 selection.positions[1] = position;
899 selection.affinities[1] = affinity;
900 return Ok(Navigation {
901 selection,
902 preferred_x: None,
903 boundary: false,
904 });
905 }
906 let horizontal = matches!(
907 movement,
908 Movement::Left | Movement::Right | Movement::WordLeft | Movement::WordRight
909 );
910 let left = matches!(movement, Movement::Left | Movement::WordLeft);
911 if horizontal && !extend && anchor.paragraph != focus.paragraph {
912 let index = usize::from(if left { anchor > focus } else { anchor < focus });
913 let selection = Selection {
914 positions: [selection.positions[index]; 2],
915 affinities: [selection.affinities[index]; 2],
916 };
917 return Ok(Navigation {
918 selection,
919 preferred_x: None,
920 boundary: false,
921 });
922 }
923 let mut index = self.visible_index(focus.paragraph)?;
924 let paragraph = &self.shaped.paragraphs[index];
925 let cursor = paragraph.cursor(focus.offset, selection.affinities[1])?;
926 let mut next;
927 let boundary;
928 if matches!(movement, Movement::Up | Movement::Down) {
929 let caret = paragraph.text.caret(cursor, 1.0);
930 let x = preferred_x.unwrap_or(caret.x0 as f32 + paragraph.origin[0]);
931 let line = paragraph
932 .text
933 .lines()
934 .position(|(_, line)| f64::from(line.top) == caret.y0)
935 .unwrap();
936 let up = matches!(movement, Movement::Up);
937 let target = if up {
938 line.checked_sub(1)
939 .and_then(|line| paragraph.text.lines().nth(line))
940 } else {
941 paragraph.text.lines().nth(line + 1)
942 }
943 .map(|(_, line)| line);
944 let target = if target.is_some() {
945 target
946 } else {
947 let neighbor = if self.shaped.tables.is_empty() {
948 if up {
949 index.checked_sub(1)
950 } else {
951 (index + 1 < self.shaped.paragraphs.len()).then_some(index + 1)
952 }
953 } else {
954 let column = self.column(x, paragraph.id)?;
955 let current = column
956 .iter()
957 .position(|candidate| *candidate == index)
958 .ok_or(EditError::InvalidStructure)?;
959 if up {
960 current.checked_sub(1).map(|i| column[i])
961 } else {
962 column.get(current + 1).copied()
963 }
964 };
965 neighbor.and_then(|neighbor| {
966 index = neighbor;
967 let paragraph = &self.shaped.paragraphs[index];
968 if up {
969 paragraph.text.lines().last()
970 } else {
971 paragraph.text.lines().next()
972 }
973 .map(|(_, line)| line)
974 })
975 };
976 boundary = target.is_none();
977 next = target
978 .map(|line| {
979 self.shaped.paragraphs[index].text.hit_test(
980 x - self.shaped.paragraphs[index].origin[0],
981 line.top + line.height * 0.5,
982 )
983 })
984 .unwrap_or(cursor);
985 preferred_x = Some(x);
986 } else {
987 let local = if anchor.paragraph == focus.paragraph {
988 ParagraphSelection::new(
989 paragraph.cursor(anchor.offset, selection.affinities[0])?,
990 cursor,
991 )
992 } else {
993 cursor.into()
994 };
995 next = edit::step(&paragraph.text.shaped, local, movement, extend).focus();
996 if horizontal && next == cursor && (extend || anchor == focus) {
997 let neighbor = if left {
998 index.checked_sub(1)
999 } else {
1000 (index + 1 < self.shaped.paragraphs.len()).then_some(index + 1)
1001 };
1002 if let Some(neighbor) = neighbor {
1003 index = neighbor;
1004 let paragraph = &self.shaped.paragraphs[index];
1005 let line = if left {
1006 paragraph.text.lines().last()
1007 } else {
1008 paragraph.text.lines().next()
1009 }
1010 .unwrap()
1011 .1;
1012 next = paragraph.text.hit_test(
1013 if left { f32::MAX } else { -1.0 },
1014 line.top + line.height * 0.5,
1015 );
1016 if matches!(movement, Movement::WordLeft | Movement::WordRight) {
1017 next = edit::word_cursor(&paragraph.text.shaped, next, left);
1018 }
1019 }
1020 }
1021 boundary = !extend
1022 && anchor == focus
1023 && next == cursor
1024 && self.source_index(index) == focus.paragraph
1025 && matches!(movement, Movement::Left | Movement::Right);
1026 }
1027 if !matches!(movement, Movement::Up | Movement::Down) {
1028 preferred_x = None;
1029 }
1030 let paragraph = &self.shaped.paragraphs[index];
1031 let visible = paragraph.projection.text().utf16_offset(next.index())?;
1032 let position = TextPosition {
1033 paragraph: self.source_index(index),
1034 offset: paragraph
1035 .projection
1036 .source_offset(visible, crate::affinity(next.affinity()))?,
1037 };
1038 if !extend {
1039 selection.positions[0] = position;
1040 selection.affinities[0] = next.affinity();
1041 }
1042 selection.positions[1] = position;
1043 selection.affinities[1] = next.affinity();
1044 Ok(Navigation {
1045 selection,
1046 preferred_x,
1047 boundary,
1048 })
1049 }
1050
1051 fn paragraph_at(self, x: f32, y: f32) -> Result<usize, EditError> {
1052 if self.shaped.tables.is_empty() {
1053 return Ok(self
1054 .shaped
1055 .paragraphs
1056 .partition_point(|paragraph| paragraph.origin[1] <= y)
1057 .saturating_sub(1));
1058 }
1059 let paragraphs = self
1060 .shaped
1061 .paragraphs
1062 .iter()
1063 .enumerate()
1064 .map(|(index, paragraph)| (paragraph.id, (index, paragraph)))
1065 .collect::<BTreeMap<_, _>>();
1066 let tables = self
1067 .shaped
1068 .tables
1069 .iter()
1070 .map(|table| (table.id, table))
1071 .collect::<BTreeMap<_, _>>();
1072 let mut nodes = self.source.document.nodes();
1073 loop {
1074 let mut target = None;
1075 for node in visible_paragraphs(nodes.iter()) {
1076 let top = match &node.content {
1077 onestore::page::ParagraphContent::Text(_) => {
1078 paragraphs
1079 .get(&node.id)
1080 .ok_or(EditError::InvalidStructure)?
1081 .1
7761082 .origin[1]
7771083 }
7781084 onestore::page::ParagraphContent::Table(table) => {
......@@ -822,7 +1128,7 @@ impl TextOutline {
8221128 .min_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)))
8231129 .ok_or(EditError::InvalidStructure)?
8241130 .0;
825 nodes = self.document.container(Some(cell.id))?;
1131 nodes = self.source.document.container(Some(cell.id))?;
8261132 }
8271133 }
8281134}
......@@ -2994,37 +3300,7 @@ impl CanvasEditor {
29943300 y: f32,
29953301 unit: SelectionUnit,
29963302 ) -> Result<Selection, EditError> {
2997 if !x.is_finite() || !y.is_finite() {
2998 return Err(EditError::InvalidRange);
2999 }
3000 let index = self.active_outline().paragraph_at(x, y)?;
3001 let paragraph = &self.active_outline().shaped.paragraphs[index];
3002 let layout = &paragraph.text.shaped;
3003 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
3004 let local = edit::selection_at(
3005 layout,
3006 paragraph.text.hit_test(point[0], point[1]),
3007 point[0],
3008 unit,
3009 );
3010 let mut selection = self.selection();
3011 for (slot, cursor) in [local.anchor(), local.focus()].into_iter().enumerate() {
3012 let visible = paragraph.projection.text().utf16_offset(cursor.index())?;
3013 selection.positions[slot] = TextPosition {
3014 paragraph: self.active_outline().source_index(index),
3015 offset: paragraph
3016 .projection
3017 .source_offset(visible, crate::affinity(cursor.affinity()))?,
3018 };
3019 selection.affinities[slot] = cursor.affinity();
3020 }
3021 if unit == SelectionUnit::Paragraph
3022 && let Some(next) = self.active_outline().next_sibling_start(index)
3023 {
3024 selection.positions[1] = next;
3025 selection.affinities[1] = Affinity::Downstream;
3026 }
3027 Ok(selection)
3303 self.active_outline().geometry().selection_at(x, y, unit)
30283304 }
30293305
30303306 pub fn caret(&self, width: f32) -> Result<BoundingBox, EditError> {
......@@ -3048,12 +3324,46 @@ impl CanvasEditor {
30483324 movement: Movement,
30493325 extend: bool,
30503326 ) -> Result<(), EditorError> {
3327 if self.prepare_navigation(movement, extend)? {
3328 return Ok(());
3329 }
3330 self.move_selection_prepared(engine, movement, extend)
3331 }
3332
3333 pub(crate) fn prepare_navigation(
3334 &mut self,
3335 movement: Movement,
3336 extend: bool,
3337 ) -> Result<bool, EditorError> {
30513338 self.finish_composition();
30523339 self.whole = None;
30533340 self.typing = None;
3054 if !extend && movement == Movement::Right && self.leave_equation()? {
3055 return Ok(());
3341 Ok(!extend && movement == Movement::Right && self.leave_equation()?)
3342 }
3343
3344 #[cfg(feature = "interaction")]
3345 pub(crate) fn move_selection_in(
3346 &mut self,
3347 shaped: &OutlineLayout,
3348 movement: Movement,
3349 extend: bool,
3350 ) -> Result<(), EditorError> {
3351 let navigation = OutlineGeometry {
3352 source: self.active_outline(),
3353 shaped,
30563354 }
3355 .navigate(self.selection(), movement, extend, self.preferred_x)?;
3356 self.preferred_x = navigation.preferred_x;
3357 self.active_outline_mut().selection = navigation.selection;
3358 Ok(())
3359 }
3360
3361 pub(crate) fn move_selection_prepared(
3362 &mut self,
3363 engine: &mut TextEngine,
3364 movement: Movement,
3365 extend: bool,
3366 ) -> Result<(), EditorError> {
30573367 let [anchor, focus] = self.active_outline().selection.positions;
30583368 if !extend
30593369 && self.caret_outline().is_some()
......@@ -3113,308 +3423,151 @@ impl CanvasEditor {
31133423 }
31143424 return Ok(());
31153425 }
3116 if matches!(
3426 let navigation = self.active_outline().geometry().navigate(
3427 self.selection(),
31173428 movement,
3118 Movement::ParagraphStart
3119 | Movement::ParagraphEnd
3120 | Movement::DocumentStart
3121 | Movement::DocumentEnd
3122 ) {
3429 extend,
3430 self.preferred_x,
3431 )?;
3432 if navigation.boundary
3433 && matches!(movement, Movement::Up | Movement::Down)
3434 && !extend
3435 && anchor == focus
3436 && !self.active_outline().title
3437 {
3438 let x = navigation
3439 .preferred_x
3440 .expect("vertical navigation retains its column");
31233441 let outline = self.active_outline();
3124 let current = outline.visible_index(focus.paragraph)?;
3125 let end = matches!(movement, Movement::ParagraphEnd | Movement::DocumentEnd);
3126 let current_end = outline.document.paragraph(focus.paragraph).unwrap();
3127 let current_end = current_end.utf16_offset(current_end.text().len())?;
3128 let index = match movement {
3129 Movement::DocumentStart => 0,
3130 Movement::DocumentEnd => outline.shaped.paragraphs.len() - 1,
3131 Movement::ParagraphStart if focus.offset == 0 => current.saturating_sub(1),
3132 Movement::ParagraphEnd if focus.offset == current_end => {
3133 (current + 1).min(outline.shaped.paragraphs.len() - 1)
3134 }
3135 _ => current,
3136 };
3137 let source = outline.source_index(index);
3138 let paragraph = outline.document.paragraph(source).unwrap();
3139 let position = TextPosition {
3140 paragraph: source,
3141 offset: if end {
3142 paragraph.utf16_offset(paragraph.text().len())?
3143 } else {
3144 0
3145 },
3146 };
3147 let affinity = if end {
3148 Affinity::Upstream
3149 } else {
3150 Affinity::Downstream
3151 };
3152 let selection = &mut self.active_outline_mut().selection;
3153 if !extend {
3154 selection.positions[0] = position;
3155 selection.affinities[0] = affinity;
3156 }
3157 selection.positions[1] = position;
3158 selection.affinities[1] = affinity;
3159 self.preferred_x = None;
3160 return Ok(());
3161 }
3162 let horizontal = matches!(
3163 movement,
3164 Movement::Left | Movement::Right | Movement::WordLeft | Movement::WordRight
3165 );
3166 let left = matches!(movement, Movement::Left | Movement::WordLeft);
3167 if horizontal && !extend && anchor.paragraph != focus.paragraph {
3168 let index = usize::from(if left { anchor > focus } else { anchor < focus });
3169 self.active_outline_mut().selection.positions =
3170 [self.active_outline().selection.positions[index]; 2];
3171 self.active_outline_mut().selection.affinities =
3172 [self.active_outline().selection.affinities[index]; 2];
3173 self.preferred_x = None;
3174 return Ok(());
3175 }
3176 let mut index = self.active_outline().visible_index(focus.paragraph)?;
3177 let paragraph = &self.active_outline().shaped.paragraphs[index];
3178 let cursor =
3179 paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?;
3180 let mut next;
3181 if matches!(movement, Movement::Up | Movement::Down) {
3182 let caret = paragraph.text.caret(cursor, 1.0);
3183 let x = self
3184 .preferred_x
3185 .unwrap_or(caret.x0 as f32 + paragraph.origin[0]);
3186 let line = paragraph
3187 .text
3188 .lines()
3189 .position(|(_, line)| f64::from(line.top) == caret.y0)
3190 .unwrap();
3442 let last = outline.document.nodes().len() - 1;
31913443 let up = matches!(movement, Movement::Up);
3192 let target = if up {
3193 line.checked_sub(1)
3194 .and_then(|line| paragraph.text.lines().nth(line))
3195 } else {
3196 paragraph.text.lines().nth(line + 1)
3197 }
3198 .map(|(_, line)| line);
3199 let target = if target.is_some() {
3200 target
3201 } else {
3202 let neighbor = if self.active_outline().shaped.tables.is_empty() {
3203 if up {
3204 index.checked_sub(1)
3205 } else {
3206 (index + 1 < self.active_outline().shaped.paragraphs.len())
3207 .then_some(index + 1)
3208 }
3209 } else {
3210 let column = self.active_outline().column(x, paragraph.id)?;
3211 let current = column
3212 .iter()
3213 .position(|candidate| *candidate == index)
3214 .ok_or(EditError::InvalidStructure)?;
3215 if up {
3216 current.checked_sub(1).map(|i| column[i])
3217 } else {
3218 column.get(current + 1).copied()
3219 }
3220 };
3221 neighbor.and_then(|neighbor| {
3222 index = neighbor;
3223 let paragraph = &self.active_outline().shaped.paragraphs[index];
3224 if up {
3225 paragraph.text.lines().last()
3226 } else {
3227 paragraph.text.lines().next()
3228 }
3229 .map(|(_, line)| line)
3444 let base_node = &outline.document.nodes()[if up { 0 } else { last }];
3445 let node = self.blank_paragraph(base_node)?;
3446 let trailing = outline
3447 .document
3448 .nodes()
3449 .iter()
3450 .rev()
3451 .take_while(|node| {
3452 node.text()
3453 .is_some_and(|text| text.text.text().bytes().all(|byte| byte == b' '))
32303454 })
3231 };
3232 if target.is_none() && !extend && anchor == focus && !self.active_outline().title {
3233 let outline = self.active_outline();
3234 let last = outline.document.nodes().len() - 1;
3235 let up = matches!(movement, Movement::Up);
3236 let base_node = &outline.document.nodes()[if up { 0 } else { last }];
3237 let node = self.blank_paragraph(base_node)?;
3238 let trailing = outline
3239 .document
3240 .nodes()
3241 .iter()
3242 .rev()
3243 .take_while(|node| {
3244 node.text()
3245 .is_some_and(|text| text.text.text().bytes().all(|byte| byte == b' '))
3246 })
3247 .count();
3248 let position = if up {
3249 Some([
3250 outline.origin()[0] + x,
3251 (((f64::from(outline.origin()[1]) / 18.0).ceil() - 3.0) * 18.0 + 14.4)
3252 as f32,
3253 ])
3254 } else if outline.origin()[0] >= 180.0 && trailing >= 2 {
3255 let node = &outline.document.nodes()[last.saturating_sub(trailing)];
3256 let bottom = match &node.content {
3257 onestore::page::ParagraphContent::Text(_) => {
3258 let paragraph = outline
3259 .shaped
3260 .paragraphs
3261 .iter()
3262 .find(|paragraph| paragraph.id == node.id)
3263 .ok_or(EditError::InvalidStructure)?;
3264 f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height())
3265 }
3266 onestore::page::ParagraphContent::Table(table) => {
3267 let cell = outline
3268 .shaped
3269 .tables
3270 .iter()
3271 .find(|layout| layout.id == table.id)
3272 .and_then(|layout| layout.cells.last())
3273 .ok_or(EditError::InvalidStructure)?;
3274 f64::from(cell.rect[3]) + 1.68
3275 }
3276 onestore::page::ParagraphContent::Image(onestore::page::Image {
3277 id,
3278 ..
3279 })
3280 | onestore::page::ParagraphContent::Attachment(
3281 onestore::page::Attachment { id, .. },
3282 )
3283 | onestore::page::ParagraphContent::Ink(onestore::page::Ink {
3284 id, ..
3285 })
3286 | onestore::page::ParagraphContent::Unsupported(
3287 onestore::page::Unsupported { id, .. },
3288 ) => outline
3455 .count();
3456 let position = if up {
3457 Some([
3458 outline.origin()[0] + x,
3459 (((f64::from(outline.origin()[1]) / 18.0).ceil() - 3.0) * 18.0 + 14.4) as f32,
3460 ])
3461 } else if outline.origin()[0] >= 180.0 && trailing >= 2 {
3462 let node = &outline.document.nodes()[last.saturating_sub(trailing)];
3463 let bottom = match &node.content {
3464 onestore::page::ParagraphContent::Text(_) => {
3465 let paragraph = outline
32893466 .shaped
3290 .objects
3467 .paragraphs
32913468 .iter()
3292 .find(|object| object.id == *id)
3293 .map(|object| f64::from(object.bottom))
3294 .ok_or(EditError::InvalidStructure)?,
3295 } + f64::from(outline.origin()[1]);
3296 let cell = (bottom - 14.4) / 18.0;
3297 // Stored coordinates can straddle a grid boundary by one f32 ULP.
3298 let grid = if (cell - cell.round()).abs()
3299 <= f64::from(f32::EPSILON) * cell.abs().max(1.0)
3300 {
3301 cell.round()
3302 } else {
3303 cell.ceil()
3304 };
3305 Some([outline.origin()[0], ((grid + 3.0) * 18.0 + 14.4) as f32])
3306 } else {
3307 None
3308 };
3309 if let Some(position) = position {
3310 let outline = TextOutline::new(
3311 engine,
3312 TextDocument::from_nodes(vec![node])?,
3313 DEFAULT_OUTLINE_WIDTH,
3314 position,
3315 )?;
3316 self.active = Focus::Caret {
3317 outline: Box::new(outline),
3318 index: self.outlines.len(),
3319 };
3320 self.preferred_x = Some(if up { 0.0 } else { x });
3321 return Ok(());
3322 }
3323 let edit = DocumentEdit {
3324 columns: BTreeMap::new(),
3325 container: None,
3326 range: last + 1..last + 1,
3327 replacement: vec![PageParagraph {
3328 parent: base_node.parent,
3329 ..node
3330 }],
3331 };
3332 let paragraph = outline.document.paragraphs().count();
3333 let previous = if let Focus::Outline(index) = self.active {
3334 let draft = self.outlines[index].clone();
3335 Some(std::mem::replace(
3336 &mut self.active,
3337 Focus::Draft {
3338 index,
3339 outline: Box::new(draft),
3340 },
3341 ))
3469 .find(|paragraph| paragraph.id == node.id)
3470 .ok_or(EditError::InvalidStructure)?;
3471 f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height())
3472 }
3473 onestore::page::ParagraphContent::Table(table) => {
3474 let cell = outline
3475 .shaped
3476 .tables
3477 .iter()
3478 .find(|layout| layout.id == table.id)
3479 .and_then(|layout| layout.cells.last())
3480 .ok_or(EditError::InvalidStructure)?;
3481 f64::from(cell.rect[3]) + 1.68
3482 }
3483 onestore::page::ParagraphContent::Image(onestore::page::Image {
3484 id, ..
3485 })
3486 | onestore::page::ParagraphContent::Attachment(onestore::page::Attachment {
3487 id,
3488 ..
3489 })
3490 | onestore::page::ParagraphContent::Ink(onestore::page::Ink { id, .. })
3491 | onestore::page::ParagraphContent::Unsupported(
3492 onestore::page::Unsupported { id, .. },
3493 ) => outline
3494 .shaped
3495 .objects
3496 .iter()
3497 .find(|object| object.id == *id)
3498 .map(|object| f64::from(object.bottom))
3499 .ok_or(EditError::InvalidStructure)?,
3500 } + f64::from(outline.origin()[1]);
3501 let cell = (bottom - 14.4) / 18.0;
3502 // Stored coordinates can straddle a grid boundary by one f32 ULP.
3503 let grid = if (cell - cell.round()).abs()
3504 <= f64::from(f32::EPSILON) * cell.abs().max(1.0)
3505 {
3506 cell.round()
33423507 } else {
3343 None
3508 cell.ceil()
33443509 };
3345 if let Err(error) = self.apply(
3510 Some([outline.origin()[0], ((grid + 3.0) * 18.0 + 14.4) as f32])
3511 } else {
3512 None
3513 };
3514 if let Some(position) = position {
3515 let outline = TextOutline::new(
33463516 engine,
3347 edit,
3348 [TextPosition {
3349 paragraph,
3350 offset: 0,
3351 }; 2]
3352 .into(),
3353 false,
3354 None,
3355 ) {
3356 if let Some(previous) = previous {
3357 self.active = previous;
3358 }
3359 return Err(error);
3360 }
3361 self.preferred_x = Some(x);
3517 TextDocument::from_nodes(vec![node])?,
3518 DEFAULT_OUTLINE_WIDTH,
3519 position,
3520 )?;
3521 self.active = Focus::Caret {
3522 outline: Box::new(outline),
3523 index: self.outlines.len(),
3524 };
3525 self.preferred_x = Some(if up { 0.0 } else { x });
33623526 return Ok(());
33633527 }
3364 next = target
3365 .map(|line| {
3366 self.active_outline().shaped.paragraphs[index]
3367 .text
3368 .hit_test(
3369 x - self.active_outline().shaped.paragraphs[index].origin[0],
3370 line.top + line.height * 0.5,
3371 )
3372 })
3373 .unwrap_or(cursor);
3374 self.preferred_x = Some(x);
3375 } else {
3376 let local = if anchor.paragraph == focus.paragraph {
3377 ParagraphSelection::new(
3378 paragraph
3379 .cursor(anchor.offset, self.active_outline().selection.affinities[0])?,
3380 cursor,
3381 )
3528 let edit = DocumentEdit {
3529 columns: BTreeMap::new(),
3530 container: None,
3531 range: last + 1..last + 1,
3532 replacement: vec![PageParagraph {
3533 parent: base_node.parent,
3534 ..node
3535 }],
3536 };
3537 let paragraph = outline.document.paragraphs().count();
3538 let previous = if let Focus::Outline(index) = self.active {
3539 let draft = self.outlines[index].clone();
3540 Some(std::mem::replace(
3541 &mut self.active,
3542 Focus::Draft {
3543 index,
3544 outline: Box::new(draft),
3545 },
3546 ))
33823547 } else {
3383 cursor.into()
3548 None
33843549 };
3385 next = edit::step(&paragraph.text.shaped, local, movement, extend).focus();
3386 if horizontal && next == cursor && (extend || anchor == focus) {
3387 let neighbor = if left {
3388 index.checked_sub(1)
3389 } else {
3390 (index + 1 < self.active_outline().shaped.paragraphs.len()).then_some(index + 1)
3391 };
3392 if let Some(neighbor) = neighbor {
3393 index = neighbor;
3394 let paragraph = &self.active_outline().shaped.paragraphs[index];
3395 let line = if left {
3396 paragraph.text.lines().last()
3397 } else {
3398 paragraph.text.lines().next()
3399 }
3400 .unwrap()
3401 .1;
3402 next = paragraph.text.hit_test(
3403 if left { f32::MAX } else { -1.0 },
3404 line.top + line.height * 0.5,
3405 );
3406 if matches!(movement, Movement::WordLeft | Movement::WordRight) {
3407 next = edit::word_cursor(&paragraph.text.shaped, next, left);
3408 }
3550 if let Err(error) = self.apply(
3551 engine,
3552 edit,
3553 [TextPosition {
3554 paragraph,
3555 offset: 0,
3556 }; 2]
3557 .into(),
3558 false,
3559 None,
3560 ) {
3561 if let Some(previous) = previous {
3562 self.active = previous;
34093563 }
3564 return Err(error);
34103565 }
3566 self.preferred_x = Some(x);
3567 return Ok(());
34113568 }
3412 if !extend
3413 && anchor == focus
3414 && next == cursor
3415 && self.active_outline().source_index(index) == focus.paragraph
3416 && matches!(movement, Movement::Left | Movement::Right)
3417 {
3569 if navigation.boundary && matches!(movement, Movement::Left | Movement::Right) {
3570 let left = movement == Movement::Left;
34183571 let caret = self.caret(1.0)?;
34193572 let origin = self.active_outline().origin();
34203573 let x = caret.x0 + f64::from(origin[0]);
......@@ -3443,41 +3596,17 @@ impl CanvasEditor {
34433596 return Ok(());
34443597 }
34453598 }
3446 if !matches!(movement, Movement::Up | Movement::Down) {
3447 self.preferred_x = None;
3448 }
3449 let paragraph = &self.active_outline().shaped.paragraphs[index];
3450 let visible = paragraph.projection.text().utf16_offset(next.index())?;
3451 let position = TextPosition {
3452 paragraph: self.active_outline().source_index(index),
3453 offset: paragraph
3454 .projection
3455 .source_offset(visible, crate::affinity(next.affinity()))?,
3456 };
3457 if !extend {
3458 self.active_outline_mut().selection.positions[0] = position;
3459 self.active_outline_mut().selection.affinities[0] = next.affinity();
3460 }
3461 self.active_outline_mut().selection.positions[1] = position;
3462 self.active_outline_mut().selection.affinities[1] = next.affinity();
3599 self.preferred_x = navigation.preferred_x;
3600 self.active_outline_mut().selection = navigation.selection;
34633601 Ok(())
34643602 }
34653603
3604 #[cfg(test)]
34663605 pub(crate) fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
34673606 self.active_outline()
34683607 .range_rects(self.active_outline().selection)
34693608 }
34703609
3471 pub(crate) fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
3472 match &self.composition {
3473 Some(composition) => self.active_outline().range_rects(Selection {
3474 positions: [composition.range.start, composition.range.end],
3475 affinities: [Affinity::Downstream, Affinity::Upstream],
3476 }),
3477 None => Ok(Vec::new()),
3478 }
3479 }
3480
34813610 pub fn replace(
34823611 &mut self,
34833612 engine: &mut TextEngine,
......@@ -4993,10 +5122,8 @@ fn holds(content: &ParagraphContent, id: ExGuid) -> bool {
49935122 }
49945123}
49955124
4996/// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same
4997/// paragraph, past what changed in its text when it lies after it; at the start of the
4998/// paragraph now at its place when that one is gone.
4999fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition {
5125/// Follows paragraph IDs and UTF-16 through edits; deleted paragraphs clamp to their surviving index.
5126pub fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition {
50005127 let found = old.leaf(position.paragraph).and_then(|(_, _, node)| {
50015128 let paragraph = new
50025129 .text_nodes()
......@@ -5087,6 +5214,660 @@ mod tests {
50875214 use super::*;
50885215 use onestore::document::Format;
50895216
5217 fn alternate_shape(
5218 engine: &mut TextEngine,
5219 editor: &CanvasEditor,
5220 width: f32,
5221 ) -> OutlineLayout {
5222 let outline = editor.active_outline();
5223 OutlineLayout::flow(
5224 outline.document.nodes().iter(),
5225 &outline.indents,
5226 width,
5227 true,
5228 0,
5229 None,
5230 &mut |node, previous, width, indents| {
5231 ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions)
5232 },
5233 )
5234 .unwrap()
5235 }
5236
5237 #[test]
5238 fn alternate_geometry_keeps_source_utf16_and_blank_paragraphs() {
5239 let mut engine = TextEngine::default();
5240 let document = TextDocument::new(vec![
5241 Paragraph::from_runs([
5242 ("alpha ".into(), Format::default()),
5243 (
5244 "😀 e\u{301} ".into(),
5245 Format {
5246 bold: Some(true),
5247 ..Format::default()
5248 },
5249 ),
5250 ("omega".into(), Format::default()),
5251 ]),
5252 Paragraph::new(String::new(), Format::default()),
5253 Paragraph::new(String::new(), Format::default()),
5254 Paragraph::new("last".into(), Format::default()),
5255 Paragraph::new("left אבג right 😀".into(), Format::default()),
5256 ])
5257 .unwrap();
5258 let mut editor = CanvasEditor::new(&mut engine, document.clone(), 1000.0).unwrap();
5259 let selection = [
5260 TextPosition {
5261 paragraph: 4,
5262 offset: 5,
5263 },
5264 TextPosition {
5265 paragraph: 0,
5266 offset: 6,
5267 },
5268 ]
5269 .into();
5270 editor.select(selection).unwrap();
5271 let page = editor.page().unwrap();
5272 let canonical = layout_snapshot(&editor.active_outline().shaped);
5273 let ids = editor
5274 .active_outline()
5275 .layouts()
5276 .map(|(index, paragraph)| (index, paragraph.id))
5277 .collect::<Vec<_>>();
5278 let text = "alpha 😀 e\u{301} omega\n\n\nlast\nleft אבג right 😀";
5279 assert_eq!(editor.active_outline().shown_text(), text);
5280 let mut heights = Vec::new();
5281 for width in [70.0, 230.0] {
5282 let shaped = alternate_shape(&mut engine, &editor, width);
5283 let geometry = OutlineGeometry {
5284 source: editor.active_outline(),
5285 shaped: &shaped,
5286 };
5287 assert_eq!(
5288 geometry
5289 .layouts()
5290 .map(|(index, paragraph)| (index, paragraph.id))
5291 .collect::<Vec<_>>(),
5292 ids
5293 );
5294 for index in [1, 2] {
5295 let paragraph = geometry.paragraph_layout(index).unwrap();
5296 assert!(paragraph.projection.text().text().is_empty());
5297 assert_eq!(paragraph.text.lines().count(), 1);
5298 }
5299 for (index, paragraph) in geometry.layouts() {
5300 for (byte, offset) in paragraph.projection.source_boundaries() {
5301 for affinity in [Affinity::Upstream, Affinity::Downstream] {
5302 let cursor = paragraph.cursor(offset, affinity).unwrap();
5303 if cursor.index() != byte {
5304 continue;
5305 }
5306 let position = TextPosition {
5307 paragraph: index,
5308 offset,
5309 };
5310 let caret = geometry.caret_at(position, affinity, 1.0).unwrap();
5311 let hit = geometry
5312 .selection_at(
5313 caret.x0 as f32 + 0.01,
5314 ((caret.y0 + caret.y1) * 0.5) as f32,
5315 SelectionUnit::Grapheme,
5316 )
5317 .unwrap();
5318 assert_eq!(hit.positions[1].paragraph, index);
5319 // Bidi and soft-wrap boundaries can share a point with another source caret.
5320 let recovered = geometry
5321 .caret_at(hit.positions[1], hit.affinities[1], 1.0)
5322 .unwrap();
5323 assert!(
5324 (caret.x0 - recovered.x0).abs() < 0.05,
5325 "{position:?} {affinity:?} -> {hit:?}"
5326 );
5327 assert!(
5328 (caret.y0 - recovered.y0).abs() < 0.05,
5329 "{position:?} {affinity:?} -> {hit:?}"
5330 );
5331 let source = document.paragraph(index).unwrap();
5332 source.byte_offset(hit.positions[1].offset).unwrap();
5333 let flat = geometry.source.utf16_offset(position).unwrap();
5334 assert_eq!(geometry.source.utf16_position(flat).unwrap(), position);
5335 }
5336 }
5337 }
5338 assert!(matches!(
5339 geometry.selection_at(f32::NAN, 0.0, SelectionUnit::Grapheme),
5340 Err(EditError::InvalidRange)
5341 ));
5342 heights.push(shaped.size[1]);
5343 assert_eq!(editor.page().unwrap(), page);
5344 assert_eq!(layout_snapshot(&editor.active_outline().shaped), canonical);
5345 assert_eq!(editor.selection(), selection);
5346 assert_eq!(editor.history_depth(), [0, 0]);
5347 assert!(editor.take_ops().unwrap().is_empty());
5348 }
5349 assert!(heights[0] > heights[1]);
5350 assert_eq!(editor.active_outline().document, document);
5351 assert_eq!(
5352 editor.active_outline().shown_text().encode_utf16().count(),
5353 text.encode_utf16().count()
5354 );
5355 }
5356
5357 #[test]
5358 fn alternate_range_and_marked_geometry_use_display_lines() {
5359 let mut engine = TextEngine::default();
5360 let document = TextDocument::new(vec![
5361 Paragraph::from_runs([
5362 ("alpha 😀 ".into(), Format::default()),
5363 (
5364 "a rich run with enough words to wrap several times in a narrow column and twice in a wider column".into(),
5365 Format { bold: Some(true), ..Format::default() },
5366 ),
5367 ]),
5368 Paragraph::new(String::new(), Format::default()),
5369 Paragraph::new("last".into(), Format::default()),
5370 ]).unwrap();
5371 let page = CanvasEditor::new(&mut engine, document, 1000.0)
5372 .unwrap()
5373 .page()
5374 .unwrap();
5375 let selection: Selection = [
5376 TextPosition {
5377 paragraph: 2,
5378 offset: 4,
5379 },
5380 TextPosition {
5381 paragraph: 0,
5382 offset: 6,
5383 },
5384 ]
5385 .into();
5386 let on_lines = |shaped: &OutlineLayout, rectangles: &[BoundingBox]| {
5387 assert!(!rectangles.is_empty());
5388 for rect in rectangles {
5389 assert!(shaped.paragraphs.iter().any(|paragraph| {
5390 paragraph.text.lines().any(|(_, line)| {
5391 let top = f64::from(paragraph.origin[1] + line.top);
5392 (rect.y0 - top).abs() < 0.001
5393 && (rect.y1 - top - f64::from(line.height)).abs() < 0.001
5394 })
5395 }));
5396 }
5397 };
5398 for commit in [false, true] {
5399 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
5400 editor.insert(&mut engine, "!").unwrap();
5401 editor.undo(&mut engine).unwrap();
5402 editor.take_ops().unwrap();
5403 editor.select(selection).unwrap();
5404 let original = editor.page().unwrap();
5405 let canonical = editor.selection_rects().unwrap();
5406 let mut counts = Vec::new();
5407 for width in [70.0, 230.0] {
5408 let shaped = alternate_shape(&mut engine, &editor, width);
5409 let geometry = OutlineGeometry {
5410 source: editor.active_outline(),
5411 shaped: &shaped,
5412 };
5413 let rectangles = geometry.range_rects(selection).unwrap();
5414 on_lines(&shaped, &rectangles);
5415 assert_ne!(rectangles, canonical);
5416 let blank = geometry
5417 .caret_at(
5418 TextPosition {
5419 paragraph: 1,
5420 offset: 0,
5421 },
5422 Affinity::Downstream,
5423 4.0,
5424 )
5425 .unwrap();
5426 assert!(rectangles.contains(&blank));
5427 counts.push(rectangles.len());
5428 }
5429 assert!(counts[0] > counts[1]);
5430 assert_eq!(editor.history_depth(), [0, 1]);
5431 assert!(editor.take_ops().unwrap().is_empty());
5432 let preview =
5433 "marked 😀 preview with several words wrapping onto other lines\nsecond line";
5434 let end = preview.encode_utf16().count().try_into().unwrap();
5435 editor
5436 .compose(&mut engine, preview.into(), end..end)
5437 .unwrap();
5438 let marked = editor.marked_range().unwrap();
5439 let preview_page = editor.page().unwrap();
5440 let preview_selection = editor.selection();
5441 let canonical = layout_snapshot(&editor.active_outline().shaped);
5442 for width in [70.0, 230.0] {
5443 let shaped = alternate_shape(&mut engine, &editor, width);
5444 let geometry = OutlineGeometry {
5445 source: editor.active_outline(),
5446 shaped: &shaped,
5447 };
5448 on_lines(
5449 &shaped,
5450 &geometry
5451 .range_rects(Selection {
5452 positions: [marked.start, marked.end],
5453 affinities: [Affinity::Downstream, Affinity::Upstream],
5454 })
5455 .unwrap(),
5456 );
5457 for (index, paragraph) in geometry.layouts() {
5458 let source = geometry.source.document.paragraph(index).unwrap();
5459 let range = if index == marked.start.paragraph {
5460 marked.start.offset
5461 } else {
5462 0
5463 }..if index == marked.end.paragraph {
5464 marked.end.offset
5465 } else {
5466 source.utf16_offset(source.text().len()).unwrap()
5467 };
5468 for [_, _, baseline] in geometry.underlines(index, range).unwrap() {
5469 assert!(paragraph.text.lines().any(|(_, line)| {
5470 (baseline - paragraph.origin[1] - line.baseline).abs() < 0.001
5471 }));
5472 }
5473 }
5474 assert_eq!(editor.page().unwrap(), preview_page);
5475 assert_eq!(editor.selection(), preview_selection);
5476 assert_eq!(editor.marked_range(), Some(marked.clone()));
5477 assert_eq!(editor.history_depth(), [0, 1]);
5478 assert_eq!(layout_snapshot(&editor.active_outline().shaped), canonical);
5479 assert!(editor.take_ops().unwrap().is_empty());
5480 }
5481 if commit {
5482 editor.finish_composition();
5483 assert!(editor.marked_range().is_none());
5484 assert_eq!(editor.history_depth(), [1, 0]);
5485 assert!(!editor.take_ops().unwrap().is_empty());
5486 let committed = editor.page().unwrap();
5487 editor.undo(&mut engine).unwrap();
5488 assert_eq!(editor.page().unwrap(), original);
5489 assert_eq!(editor.selection(), selection);
5490 editor.redo(&mut engine).unwrap();
5491 assert_eq!(editor.page().unwrap(), committed);
5492 } else {
5493 assert!(editor.cancel_composition(&mut engine).unwrap());
5494 assert_eq!(editor.page().unwrap(), original);
5495 assert_eq!(editor.selection(), selection);
5496 assert_eq!(editor.history_depth(), [0, 1]);
5497 assert!(editor.take_ops().unwrap().is_empty());
5498 editor.redo(&mut engine).unwrap();
5499 assert!(
5500 editor
5501 .active_outline()
5502 .document
5503 .paragraph(0)
5504 .unwrap()
5505 .text()
5506 .starts_with("!alpha")
5507 );
5508 }
5509 }
5510 }
5511
5512 #[test]
5513 fn display_title_height_never_changes_canonical_placements() {
5514 let mut engine = TextEngine::default();
5515 let mut title = TextOutline::new(
5516 &mut engine,
5517 TextDocument::new(vec![Paragraph::new("Header".into(), Format::default())]).unwrap(),
5518 468.0,
5519 [0.0; 2],
5520 )
5521 .unwrap()
5522 .snapshot();
5523 title.title = true;
5524 title.layout.max_width = None;
5525 title.layout.width_set_by_user = None;
5526 let body = TextOutline::new(
5527 &mut engine,
5528 TextDocument::new(vec![Paragraph::new("Anchor".into(), Format::default())]).unwrap(),
5529 72.0,
5530 [120.0, 80.0],
5531 )
5532 .unwrap()
5533 .snapshot();
5534 let page = Page {
5535 title: "Header".into(),
5536 identity: None,
5537 created: None,
5538 margin_origin: [36.0, 14.4],
5539 rtl: false,
5540 color: None,
5541 rule_lines: None,
5542 definitions: BTreeMap::new(),
5543 objects: vec![
5544 PageObject::Title(Title {
5545 id: onestore::page::text::new_id().unwrap(),
5546 date: None,
5547 layout: Default::default(),
5548 outlines: vec![title],
5549 }),
5550 PageObject::Outline(body),
5551 ],
5552 };
5553 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);
5554 let mut results = Vec::new();
5555 let mut heights = Vec::new();
5556 for width in [70.0, 230.0] {
5557 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
5558 let original = editor.page().unwrap();
5559 let original_shape = layout_snapshot(&editor.active_outline().shaped);
5560 let shaped = alternate_shape(&mut engine, &editor, width);
5561 OutlineGeometry {
5562 source: editor.active_outline(),
5563 shaped: &shaped,
5564 }
5565 .caret_at(
5566 TextPosition {
5567 paragraph: 0,
5568 offset: 0,
5569 },
5570 Affinity::Downstream,
5571 1.0,
5572 )
5573 .unwrap();
5574 assert_eq!(editor.page().unwrap(), original);
5575 assert_eq!(editor.history_depth(), [0, 0]);
5576 assert!(editor.take_ops().unwrap().is_empty());
5577 editor.select_all().unwrap();
5578 editor.insert(&mut engine, &text).unwrap();
5579 let shaped = alternate_shape(&mut engine, &editor, width);
5580 let geometry = OutlineGeometry {
5581 source: editor.active_outline(),
5582 shaped: &shaped,
5583 };
5584 geometry
5585 .range_rects(editor.active_outline().whole())
5586 .unwrap();
5587 heights.push(shaped.size[1]);
5588 let edited = editor.page().unwrap();
5589 let edited_shape = layout_snapshot(&editor.active_outline().shaped);
5590 let edit_ops = editor.take_ops().unwrap();
5591 assert!(!edit_ops.is_empty());
5592 assert_eq!(editor.history_depth(), [1, 0]);
5593 editor.undo(&mut engine).unwrap();
5594 assert_eq!(editor.page().unwrap(), original);
5595 assert_eq!(
5596 layout_snapshot(&editor.active_outline().shaped),
5597 original_shape
5598 );
5599 let undo_ops = editor.take_ops().unwrap();
5600 assert_eq!(editor.history_depth(), [0, 1]);
5601 editor.redo(&mut engine).unwrap();
5602 assert_eq!(editor.page().unwrap(), edited);
5603 assert_eq!(
5604 layout_snapshot(&editor.active_outline().shaped),
5605 edited_shape
5606 );
5607 assert_eq!(editor.history_depth(), [1, 0]);
5608 results.push((
5609 edited,
5610 edited_shape,
5611 edit_ops,
5612 undo_ops,
5613 editor.take_ops().unwrap(),
5614 ));
5615 }
5616 assert!(heights[0] > heights[1]);
5617 assert_eq!(results[0], results[1]);
5618 }
5619
5620 #[test]
5621 fn alternate_navigation_follows_display_lines_and_keeps_source_anchors() {
5622 let mut engine = TextEngine::default();
5623 let document = TextDocument::new(vec![
5624 Paragraph::from_runs([
5625 ("alpha 😀 e\u{301} ".into(), Format::default()),
5626 (
5627 "many words cross several display lines while the canonical outline stays wide and unchanged ".repeat(3),
5628 Format { bold: Some(true), ..Format::default() },
5629 ),
5630 ("אבג end".into(), Format::default()),
5631 ]),
5632 Paragraph::new(String::new(), Format::default()),
5633 Paragraph::new(String::new(), Format::default()),
5634 Paragraph::new("last".into(), Format::default()),
5635 ]).unwrap();
5636 let mut editor = CanvasEditor::new(&mut engine, document, 2000.0).unwrap();
5637 let first = TextPosition {
5638 paragraph: 0,
5639 offset: 0,
5640 };
5641 let source_selection: Selection = [first; 2].into();
5642 let original = editor.page().unwrap();
5643 let canonical = layout_snapshot(&editor.active_outline().shaped);
5644 for width in [70.0, 230.0] {
5645 let shaped = alternate_shape(&mut engine, &editor, width);
5646 let geometry = OutlineGeometry {
5647 source: editor.active_outline(),
5648 shaped: &shaped,
5649 };
5650 let paragraph = geometry.paragraph_layout(0).unwrap();
5651 let caret = geometry.caret_at(first, Affinity::Downstream, 1.0).unwrap();
5652 let (_, second_line) = paragraph.text.lines().nth(1).unwrap();
5653 let hit = geometry
5654 .selection_at(
5655 caret.x0 as f32,
5656 paragraph.origin[1] + second_line.top + second_line.height * 0.5,
5657 SelectionUnit::Grapheme,
5658 )
5659 .unwrap();
5660 let down = geometry
5661 .navigate(source_selection, Movement::Down, false, None)
5662 .unwrap();
5663 assert_eq!(down.selection, hit);
5664 assert_eq!(down.selection.positions[1].paragraph, 0);
5665 assert_eq!(down.preferred_x, Some(caret.x0 as f32));
5666 assert!(!down.boundary);
5667 let extending = Selection {
5668 positions: [first, down.selection.positions[1]],
5669 affinities: [Affinity::Downstream, down.selection.affinities[1]],
5670 };
5671 let next = geometry
5672 .navigate(extending, Movement::Down, true, down.preferred_x)
5673 .unwrap();
5674 assert_eq!(next.selection.positions[0], first);
5675 assert_eq!(next.selection.affinities[0], Affinity::Downstream);
5676 assert_eq!(next.preferred_x, down.preferred_x);
5677 let next_caret = geometry
5678 .caret_at(
5679 next.selection.positions[1],
5680 next.selection.affinities[1],
5681 1.0,
5682 )
5683 .unwrap();
5684 let (_, third_line) = paragraph.text.lines().nth(2).unwrap();
5685 assert!(
5686 (next_caret.y0 - f64::from(paragraph.origin[1] + third_line.top)).abs() < 0.001
5687 );
5688 for movement in [Movement::LineStart, Movement::LineEnd] {
5689 let line = geometry
5690 .navigate(down.selection, movement, false, down.preferred_x)
5691 .unwrap();
5692 let line_caret = geometry
5693 .caret_at(
5694 line.selection.positions[1],
5695 line.selection.affinities[1],
5696 1.0,
5697 )
5698 .unwrap();
5699 assert!(
5700 (line_caret.y0 - f64::from(paragraph.origin[1] + second_line.top)).abs()
5701 < 0.001
5702 );
5703 assert_eq!(line.preferred_x, None);
5704 assert!(!line.boundary);
5705 }
5706 let last = TextPosition {
5707 paragraph: 3,
5708 offset: 4,
5709 };
5710 let reversed: Selection = [last, first].into();
5711 for (movement, expected) in [
5712 (Movement::Left, first),
5713 (Movement::WordLeft, first),
5714 (Movement::Right, last),
5715 (Movement::WordRight, last),
5716 ] {
5717 let collapsed = geometry
5718 .navigate(reversed, movement, false, Some(15.0))
5719 .unwrap();
5720 assert_eq!(collapsed.selection.positions, [expected; 2]);
5721 assert_eq!(collapsed.preferred_x, None);
5722 assert!(!collapsed.boundary);
5723 }
5724 for movement in [
5725 Movement::Left,
5726 Movement::Right,
5727 Movement::WordLeft,
5728 Movement::WordRight,
5729 Movement::ParagraphStart,
5730 Movement::ParagraphEnd,
5731 Movement::DocumentStart,
5732 Movement::DocumentEnd,
5733 ] {
5734 let moved = geometry.navigate(reversed, movement, true, None).unwrap();
5735 assert_eq!(moved.selection.positions[0], last);
5736 assert_eq!(moved.selection.affinities[0], reversed.affinities[0]);
5737 for position in moved.selection.positions {
5738 geometry
5739 .source
5740 .document
5741 .paragraph(position.paragraph)
5742 .unwrap()
5743 .byte_offset(position.offset)
5744 .unwrap();
5745 }
5746 }
5747 assert_eq!(editor.page().unwrap(), original);
5748 assert_eq!(layout_snapshot(&editor.active_outline().shaped), canonical);
5749 assert_eq!(editor.selection(), source_selection);
5750 assert_eq!(editor.history_depth(), [0, 0]);
5751 assert!(editor.take_ops().unwrap().is_empty());
5752 }
5753 editor
5754 .compose(&mut engine, "marked 😀\npreview".into(), 0..0)
5755 .unwrap();
5756 let marked = editor.marked_range();
5757 let preview = editor.page().unwrap();
5758 let selection = editor.selection();
5759 for width in [70.0, 230.0] {
5760 let shaped = alternate_shape(&mut engine, &editor, width);
5761 let geometry = OutlineGeometry {
5762 source: editor.active_outline(),
5763 shaped: &shaped,
5764 };
5765 for movement in [
5766 Movement::Up,
5767 Movement::Down,
5768 Movement::LineStart,
5769 Movement::LineEnd,
5770 ] {
5771 geometry.navigate(selection, movement, true, None).unwrap();
5772 }
5773 assert_eq!(editor.marked_range(), marked);
5774 assert_eq!(editor.page().unwrap(), preview);
5775 assert_eq!(editor.selection(), selection);
5776 assert_eq!(editor.history_depth(), [0, 0]);
5777 assert!(editor.take_ops().unwrap().is_empty());
5778 }
5779 editor.cancel_composition(&mut engine).unwrap();
5780 assert_eq!(editor.page().unwrap(), original);
5781 }
5782
5783 #[test]
5784 fn alternate_navigation_boundaries_do_not_create_spatial_content() {
5785 let mut engine = TextEngine::default();
5786 for title in [false, true] {
5787 for text in ["", "alpha 😀\nlast"] {
5788 let mut outline = TextOutline::new(
5789 &mut engine,
5790 TextDocument::new(
5791 text.split('\n')
5792 .map(|part| Paragraph::new(part.into(), Format::default()))
5793 .collect(),
5794 )
5795 .unwrap(),
5796 300.0,
5797 [220.0, 180.0],
5798 )
5799 .unwrap();
5800 outline.title = title;
5801 let mut editor =
5802 CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), None).unwrap();
5803 let original = editor.page().unwrap();
5804 let source = editor.active_outline().snapshot();
5805 let canonical = layout_snapshot(&editor.active_outline().shaped);
5806 for width in [70.0, 230.0] {
5807 let shaped = alternate_shape(&mut engine, &editor, width);
5808 let geometry = OutlineGeometry {
5809 source: editor.active_outline(),
5810 shaped: &shaped,
5811 };
5812 let start = TextPosition {
5813 paragraph: 0,
5814 offset: 0,
5815 };
5816 let end = geometry
5817 .navigate([start; 2].into(), Movement::DocumentEnd, false, None)
5818 .unwrap()
5819 .selection;
5820 for (movement, selection) in
5821 [(Movement::Up, [start; 2].into()), (Movement::Down, end)]
5822 {
5823 for extend in [false, true] {
5824 let edge = geometry
5825 .navigate(selection, movement, extend, Some(11.0))
5826 .unwrap();
5827 assert!(edge.boundary);
5828 assert_eq!(edge.selection.positions, selection.positions);
5829 let focus = selection.positions[1];
5830 let affinity = geometry
5831 .paragraph_layout(focus.paragraph)
5832 .unwrap()
5833 .cursor(focus.offset, selection.affinities[1])
5834 .unwrap()
5835 .affinity();
5836 assert_eq!(edge.selection.affinities[1], affinity);
5837 assert_eq!(
5838 edge.selection.affinities[0],
5839 if extend {
5840 selection.affinities[0]
5841 } else {
5842 affinity
5843 }
5844 );
5845 assert_eq!(edge.preferred_x, Some(11.0));
5846 }
5847 }
5848 assert_eq!(
5849 geometry
5850 .navigate(
5851 [start; 2].into(),
5852 Movement::DocumentStart,
5853 false,
5854 Some(11.0)
5855 )
5856 .unwrap()
5857 .selection
5858 .positions,
5859 [start; 2]
5860 );
5861 assert_eq!(editor.active_outline().snapshot(), source);
5862 assert_eq!(editor.page().unwrap(), original);
5863 assert_eq!(layout_snapshot(&editor.active_outline().shaped), canonical);
5864 assert_eq!(editor.history_depth(), [0, 0]);
5865 assert!(editor.take_ops().unwrap().is_empty());
5866 }
5867 }
5868 }
5869 }
5870
50905871 fn table_editor(engine: &mut TextEngine) -> CanvasEditor {
50915872 use onestore::page::text::new_id;
50925873 use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
crates/canvas/src/editor/link.rs+49-43
......@@ -191,49 +191,11 @@ impl CanvasEditor {
191191 })
192192 }
193193
194 /// The address of the link drawn under document point `point` of outline `id`, which a
195 /// click opens as OneNote's does.
196 pub(crate) fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option<String> {
197 let outline = self.visible_outlines().find(|outline| outline.id == id)?;
198 let index = outline.paragraph_at(x, y).ok()?;
199 let paragraph = &outline.shaped.paragraphs[index];
200 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
201 let cursor = paragraph.text.hit_test(point[0], point[1]);
202 let edge = paragraph.text.caret(cursor, 0.0).x0 as f32;
203 let visible = paragraph.projection.text();
204 let byte = cursor.index();
205 // The character under the point lies on the side of the nearest boundary it is on.
206 let at = if point[0] < edge {
207 visible.text()[..byte].char_indices().next_back()?.0
208 } else {
209 visible.text()[byte..].chars().next()?;
210 byte
211 };
212 let (line, _) = paragraph
213 .text
214 .lines()
215 .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?;
216 if point[0] > line.metrics().advance {
217 return None;
218 }
219 let source = paragraph
220 .projection
221 .source_offset(
222 visible.utf16_offset(at).ok()?,
223 onestore::page::text::Affinity::Downstream,
224 )
225 .ok()?;
226 let paragraph = outline.source_index(index);
227 links(outline.document.paragraph(paragraph)?, paragraph)
228 .into_iter()
229 .find(|link| link.label.contains(&source))
230 .map(|link| link.target)
231 .or_else(|| {
232 shown_urls(visible)
233 .into_iter()
234 .find(|url| url.contains(&at))
235 .map(|url| visible.text()[url].to_owned())
236 })
194 pub(crate) fn link_under(&self, id: ExGuid, point: [f32; 2]) -> Option<String> {
195 self.visible_outlines()
196 .find(|outline| outline.id == id)?
197 .geometry()
198 .link_under(point)
237199 }
238200
239201 /// What the Link dialog links, as OneNote 2010 picks it: the selection within one
......@@ -511,6 +473,50 @@ impl CanvasEditor {
511473 }
512474}
513475
476impl super::OutlineGeometry<'_> {
477 pub(crate) fn link_under(self, [x, y]: [f32; 2]) -> Option<String> {
478 let index = self.paragraph_at(x, y).ok()?;
479 let paragraph = &self.shaped.paragraphs[index];
480 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
481 let cursor = paragraph.text.hit_test(point[0], point[1]);
482 let edge = paragraph.text.caret(cursor, 0.0).x0 as f32;
483 let visible = paragraph.projection.text();
484 let byte = cursor.index();
485 // The character under the point lies on the side of the nearest boundary it is on.
486 let at = if point[0] < edge {
487 visible.text()[..byte].char_indices().next_back()?.0
488 } else {
489 visible.text()[byte..].chars().next()?;
490 byte
491 };
492 let (line, _) = paragraph
493 .text
494 .lines()
495 .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?;
496 if point[0] > line.metrics().advance {
497 return None;
498 }
499 let source = paragraph
500 .projection
501 .source_offset(
502 visible.utf16_offset(at).ok()?,
503 onestore::page::text::Affinity::Downstream,
504 )
505 .ok()?;
506 let paragraph = self.source_index(index);
507 links(self.source.document().paragraph(paragraph)?, paragraph)
508 .into_iter()
509 .find(|link| link.label.contains(&source))
510 .map(|link| link.target)
511 .or_else(|| {
512 shown_urls(visible)
513 .into_iter()
514 .find(|url| url.contains(&at))
515 .map(|url| visible.text()[url].to_owned())
516 })
517 }
518}
519
514520#[cfg(test)]
515521mod tests {
516522 use super::*;
crates/canvas/src/gpu/page.rs+98-18
......@@ -104,12 +104,18 @@ fn outline_origin(
104104 mut origin: [f32; 2],
105105 title: Option<onestore::ExGuid>,
106106 editor: Option<&CanvasEditor>,
107 column: Option<&crate::reading::Column>,
107108) -> Result<[f32; 2], SceneError> {
108109 if let Some(id) = title {
109110 let title = editor
110111 .and_then(|editor| editor.visible_outlines().find(|outline| outline.id == id))
111112 .ok_or(SceneError::MissingOutline)?;
112 origin[1] += title.bounds().y1 as f32;
113 let (geometry, projected) = crate::reading::geometry(title, column);
114 origin[1] += if column.is_some() {
115 projected[1] + geometry.shaped.size[1]
116 } else {
117 title.bounds().y1 as f32
118 };
113119 }
114120 Ok(origin)
115121}
......@@ -524,7 +530,7 @@ impl PageScene {
524530 below_title,
525531 } => {
526532 let origin = crate::origin(&source.layout);
527 if let Ok(origin) = outline_origin(origin, *below_title, editor) {
533 if let Ok(origin) = outline_origin(origin, *below_title, editor, None) {
528534 outline(layout, origin, &mut rects);
529535 }
530536 }
......@@ -667,6 +673,7 @@ impl PageScene {
667673 pub(crate) fn content_bounds<'a>(
668674 &'a self,
669675 editor: &'a CanvasEditor,
676 column: Option<&'a crate::reading::Column>,
670677 ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a {
671678 self.objects(Some(editor))
672679 .into_iter()
......@@ -677,9 +684,13 @@ impl PageScene {
677684 layout,
678685 below_title,
679686 } => {
680 let origin =
681 outline_origin(crate::origin(&source.layout), *below_title, Some(editor))
682 .ok()?;
687 let origin = outline_origin(
688 crate::origin(&source.layout),
689 *below_title,
690 Some(editor),
691 column,
692 )
693 .ok()?;
683694 Some((
684695 [
685696 origin[0],
......@@ -696,6 +707,7 @@ impl PageScene {
696707 crate::origin(&date.source().layout),
697708 *below_title,
698709 Some(editor),
710 column,
699711 )
700712 .ok()?;
701713 let size = date.layout().size;
......@@ -721,9 +733,53 @@ impl PageScene {
721733 })
722734 }
723735
736 pub(crate) fn header_text<'a>(
737 &'a self,
738 editor: &'a CanvasEditor,
739 column: &'a crate::reading::Column,
740 ) -> impl Iterator<Item = (String, [f32; 4])> + 'a {
741 self.objects(Some(editor))
742 .into_iter()
743 .flatten()
744 .filter_map(move |object| {
745 let Content::Outline {
746 source,
747 layout,
748 below_title: Some(title),
749 } = object
750 else {
751 return None;
752 };
753 let origin = outline_origin(
754 crate::origin(&source.layout),
755 Some(*title),
756 Some(editor),
757 Some(column),
758 )
759 .ok()?;
760 let text = source
761 .paragraphs
762 .iter()
763 .filter_map(|paragraph| paragraph.text())
764 .map(|text| text.text.text())
765 .collect::<Vec<_>>()
766 .join("\n");
767 Some((
768 text,
769 [
770 origin[0],
771 origin[1],
772 origin[0] + layout.size[0],
773 origin[1] + layout.size[1],
774 ],
775 ))
776 })
777 }
778
724779 pub(crate) fn date_fields(
725780 &self,
726781 editor: &CanvasEditor,
782 column: Option<&crate::reading::Column>,
727783 ) -> impl Iterator<Item = (DateField, [f32; 4])> {
728784 let mut fields = [None; 2];
729785 if let Some(date) = editor.date()
......@@ -737,7 +793,7 @@ impl PageScene {
737793 }
738794 _ => None,
739795 })
740 && let Ok(origin) = outline_origin(origin, title, Some(editor))
796 && let Ok(origin) = outline_origin(origin, title, Some(editor), column)
741797 {
742798 for (slot, (field, paragraph)) in fields.iter_mut().zip(
743799 date.fields()
......@@ -765,6 +821,16 @@ impl PageScene {
765821 &self,
766822 point: [f32; 2],
767823 editor: Option<&CanvasEditor>,
824 outline: impl FnMut(onestore::ExGuid) -> Option<T>,
825 ) -> Option<SceneHit<T>> {
826 self.hit_test_with(point, editor, None, outline)
827 }
828
829 pub(crate) fn hit_test_with<T>(
830 &self,
831 point: [f32; 2],
832 editor: Option<&CanvasEditor>,
833 column: Option<&crate::reading::Column>,
768834 mut outline: impl FnMut(onestore::ExGuid) -> Option<T>,
769835 ) -> Option<SceneHit<T>> {
770836 let check = self.object_tags(editor, None).into_iter().find(|(_, tag)| {
......@@ -780,10 +846,11 @@ impl PageScene {
780846 match object {
781847 Content::Date { .. } => {
782848 if let Some(editor) = editor
783 && let Some((field, _)) = self.date_fields(editor).find(|(_, rect)| {
784 (rect[0]..=rect[2]).contains(&point[0])
785 && (rect[1]..=rect[3]).contains(&point[1])
786 })
849 && let Some((field, _)) =
850 self.date_fields(editor, column).find(|(_, rect)| {
851 (rect[0]..=rect[2]).contains(&point[0])
852 && (rect[1]..=rect[3]).contains(&point[1])
853 })
787854 {
788855 return Some(SceneHit::Date(field));
789856 }
......@@ -851,7 +918,8 @@ impl PageScene {
851918 layout,
852919 below_title,
853920 } => {
854 let [x, y] = outline_origin(crate::origin(&source.layout), *below_title, None)?;
921 let [x, y] =
922 outline_origin(crate::origin(&source.layout), *below_title, None, None)?;
855923 cover([x, y, x + layout.size[0], y + layout.size[1]], false);
856924 for paragraph in &layout.paragraphs {
857925 let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]];
......@@ -900,18 +968,20 @@ impl PageScene {
900968 offset: [f32; 2],
901969 paper: Paper,
902970 ) -> Result<(), SceneError> {
903 self.append_primitives_with(primitives, offset, None, None, paper, |_, _, _| {
971 self.append_primitives_with(primitives, offset, None, None, None, paper, |_, _, _| {
904972 Err(SceneError::MissingOutline)
905973 })
906974 }
907975
908976 /// `moving` draws one picture at a previewed rectangle instead of its stored layout.
909977 /// Outlines the editor does not hold draw their tags' symbols.
978 #[allow(clippy::too_many_arguments)]
910979 pub(crate) fn append_primitives_with<'a, E: From<SceneError>>(
911980 &'a self,
912981 primitives: &mut Vec<Primitive<'a>>,
913982 offset: [f32; 2],
914983 editor: Option<&'a CanvasEditor>,
984 column: Option<&'a crate::reading::Column>,
915985 moving: Option<(onestore::ExGuid, [f32; 4])>,
916986 paper: Paper,
917987 mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>,
......@@ -922,12 +992,17 @@ impl PageScene {
922992 source,
923993 below_title,
924994 ..
925 } => outline_origin(crate::origin(&source.layout), *below_title, editor)?,
995 } => outline_origin(crate::origin(&source.layout), *below_title, editor, column)?,
926996 Content::Date { below_title } => {
927997 let date = editor
928998 .and_then(CanvasEditor::date)
929999 .ok_or(SceneError::MissingOutline)?;
930 outline_origin(crate::origin(&date.source().layout), *below_title, editor)?
1000 outline_origin(
1001 crate::origin(&date.source().layout),
1002 *below_title,
1003 editor,
1004 column,
1005 )?
9311006 }
9321007 Content::Image(source) => crate::origin(&source.layout),
9331008 Content::ReadOnly(object) => {
......@@ -1397,7 +1472,7 @@ mod tests {
13971472 assert_eq!(editor.active_outline().layout().max_width, None);
13981473 let bounds = |editor: &CanvasEditor| {
13991474 scene
1400 .content_bounds(editor)
1475 .content_bounds(editor, None)
14011476 .map(|(rect, _)| rect)
14021477 .collect::<Vec<_>>()
14031478 };
......@@ -1453,7 +1528,7 @@ mod tests {
14531528 let changed = bounds(&editor);
14541529 assert_eq!(changed[0][1], wrapped[0][1]);
14551530 assert!(changed[0][3] > wrapped[0][3]);
1456 let (field, rect) = scene.date_fields(&editor).next().unwrap();
1531 let (field, rect) = scene.date_fields(&editor, None).next().unwrap();
14571532 assert_eq!(field, DateField::Date);
14581533 assert_eq!(rect[1], changed[0][1]);
14591534 assert_eq!(rect[3], changed[0][3]);
......@@ -1759,6 +1834,7 @@ mod tests {
17591834 [0.0; 2],
17601835 Some(editor),
17611836 None,
1837 None,
17621838 Paper::WHITE,
17631839 |_, _, _| Ok(()),
17641840 )
......@@ -1785,7 +1861,7 @@ mod tests {
17851861 assert!((old[3] + delta - new[3]).abs() < 0.002);
17861862 assert!(
17871863 scene
1788 .content_bounds(&editor)
1864 .content_bounds(&editor, None)
17891865 .any(|(bounds, _)| bounds == *new)
17901866 );
17911867 }
......@@ -2199,6 +2275,7 @@ mod tests {
21992275 [0.0; 2],
22002276 Some(editor),
22012277 None,
2278 None,
22022279 Paper::WHITE,
22032280 |id, offset, primitives| {
22042281 let outline = editor
......@@ -2461,7 +2538,7 @@ mod tests {
24612538 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
24622539 assert!(
24632540 scene
2464 .content_bounds(&editor)
2541 .content_bounds(&editor, None)
24652542 .any(|(bounds, _)| bounds == [9.0, 19.0, 41.0, 61.0])
24662543 );
24672544 let mut primitives = Vec::new();
......@@ -2471,6 +2548,7 @@ mod tests {
24712548 [5.0, 0.0],
24722549 Some(&editor),
24732550 None,
2551 None,
24742552 Paper::WHITE,
24752553 |_, _, _| Ok(()),
24762554 )
......@@ -2682,6 +2760,7 @@ mod tests {
26822760 &mut primitives,
26832761 [0.0; 2],
26842762 Some(&editor),
2763 None,
26852764 Some((id, [70.0, 5.0, 120.0, 55.0])),
26862765 Paper::WHITE,
26872766 |_, _, _| Ok(()),
......@@ -2777,6 +2856,7 @@ mod tests {
27772856 [0.0; 2],
27782857 Some(&editor),
27792858 None,
2859 None,
27802860 Paper::WHITE,
27812861 |_, _, _| Ok(()),
27822862 )
crates/canvas/src/interaction/accessibility.rs+118-40
......@@ -94,7 +94,9 @@ impl Accessibility {
9494 }
9595
9696 pub fn read_only_for_node(&self, node: NodeId) -> Option<usize> {
97 self.read_only.iter().position(|(id, _)| *id == node)
97 self.read_only
98 .iter()
99 .position(|(id, value)| *id == node && value.supports_action(Action::Focus))
98100 }
99101
100102 pub fn date_for_node(&self, node: NodeId) -> Option<DateField> {
......@@ -114,6 +116,24 @@ impl Accessibility {
114116 /// The nodes that changed since the last update. After an error, `deactivate` before the
115117 /// next update.
116118 pub fn update(
119 &mut self,
120 view: &super::PageView,
121 viewport: Viewport,
122 label: &str,
123 ) -> Result<TreeUpdate, EditError> {
124 self.update_with(
125 &view.editor,
126 view.scene.as_ref(),
127 viewport,
128 label,
129 view.outline_preview(),
130 view.object_focus().and_then(super::ObjectFocus::read_only),
131 view.column.as_ref(),
132 )
133 }
134
135 #[allow(clippy::too_many_arguments)]
136 pub(super) fn update_with(
117137 &mut self,
118138 editor: &CanvasEditor,
119139 scene: Option<&(PageScene, [f32; 2])>,
......@@ -121,6 +141,7 @@ impl Accessibility {
121141 label: &str,
122142 preview: Option<(ExGuid, [f32; 2])>,
123143 read_only_focus: Option<usize>,
144 column: Option<&crate::reading::Column>,
124145 ) -> Result<TreeUpdate, EditError> {
125146 let mut nodes = Vec::new();
126147 let mut focus = ROOT;
......@@ -129,16 +150,26 @@ impl Accessibility {
129150 .drain(..)
130151 .map(|field| (field.outline, field))
131152 .collect();
132 for (ordinal, outline) in editor
153 let mut outlines: Vec<_> = editor
133154 .visible_outlines()
134155 .chain(editor.caret_outline())
135 .enumerate()
136 {
156 .collect();
157 if let Some(column) = column {
158 outlines.sort_by_key(|outline| {
159 column
160 .outlines
161 .iter()
162 .position(|(id, _, _)| *id == outline.id)
163 .unwrap()
164 });
165 }
166 for (ordinal, outline) in outlines.into_iter().enumerate() {
137167 let (id, sent, old) = match previous.remove(&outline.id) {
138168 Some(field) => (field.id, Some(field.node), field.paragraphs),
139169 None => (self.allocate(), None, Vec::new()),
140170 };
141 let layouts: Vec<_> = outline.layouts().collect();
171 let (geometry, displayed) = crate::reading::geometry(outline, column);
172 let layouts: Vec<_> = geometry.layouts().collect();
142173 // Only a paragraph with a successor ends its last run with the paragraph break.
143174 let same = |old_index: usize, index: usize| {
144175 old[old_index].layout == layouts[index].1.text.id()
......@@ -240,16 +271,26 @@ impl Accessibility {
240271 .map(|paragraph| paragraph.id)
241272 .collect::<Vec<_>>(),
242273 );
243 let origin = preview
274 let mut origin = preview
244275 .filter(|(id, _)| *id == outline.id)
245276 .map(|(_, origin)| origin)
246 .unwrap_or_else(|| outline.origin());
277 .unwrap_or(displayed);
278 if (column.is_some() || editor.has_page_outline(outline.id))
279 && let Some((_, offset)) = scene
280 {
281 origin = [origin[0] + offset[0], origin[1] + offset[1]];
282 }
247283 node.set_transform(Affine::translate((
248284 f64::from(origin[0]),
249285 f64::from(origin[1]),
250286 )));
251287 let bounds = outline.bounds();
252 node.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height()));
288 let [width, height] = if column.is_some() {
289 geometry.shaped.size.map(f64::from)
290 } else {
291 [bounds.width(), bounds.height()]
292 };
293 node.set_bounds(Rect::new(0.0, 0.0, width, height));
253294 if outline.id == editor.active_outline().id {
254295 let Selection {
255296 positions: [anchor, caret],
......@@ -271,7 +312,22 @@ impl Accessibility {
271312 paragraphs,
272313 });
273314 }
274 let mut children: Vec<_> = self.fields.iter().map(|field| field.id).collect();
315 let mut children: Vec<_> = self
316 .fields
317 .iter()
318 .map(|field| {
319 let origin = column
320 .and_then(|column| {
321 column
322 .outlines
323 .iter()
324 .find(|(id, _, _)| *id == field.outline)
325 })
326 .map(|(_, origin, _)| *origin)
327 .unwrap_or([0.0; 2]);
328 (field.id, origin)
329 })
330 .collect();
275331 let sent_read_only = std::mem::take(&mut self.read_only);
276332 let sent_dates = std::mem::take(&mut self.dates);
277333 if let Some((scene, offset)) = scene {
......@@ -297,10 +353,27 @@ impl Accessibility {
297353 if read_only_focus == Some(index) {
298354 focus = id;
299355 }
300 children.push(id);
356 let bounds = object.rect();
357 children.push((id, [bounds[0], bounds[1]]));
301358 self.read_only.push((id, node));
302359 }
303 for (index, (field, bounds)) in scene.date_fields(editor).enumerate() {
360 if let Some(column) = column {
361 for (text, bounds) in scene.header_text(editor, column) {
362 let index = self.read_only.len();
363 let mut node = Node::new(Role::Label);
364 node.set_value(text);
365 node.set_read_only();
366 node.set_bounds(rect(bounds));
367 let sent = sent_read_only.get(index);
368 let id = sent.map_or_else(|| self.allocate(), |(id, _)| *id);
369 if sent.is_none_or(|(_, sent)| *sent != node) {
370 nodes.push((id, node.clone()));
371 }
372 children.push((id, [bounds[0], bounds[1]]));
373 self.read_only.push((id, node));
374 }
375 }
376 for (index, (field, bounds)) in scene.date_fields(editor, column).enumerate() {
304377 let mut node = Node::new(Role::Button);
305378 node.set_label(super::DATE_LABELS[field as usize]);
306379 node.set_value(
......@@ -317,7 +390,7 @@ impl Accessibility {
317390 if sent.is_none_or(|(_, _, sent)| *sent != node) {
318391 nodes.push((id, node.clone()));
319392 }
320 children.push(id);
393 children.push((id, [bounds[0], bounds[1]]));
321394 self.dates.push((field, id, node));
322395 }
323396 }
......@@ -330,7 +403,10 @@ impl Accessibility {
330403 f64::from(viewport.origin[0]),
331404 f64::from(viewport.origin[1]),
332405 ]));
333 page.set_children(children);
406 if column.is_some() {
407 children.sort_by(|(_, a), (_, b)| a[1].total_cmp(&b[1]).then(a[0].total_cmp(&b[0])));
408 }
409 page.set_children(children.into_iter().map(|(id, _)| id).collect::<Vec<_>>());
334410 let mut root = Node::new(Role::Group);
335411 root.set_label(label);
336412 root.set_children(vec![PAGE]);
......@@ -772,7 +848,7 @@ pub(super) mod tests {
772848 _ => {}
773849 }
774850 let update = access
775 .update(&editor, Some(&scene), viewport, "Test", None, Some(0))
851 .update_with(&editor, Some(&scene), viewport, "Test", None, Some(0), None)
776852 .unwrap();
777853 let node = nodes(apply(&mut tree, update))
778854 .into_iter()
......@@ -847,7 +923,7 @@ pub(super) mod tests {
847923 scale,
848924 };
849925 let update = access
850 .update(&editor, Some(&scene), viewport, "Test", None, None)
926 .update_with(&editor, Some(&scene), viewport, "Test", None, None, None)
851927 .unwrap();
852928 let state = apply(&mut tree, update);
853929 let node = nodes(state)
......@@ -884,7 +960,7 @@ pub(super) mod tests {
884960 "annotation "
885961 );
886962 let focused = access
887 .update(&editor, Some(&scene), viewport, "Test", None, Some(0))
963 .update_with(&editor, Some(&scene), viewport, "Test", None, Some(0), None)
888964 .unwrap();
889965 assert_eq!(focused.focus, identity.unwrap());
890966 assert_eq!(access.read_only_for_node(focused.focus), Some(0));
......@@ -995,7 +1071,7 @@ pub(super) mod tests {
9951071 editor.undo(&mut engine).unwrap();
9961072 }
9971073 let update = access
998 .update(&editor, None, viewport, "Test", None, None)
1074 .update_with(&editor, None, viewport, "Test", None, None, None)
9991075 .unwrap();
10001076 let state = apply(&mut tree, update);
10011077 assert_eq!(
......@@ -1057,7 +1133,7 @@ pub(super) mod tests {
10571133 origin: [13.0, 19.0],
10581134 };
10591135 let update = access
1060 .update(&editor, None, viewport, "Test", None, None)
1136 .update_with(&editor, None, viewport, "Test", None, None, None)
10611137 .unwrap();
10621138 let field = apply(&mut tree, update).focus().unwrap();
10631139 assert_eq!(
......@@ -1094,7 +1170,7 @@ pub(super) mod tests {
10941170 );
10951171 editor.select(source).unwrap();
10961172 let update = access
1097 .update(&editor, None, viewport, "Test", None, None)
1173 .update_with(&editor, None, viewport, "Test", None, None, None)
10981174 .unwrap();
10991175 assert_eq!(
11001176 apply(&mut tree, update)
......@@ -1115,7 +1191,7 @@ pub(super) mod tests {
11151191 )
11161192 .unwrap();
11171193 let update = access
1118 .update(&editor, None, viewport, "Test", None, None)
1194 .update_with(&editor, None, viewport, "Test", None, None, None)
11191195 .unwrap();
11201196 let caret = editor.caret(1.0).unwrap();
11211197 assert_eq!(caret.x0, 27.0);
......@@ -1172,7 +1248,7 @@ pub(super) mod tests {
11721248 scale: 2.0,
11731249 };
11741250 let update = access
1175 .update(&editor, None, viewport, "Test", None, None)
1251 .update_with(&editor, None, viewport, "Test", None, None, None)
11761252 .unwrap();
11771253 for (_, node) in &update.nodes {
11781254 if node.role() == Role::TextRun {
......@@ -1199,7 +1275,7 @@ pub(super) mod tests {
11991275 .unwrap();
12001276 editor.select(source_selection).unwrap();
12011277 let selected = access
1202 .update(&editor, None, viewport, "Test", None, None)
1278 .update_with(&editor, None, viewport, "Test", None, None, None)
12031279 .unwrap();
12041280 assert_eq!(
12051281 apply(&mut tree, selected)
......@@ -1211,7 +1287,7 @@ pub(super) mod tests {
12111287 text
12121288 );
12131289 let zoomed = access
1214 .update(
1290 .update_with(
12151291 &editor,
12161292 None,
12171293 Viewport {
......@@ -1221,6 +1297,7 @@ pub(super) mod tests {
12211297 "Test",
12221298 None,
12231299 None,
1300 None,
12241301 )
12251302 .unwrap();
12261303 assert_eq!(
......@@ -1230,7 +1307,7 @@ pub(super) mod tests {
12301307 apply(&mut tree, zoomed);
12311308 access.deactivate();
12321309 let update = access
1233 .update(&editor, None, viewport, "Test", None, None)
1310 .update_with(&editor, None, viewport, "Test", None, None, None)
12341311 .unwrap();
12351312 let state = apply(&mut tree, update);
12361313 assert!(
......@@ -1241,7 +1318,7 @@ pub(super) mod tests {
12411318 let selection = *state.focus().unwrap().data().text_selection().unwrap();
12421319 editor.insert(&mut engine, "replacement").unwrap();
12431320 let update = access
1244 .update(&editor, None, viewport, "Test", None, None)
1321 .update_with(&editor, None, viewport, "Test", None, None, None)
12451322 .unwrap();
12461323 apply(&mut tree, update);
12471324 assert!(
......@@ -1251,7 +1328,7 @@ pub(super) mod tests {
12511328 );
12521329 editor.undo(&mut engine).unwrap();
12531330 let update = access
1254 .update(&editor, None, viewport, "Test", None, None)
1331 .update_with(&editor, None, viewport, "Test", None, None, None)
12551332 .unwrap();
12561333 assert_eq!(
12571334 apply(&mut tree, update)
......@@ -1317,7 +1394,7 @@ pub(super) mod tests {
13171394 .unwrap();
13181395 let caret = editor.caret(0.0).unwrap();
13191396 let update = access
1320 .update(&editor, None, viewport, "Test", None, None)
1397 .update_with(&editor, None, viewport, "Test", None, None, None)
13211398 .unwrap();
13221399 let field = apply(&mut tree, update).focus().unwrap();
13231400 assert_eq!(
......@@ -1394,7 +1471,7 @@ pub(super) mod tests {
13941471 editor.move_selection(&mut engine, movement, false).unwrap();
13951472 let caret = editor.caret(0.0).unwrap();
13961473 let update = access
1397 .update(&editor, None, viewport, "Test", None, None)
1474 .update_with(&editor, None, viewport, "Test", None, None, None)
13981475 .unwrap();
13991476 let field = apply(&mut tree, update).focus().unwrap();
14001477 let selection = field.text_selection().unwrap();
......@@ -1451,13 +1528,13 @@ pub(super) mod tests {
14511528 .then_some((editor.active_outline().id, [13.5, -7.25]));
14521529 for repeat in 0..2 {
14531530 let update = cached
1454 .update(editor, None, viewport, "Test", preview, None)
1531 .update_with(editor, None, viewport, "Test", preview, None, None)
14551532 .unwrap();
14561533 let actual = apply(&mut tree, update);
14571534 let mut rebuilt = Accessibility::default();
14581535 let expected = Tree::new(
14591536 rebuilt
1460 .update(editor, None, viewport, "Test", preview, None)
1537 .update_with(editor, None, viewport, "Test", preview, None, None)
14611538 .unwrap(),
14621539 true,
14631540 );
......@@ -1575,7 +1652,7 @@ pub(super) mod tests {
15751652 scale: 2.0,
15761653 };
15771654 let update = access
1578 .update(&editor, None, viewport, "Test", None, None)
1655 .update_with(&editor, None, viewport, "Test", None, None, None)
15791656 .unwrap();
15801657 apply(&mut tree, update);
15811658 let ids = |update: &TreeUpdate| update.nodes.iter().map(|(id, _)| *id).collect::<Vec<_>>();
......@@ -1583,7 +1660,7 @@ pub(super) mod tests {
15831660 viewport.origin[1] += scroll;
15841661 viewport.scale *= zoom;
15851662 let update = access
1586 .update(&editor, None, viewport, "Test", None, None)
1663 .update_with(&editor, None, viewport, "Test", None, None, None)
15871664 .unwrap();
15881665 assert_eq!(ids(&update), [ROOT, PAGE]);
15891666 apply(&mut tree, update);
......@@ -1599,13 +1676,13 @@ pub(super) mod tests {
15991676 )
16001677 .unwrap();
16011678 let update = access
1602 .update(&editor, None, viewport, "Test", None, None)
1679 .update_with(&editor, None, viewport, "Test", None, None, None)
16031680 .unwrap();
16041681 assert_eq!(ids(&update), [field, ROOT, PAGE]);
16051682 apply(&mut tree, update);
16061683 editor.insert(&mut engine, "x").unwrap();
16071684 let update = access
1608 .update(&editor, None, viewport, "Test", None, None)
1685 .update_with(&editor, None, viewport, "Test", None, None, None)
16091686 .unwrap();
16101687 let paragraph = &access.fields[0].paragraphs[150];
16111688 assert_eq!(
......@@ -1616,7 +1693,7 @@ pub(super) mod tests {
16161693 let state = apply(&mut tree, update);
16171694 let expected = Tree::new(
16181695 Accessibility::default()
1619 .update(&editor, None, viewport, "Test", None, None)
1696 .update_with(&editor, None, viewport, "Test", None, None, None)
16201697 .unwrap(),
16211698 true,
16221699 );
......@@ -1641,7 +1718,7 @@ pub(super) mod tests {
16411718 origin: [48.0; 2],
16421719 };
16431720 let update = access
1644 .update(&editor, None, viewport, "Test", None, None)
1721 .update_with(&editor, None, viewport, "Test", None, None, None)
16451722 .unwrap();
16461723 let fields: Vec<_> = update
16471724 .nodes
......@@ -1667,13 +1744,14 @@ pub(super) mod tests {
16671744 let source_selection = access.selection(second, selection).unwrap();
16681745 editor.select(source_selection).unwrap();
16691746 let preview = access
1670 .update(
1747 .update_with(
16711748 &editor,
16721749 None,
16731750 viewport,
16741751 "Test",
16751752 Some((second, [400.0, 50.0])),
16761753 None,
1754 None,
16771755 )
16781756 .unwrap();
16791757 let preview_field = apply(&mut tree, preview).focus().unwrap();
......@@ -1683,7 +1761,7 @@ pub(super) mod tests {
16831761 assert_eq!(editor.active_outline().origin(), [300.0, 40.0]);
16841762 editor.move_outline(second, [400.0, 50.0]).unwrap();
16851763 let update = access
1686 .update(&editor, None, viewport, "Test", None, None)
1764 .update_with(&editor, None, viewport, "Test", None, None, None)
16871765 .unwrap();
16881766 apply(&mut tree, update);
16891767 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
......@@ -1692,7 +1770,7 @@ pub(super) mod tests {
16921770 editor.undo(&mut engine).unwrap();
16931771 editor.undo(&mut engine).unwrap();
16941772 let update = access
1695 .update(&editor, None, viewport, "Test", None, None)
1773 .update_with(&editor, None, viewport, "Test", None, None, None)
16961774 .unwrap();
16971775 assert_eq!(
16981776 nodes(apply(&mut tree, update))
......@@ -1735,7 +1813,7 @@ pub(super) mod tests {
17351813 scale: 1.0,
17361814 };
17371815 Accessibility::default()
1738 .update(&editor, None, viewport, "Test", None, None)
1816 .update_with(&editor, None, viewport, "Test", None, None, None)
17391817 .unwrap();
17401818 }
17411819 assert!(wrapped);
crates/canvas/src/interaction/ink.rs+3
......@@ -82,6 +82,9 @@ impl PageView {
8282 /// Picks what presses on the page do, as the Draw tab's tools do; the lasso's selection
8383 /// goes.
8484 pub fn set_tool(&mut self, tool: Tool) -> Response {
85 if tool != Tool::Select {
86 self.column = None;
87 }
8588 self.ink.tool = tool;
8689 self.leave_ink();
8790 Response::redraw()
crates/canvas/src/interaction/mod.rs+430-95
......@@ -248,6 +248,7 @@ pub struct Place {
248248/// keyboard state between events.
249249pub struct PageView {
250250 pub editor: CanvasEditor,
251 pub(crate) column: Option<crate::reading::Column>,
251252 pub engine: TextEngine,
252253 /// The page's noneditable content and where it sits in the view.
253254 pub scene: Option<(PageScene, [f32; 2])>,
......@@ -325,6 +326,7 @@ impl PageView {
325326 ) -> Self {
326327 let mut view = Self {
327328 editor,
329 column: None,
328330 engine,
329331 scene,
330332 viewport: Viewport {
......@@ -367,6 +369,7 @@ impl PageView {
367369 editor.markdown = self.editor.markdown.take();
368370 let left = std::mem::replace(&mut self.editor, editor);
369371 self.scene = scene;
372 self.column = None;
370373 self.drag = None;
371374 self.space = false;
372375 self.object_focus = None;
......@@ -380,7 +383,9 @@ impl PageView {
380383 /// keeping the scroll, the caret and selection, and the pictures already drawn. Marked
381384 /// text must be committed or cancelled first.
382385 pub fn refresh(&mut self, page: onestore::page::Page) -> Result<Response> {
386 let width = self.column.take().map(|column| column.width);
383387 if !self.editor.refresh(page, &mut self.engine)? {
388 self.set_reading(width);
384389 return Ok(Response::default());
385390 }
386391 if let Some((scene, _)) = &mut self.scene {
......@@ -389,12 +394,15 @@ impl PageView {
389394 self.drag = None;
390395 self.object_focus = None;
391396 self.refresh_ink();
397 self.set_reading(width);
392398 self.moved()
393399 }
394400
395401 /// Lays the page out again where it stands, as after fonts arrived.
396402 pub fn relayout(&mut self) -> Result<Response> {
403 let width = self.column.take().map(|column| column.width);
397404 self.editor.relayout(&mut self.engine)?;
405 self.set_reading(width);
398406 self.moved()
399407 }
400408
......@@ -425,10 +433,12 @@ impl PageView {
425433 /// Takes up `parked`, the editor the page `open` just showed was left with, history and
426434 /// all, in place of the one it opened with.
427435 pub fn resume(&mut self, parked: CanvasEditor) -> Result<()> {
436 let width = self.column.take().map(|column| column.width);
428437 self.editor.resume(parked, &mut self.engine)?;
429438 if let Some((scene, _)) = &mut self.scene {
430439 scene.refresh(&mut self.editor, &mut self.engine)?;
431440 }
441 self.set_reading(width);
432442 Ok(())
433443 }
434444
......@@ -593,6 +603,7 @@ impl PageView {
593603 point,
594604 self.pixel(),
595605 touched,
606 self.column.as_ref(),
596607 )
597608 }
598609
......@@ -602,6 +613,20 @@ impl PageView {
602613 self.hit_test(self.viewport.document_point(position))
603614 }
604615
616 fn link_under(&self, id: onestore::ExGuid, point: [f32; 2]) -> Option<String> {
617 if self.column.is_none() {
618 return self.editor.link_under(id, point);
619 }
620 self.editor
621 .visible_outlines()
622 .find(|source| source.id == id)
623 .and_then(|source| {
624 crate::reading::geometry(source, self.column.as_ref())
625 .0
626 .link_under(point)
627 })
628 }
629
605630 /// The Outlook task icon under the pointer, in view device pixels, for the host to name.
606631 pub fn task_under_pointer(&self) -> Option<[f32; 4]> {
607632 if !self.pointer_inside || self.drag.is_some() {
......@@ -611,12 +636,17 @@ impl PageView {
611636 let view = |v: f32, axis: usize| v * self.viewport.scale + self.viewport.origin[axis];
612637 self.editor.visible_outlines().find_map(|outline| {
613638 let offset = match &self.scene {
614 Some((_, offset)) if self.editor.has_page_outline(outline.id) => *offset,
639 Some((_, offset))
640 if self.column.is_some() || self.editor.has_page_outline(outline.id) =>
641 {
642 *offset
643 }
615644 _ => [0.0; 2],
616645 };
617 let shaped = outline.shaped();
618 let left = offset[0] + outline.origin()[0] + shaped.tag_column_offset();
619 let top = offset[1] + outline.origin()[1];
646 let (geometry, origin) = crate::reading::geometry(outline, self.column.as_ref());
647 let shaped = geometry.shaped;
648 let left = offset[0] + origin[0] + shaped.tag_column_offset();
649 let top = offset[1] + origin[1];
620650 shaped.tags().find_map(|(_, origin, tag)| {
621651 let x0 = left + origin[0];
622652 let y0 = top + origin[1];
......@@ -665,6 +695,11 @@ impl PageView {
665695
666696 /// Keeps the view in bounds and restarts the caret blink after a change.
667697 fn changed(&mut self) -> Result<Response> {
698 self.reconcile_reading();
699 self.finish_change()
700 }
701
702 fn finish_change(&mut self) -> Result<Response> {
668703 if !self.host_viewport {
669704 self.scroll().clamp(&mut self.viewport);
670705 }
......@@ -686,11 +721,14 @@ impl PageView {
686721
687722 /// The caret's rectangle in device pixels, for placing the platform's input method.
688723 pub fn caret_area(&self) -> Result<[f32; 4]> {
689 let mut rect = self.editor.caret(1.0)?;
724 let outline = self.editor.active_outline();
725 let (geometry, displayed) = self.text_geometry(outline.id)?;
726 let selection = self.editor.selection();
727 let mut rect = geometry.caret_at(selection.positions[1], selection.affinities[1], 1.0)?;
690728 let origin = self
691729 .outline_preview()
692730 .map(|(_, origin)| origin)
693 .unwrap_or_else(|| self.editor.active_outline().origin());
731 .unwrap_or(displayed);
694732 rect.x0 += f64::from(origin[0]);
695733 rect.y0 += f64::from(origin[1]);
696734 rect.x1 += f64::from(origin[0]);
......@@ -714,17 +752,18 @@ impl PageView {
714752 let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from);
715753 parley::BoundingBox { x0, y0, x1, y1 }
716754 } else {
717 let mut rect = self.editor.caret(1.0)?;
718 let origin = self.editor.active_outline().origin();
755 let outline = self.editor.active_outline();
756 let (geometry, origin) = self.text_geometry(outline.id)?;
757 let selection = self.editor.selection();
758 let mut rect =
759 geometry.caret_at(selection.positions[1], selection.affinities[1], 1.0)?;
719760 rect.x0 += f64::from(origin[0]);
720761 rect.x1 += f64::from(origin[0]);
721762 rect.y0 += f64::from(origin[1]);
722763 rect.y1 += f64::from(origin[1]);
723764 rect
724765 };
725 let outline = self
726 .object_focus
727 .is_none()
766 let outline = (self.column.is_none() && self.object_focus.is_none())
728767 .then(|| self.editor.active_outline().bounds());
729768 self.reveal(rect, outline);
730769 Ok(())
......@@ -781,17 +820,10 @@ impl PageView {
781820 && !self.host_viewport
782821 && !self.viewport.size.contains(&0)
783822 {
784 let [x, y] = outline.origin().map(f64::from);
785 let rect = outline
786 .range_rects(selection)?
823 let rect = self
824 .range_rects(outline.id, selection)?
787825 .into_iter()
788 .reduce(|a, b| a.union(b))
789 .map(|rect| parley::BoundingBox {
790 x0: rect.x0 + x,
791 y0: rect.y0 + y,
792 x1: rect.x1 + x,
793 y1: rect.y1 + y,
794 });
826 .reduce(|a, b| a.union(b));
795827 if let Some(rect) = rect {
796828 self.reveal(rect, None);
797829 return self.moved();
......@@ -804,11 +836,15 @@ impl PageView {
804836 /// it keeps its place on screen.
805837 fn move_page(&mut self, up: bool, extend: bool) -> Result<()> {
806838 let movement = if up { Movement::Up } else { Movement::Down };
839 if self.editor.prepare_navigation(movement, extend)? {
840 self.reconcile_reading();
841 return Ok(());
842 }
843 self.reconcile_reading();
807844 let start = self.caret_area()?[1];
808845 let mut caret = self.caret_area()?;
809846 while (caret[1] - start).abs() < self.viewport.size[1] as f32 {
810 self.editor
811 .move_selection(&mut self.engine, movement, extend)?;
847 self.move_selection_prepared(movement, extend)?;
812848 let moved = self.caret_area()?;
813849 if moved == caret {
814850 break;
......@@ -821,10 +857,11 @@ impl PageView {
821857
822858 /// Reveals the caret or focused object after an edit, then reports the change.
823859 fn edited(&mut self) -> Result<Response> {
860 self.reconcile_reading();
824861 if !self.host_viewport {
825862 self.reveal_focus()?;
826863 }
827 self.changed()
864 self.finish_change()
828865 }
829866
830867 /// The page's zoom, where 1 is OneNote's 100%: 96 pixels per inch.
......@@ -876,14 +913,28 @@ impl PageView {
876913 .visible_outlines()
877914 .chain(self.editor.caret_outline())
878915 .map(|outline| {
879 let rect = outline.bounds();
916 let (geometry, origin) = crate::reading::geometry(outline, self.column.as_ref());
917 let rect = if self.column.is_some() {
918 parley::BoundingBox {
919 x0: f64::from(origin[0]),
920 y0: f64::from(origin[1]),
921 x1: f64::from(origin[0] + geometry.shaped.size[0]),
922 y1: f64::from(origin[1] + geometry.shaped.size[1]),
923 }
924 } else {
925 outline.bounds()
926 };
880927 let offset = self
881928 .scene
882929 .as_ref()
883 .filter(|_| self.editor.has_page_outline(outline.id))
930 .filter(|_| self.column.is_some() || self.editor.has_page_outline(outline.id))
884931 .map(|(_, offset)| *offset)
885932 .unwrap_or([0.0; 2]);
886 let [left, top] = reach(outline);
933 let [left, top] = if self.column.is_some() {
934 [origin[0] - 7.5, origin[1] - 6.0]
935 } else {
936 reach(outline)
937 };
887938 [
888939 left + offset[0],
889940 top + offset[1],
......@@ -894,7 +945,7 @@ impl PageView {
894945 let pad = PAD * self.display_scale;
895946 let fixed = self.scene.iter().flat_map(|(scene, offset)| {
896947 scene
897 .content_bounds(&self.editor)
948 .content_bounds(&self.editor, self.column.as_ref())
898949 .map(move |(rect, padded)| {
899950 (
900951 crate::translated(rect, *offset),
......@@ -907,7 +958,9 @@ impl PageView {
907958 editable.map(|rect| (rect, pad)).chain(fixed),
908959 self.editor.rtl(),
909960 );
910 if let Some(reach) = self.reach {
961 if self.column.is_none()
962 && let Some(reach) = self.reach
963 {
911964 for axis in 0..2 {
912965 scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale);
913966 scroll.max[axis] = scroll.max[axis]
......@@ -949,6 +1002,158 @@ impl PageView {
9491002 update_pictures(scene, *offset, editor, viewport, paper, waker)
9501003 }
9511004
1005 pub fn set_reading(&mut self, width: Option<f32>) -> bool {
1006 self.column = width.and_then(|width| {
1007 crate::reading::Column::build(
1008 &self.editor,
1009 self.scene.as_ref().map(|(scene, _)| scene),
1010 width,
1011 &mut self.engine,
1012 )
1013 .ok()
1014 .flatten()
1015 });
1016 self.column.is_some()
1017 }
1018
1019 pub fn reading_status(&self, width: f32) -> (bool, bool) {
1020 let offered = crate::reading::Column::build(
1021 &self.editor,
1022 self.scene.as_ref().map(|(scene, _)| scene),
1023 width,
1024 &mut self.engine.clone(),
1025 )
1026 .ok()
1027 .flatten()
1028 .is_some();
1029 (offered, self.column.is_some())
1030 }
1031
1032 pub fn reconcile_reading(&mut self) {
1033 if let Some(column) = &self.column {
1034 self.set_reading(Some(column.width));
1035 }
1036 }
1037
1038 fn text_geometry(
1039 &self,
1040 id: onestore::ExGuid,
1041 ) -> Result<(crate::editor::OutlineGeometry<'_>, [f32; 2])> {
1042 let source = self
1043 .editor
1044 .visible_outlines()
1045 .chain(self.editor.caret_outline())
1046 .find(|outline| outline.id == id)
1047 .ok_or(onestore::page::text::EditError::InvalidRange)?;
1048 let (geometry, mut origin) = crate::reading::geometry(source, self.column.as_ref());
1049 if (self.column.is_some() || self.editor.has_page_outline(id))
1050 && let Some((_, offset)) = &self.scene
1051 {
1052 origin = [origin[0] + offset[0], origin[1] + offset[1]];
1053 }
1054 Ok((geometry, origin))
1055 }
1056
1057 pub fn caret_at(
1058 &self,
1059 id: onestore::ExGuid,
1060 position: crate::document::TextPosition,
1061 affinity: parley::Affinity,
1062 width: f32,
1063 ) -> Result<parley::BoundingBox> {
1064 let (geometry, origin) = self.text_geometry(id)?;
1065 let rect = geometry.caret_at(position, affinity, width)?;
1066 Ok(parley::BoundingBox {
1067 x0: rect.x0 + f64::from(origin[0]),
1068 x1: rect.x1 + f64::from(origin[0]),
1069 y0: rect.y0 + f64::from(origin[1]),
1070 y1: rect.y1 + f64::from(origin[1]),
1071 })
1072 }
1073
1074 pub fn range_rects(
1075 &self,
1076 id: onestore::ExGuid,
1077 selection: Selection,
1078 ) -> Result<Vec<parley::BoundingBox>> {
1079 let (geometry, origin) = self.text_geometry(id)?;
1080 Ok(geometry
1081 .range_rects(selection)?
1082 .into_iter()
1083 .map(|rect| parley::BoundingBox {
1084 x0: rect.x0 + f64::from(origin[0]),
1085 x1: rect.x1 + f64::from(origin[0]),
1086 y0: rect.y0 + f64::from(origin[1]),
1087 y1: rect.y1 + f64::from(origin[1]),
1088 })
1089 .collect())
1090 }
1091
1092 pub fn selection_at(
1093 &self,
1094 id: onestore::ExGuid,
1095 point: [f32; 2],
1096 unit: SelectionUnit,
1097 ) -> Result<Selection> {
1098 let (geometry, origin) = self.text_geometry(id)?;
1099 Ok(geometry.selection_at(point[0] - origin[0], point[1] - origin[1], unit)?)
1100 }
1101
1102 pub fn text_block(&self, point: [f32; 2]) -> Option<[f32; 4]> {
1103 self.editor
1104 .visible_outlines()
1105 .chain(
1106 self.editor
1107 .caret_outline()
1108 .filter(|_| self.column.is_some()),
1109 )
1110 .filter_map(|source| {
1111 let (geometry, origin) = self.text_geometry(source.id).ok()?;
1112 let rect = if self.column.is_some() {
1113 [
1114 origin[0],
1115 origin[1],
1116 origin[0] + geometry.shaped.size[0],
1117 origin[1] + geometry.shaped.size[1],
1118 ]
1119 } else {
1120 let bounds = source.bounds();
1121 [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(|value| value as f32)
1122 };
1123 let dx = (rect[0] - point[0]).max(point[0] - rect[2]).max(0.0);
1124 let dy = (rect[1] - point[1]).max(point[1] - rect[3]).max(0.0);
1125 Some((dx.hypot(dy), rect))
1126 })
1127 .min_by(|a, b| a.0.total_cmp(&b.0))
1128 .map(|(_, rect)| rect)
1129 }
1130
1131 fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<()> {
1132 if self.editor.prepare_navigation(movement, extend)? {
1133 self.reconcile_reading();
1134 return Ok(());
1135 }
1136 self.reconcile_reading();
1137 self.move_selection_prepared(movement, extend)
1138 }
1139
1140 fn move_selection_prepared(&mut self, movement: Movement, extend: bool) -> Result<()> {
1141 if let Some(column) = &self.column {
1142 let id = self.editor.active_outline().id;
1143 let (_, _, shaped) = column
1144 .outlines
1145 .iter()
1146 .find(|(source, _, _)| *source == id)
1147 .expect("active source is projected");
1148 self.editor.move_selection_in(shaped, movement, extend)?;
1149 } else {
1150 self.editor
1151 .move_selection_prepared(&mut self.engine, movement, extend)?;
1152 self.reconcile_reading();
1153 }
1154 Ok(())
1155 }
1156
9521157 /// Everything to draw this frame.
9531158 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {
9541159 let preview = match &self.drag {
......@@ -973,11 +1178,13 @@ impl PageView {
9731178 if !self.pointer_inside || self.space {
9741179 return None;
9751180 }
976 match page_hit_test(
1181 match page_hit(
9771182 &self.editor,
9781183 self.scene.as_ref(),
9791184 self.viewport.document_point(self.pointer),
9801185 self.pixel(),
1186 None,
1187 self.column.as_ref(),
9811188 ) {
9821189 Some(
9831190 Hit::Text { id, .. } | Hit::Handle { id, .. } | Hit::Resize { id, .. },
......@@ -1000,6 +1207,7 @@ impl PageView {
10001207 primitives.extend(page_primitives(
10011208 &self.editor,
10021209 self.scene.as_ref(),
1210 self.column.as_ref(),
10031211 preview,
10041212 self.object_focus,
10051213 Paint {
......@@ -1089,9 +1297,7 @@ impl PageView {
10891297 (Some(Drag::Column { .. }), _) | (None, Some(Hit::Column { .. })) => Cursor::ColResize,
10901298 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
10911299 (None, Some(Hit::Date(_))) => Cursor::Pointer,
1092 (None, Some(Hit::Text { id, point }))
1093 if self.editor.link_under(id, point).is_some() =>
1094 {
1300 (None, Some(Hit::Text { id, point })) if self.link_under(id, point).is_some() => {
10951301 Cursor::Pointer
10961302 }
10971303 (
......@@ -1170,10 +1376,7 @@ impl PageView {
11701376 Some(Drag::Text { anchor, unit }) => {
11711377 let (anchor, unit) = (*anchor, *unit);
11721378 let point = self.viewport.document_point(self.pointer);
1173 let origin = self.editor.active_outline().origin();
1174 let target =
1175 self.editor
1176 .selection_at(point[0] - origin[0], point[1] - origin[1], unit)?;
1379 let target = self.selection_at(self.editor.active_outline().id, point, unit)?;
11771380 let pair = |selection: Selection| {
11781381 [0, 1].map(|end| (selection.positions[end], selection.affinities[end]))
11791382 };
......@@ -1357,7 +1560,7 @@ impl PageView {
13571560 Some(Hit::Text { id, point })
13581561 if unit == SelectionUnit::Grapheme
13591562 && !self.modifiers.shift
1360 && let Some(address) = self.editor.link_under(id, point) =>
1563 && let Some(address) = self.link_under(id, point) =>
13611564 {
13621565 self.drag = None;
13631566 return Ok(Response {
......@@ -1365,15 +1568,21 @@ impl PageView {
13651568 ..Response::default()
13661569 });
13671570 }
1368 Some(Hit::Text { id, point }) => {
1571 Some(Hit::Text { id, point: local }) => {
1572 let reading = self.column.is_some();
13691573 let extend = self.modifiers.shift
13701574 && self.object_focus.is_none()
13711575 && id == self.editor.active_outline().id;
13721576 self.set_object_focus(None);
13731577 self.editor.focus_outline(id)?;
13741578 let previous = self.editor.selection();
1375 self.editor.select_below(&mut self.engine, id, point)?;
1376 let selection = self.editor.selection_at(point[0], point[1], unit)?;
1579 self.reconcile_reading();
1580 let selection = if reading {
1581 self.selection_at(id, point, unit)?
1582 } else {
1583 self.editor.select_below(&mut self.engine, id, local)?;
1584 self.editor.selection_at(local[0], local[1], unit)?
1585 };
13771586 let selection = if extend {
13781587 Selection {
13791588 positions: [previous.positions[0], selection.positions[1]],
......@@ -1402,6 +1611,29 @@ impl PageView {
14021611
14031612 /// A fresh click places text 7 px above the pointer, on the grid.
14041613 fn place_caret(&mut self, point: [f32; 2]) -> Result<()> {
1614 if let Some(column) = &self.column {
1615 let id = column
1616 .outlines
1617 .iter()
1618 .rev()
1619 .find(|(id, _, _)| {
1620 self.editor
1621 .visible_outlines()
1622 .chain(self.editor.caret_outline())
1623 .any(|outline| outline.id == *id && !outline.title)
1624 })
1625 .map(|(id, _, _)| *id);
1626 if let Some(id) = id {
1627 self.editor.focus_outline(id)?;
1628 self.reconcile_reading();
1629 let selection = self.selection_at(id, point, SelectionUnit::Grapheme)?;
1630 self.editor.select(selection)?;
1631 return Ok(());
1632 }
1633 self.editor.leave_title(&mut self.engine)?;
1634 self.reconcile_reading();
1635 return Ok(());
1636 }
14051637 let position = [point[0], point[1] - 7.0 * self.pixel()];
14061638 let position = self.grid(position);
14071639 self.editor
......@@ -1625,8 +1857,8 @@ impl PageView {
16251857 /// `None` off text, pictures and files.
16261858 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {
16271859 let point = self.viewport.document_point(self.pointer);
1628 let (id, point) = match self.hit_test(point) {
1629 Some(Hit::Text { id, point }) => (id, point),
1860 let id = match self.hit_test(point) {
1861 Some(Hit::Text { id, .. }) => id,
16301862 Some(Hit::File(id)) | Some(Hit::Image { id, .. }) => {
16311863 let focus = if self.editor.attachment(id).is_some() {
16321864 ObjectFocus::File(id)
......@@ -1645,9 +1877,8 @@ impl PageView {
16451877 let outline = self.editor.active_outline().id;
16461878 let [anchor, focus] = self.editor.selection().positions;
16471879 self.editor.focus_outline(id)?;
1648 let at = self
1649 .editor
1650 .selection_at(point[0], point[1], SelectionUnit::Grapheme)?;
1880 self.reconcile_reading();
1881 let at = self.selection_at(id, point, SelectionUnit::Grapheme)?;
16511882 let inside = outline == id
16521883 && anchor.min(focus) <= at.positions[0]
16531884 && at.positions[0] <= anchor.max(focus);
......@@ -1727,8 +1958,8 @@ impl PageView {
17271958 let (id, word, correction) = {
17281959 let mut outlines: Vec<&TextOutline> = self.editor.outlines().iter().collect();
17291960 outlines.sort_by(|a, b| {
1730 let [ax, ay] = a.origin();
1731 let [bx, by] = b.origin();
1961 let [ax, ay] = crate::reading::geometry(a, self.column.as_ref()).1;
1962 let [bx, by] = crate::reading::geometry(b, self.column.as_ref()).1;
17321963 ay.total_cmp(&by).then(ax.total_cmp(&bx))
17331964 });
17341965 let active = self.editor.active_outline().id;
......@@ -1962,14 +2193,16 @@ impl PageView {
19622193 /// Puts the caret where a drop at `position` lands, as a click there would.
19632194 fn drop_caret(&mut self, position: [f32; 2]) -> Result<()> {
19642195 let point = self.viewport.document_point(position);
2196 let reading = self.column.is_some();
19652197 self.set_object_focus(None);
19662198 match self.hit_test(point) {
1967 Some(Hit::Text { id, point }) => {
2199 Some(Hit::Text { id, point: local }) => {
19682200 self.editor.focus_outline(id)?;
1969 self.editor.select_below(&mut self.engine, id, point)?;
1970 let at = self
1971 .editor
1972 .selection_at(point[0], point[1], SelectionUnit::Grapheme)?;
2201 self.reconcile_reading();
2202 if !reading {
2203 self.editor.select_below(&mut self.engine, id, local)?;
2204 }
2205 let at = self.selection_at(id, point, SelectionUnit::Grapheme)?;
19732206 self.editor.select(at)?;
19742207 }
19752208 None => self.place_caret(point)?,
......@@ -2118,8 +2351,7 @@ impl PageView {
21182351 | (_, Key::Named(NamedKey::Tab)) => return Ok(Response::default()),
21192352 // Enter leaves the caret at the selection's end, as Right does.
21202353 (_, Key::Named(NamedKey::Enter)) => {
2121 self.editor
2122 .move_selection(&mut self.engine, Movement::Right, false)?;
2354 self.move_selection(Movement::Right, false)?;
21232355 return self.edited();
21242356 }
21252357 _ => {}
......@@ -2199,8 +2431,7 @@ impl PageView {
21992431 }
22002432 let chord = Command::from_key(key, self.modifiers);
22012433 if let Some(Command::Move(movement)) = chord {
2202 self.editor
2203 .move_selection(&mut self.engine, movement, shift)?;
2434 self.move_selection(movement, shift)?;
22042435 } else if let Some(Command::ScrollPage { up }) = chord {
22052436 // AppKit keeps ten points of the last page in view.
22062437 let page = (self.viewport.size[1] as f32 - 10.0 * self.display_scale).max(0.0);
......@@ -2306,13 +2537,14 @@ pub enum Hit {
23062537}
23072538
23082539/// What a document point lands on; `pixel` is document points per device pixel.
2309pub(crate) fn page_hit_test(
2540#[cfg(test)]
2541fn page_hit_test(
23102542 editor: &CanvasEditor,
23112543 scene: Option<&(PageScene, [f32; 2])>,
23122544 point: [f32; 2],
23132545 pixel: f32,
23142546) -> Option<Hit> {
2315 page_hit(editor, scene, point, pixel, None)
2547 page_hit(editor, scene, point, pixel, None, None)
23162548}
23172549
23182550/// OneNote 2010's play button: a blue disc with a white triangle, in `rect`.
......@@ -2342,7 +2574,57 @@ fn page_hit(
23422574 point: [f32; 2],
23432575 pixel: f32,
23442576 touched: Option<onestore::ExGuid>,
2577 column: Option<&crate::reading::Column>,
23452578) -> Option<Hit> {
2579 if let Some(column) = column {
2580 let hit = |id| {
2581 let source = editor
2582 .visible_outlines()
2583 .chain(editor.caret_outline())
2584 .find(|outline| outline.id == id)?;
2585 let (geometry, mut origin) = crate::reading::geometry(source, Some(column));
2586 if let Some((_, offset)) = scene {
2587 origin = [origin[0] + offset[0], origin[1] + offset[1]];
2588 }
2589 let local = [point[0] - origin[0], point[1] - origin[1]];
2590 if let Some((paragraph, ..)) = geometry.shaped.tags().find(|(_, origin, tag)| {
2591 let x = geometry.shaped.tag_column_offset() + origin[0];
2592 let y = origin[1];
2593 tag.icon.checkable()
2594 && (x..=x + tag.size).contains(&local[0])
2595 && (y..=y + tag.size).contains(&local[1])
2596 }) {
2597 return Some(Hit::Check {
2598 outline: id,
2599 paragraph,
2600 });
2601 }
2602 if local[0] >= 0.0
2603 && local[0] <= column.width
2604 && local[1] >= 0.0
2605 && local[1] <= geometry.shaped.size[1]
2606 {
2607 Some(Hit::Text { id, point: local })
2608 } else {
2609 None
2610 }
2611 };
2612 if let Some((scene, offset)) = scene
2613 && let Some(hit) = scene.hit_test_with(
2614 [point[0] - offset[0], point[1] - offset[1]],
2615 Some(editor),
2616 Some(column),
2617 hit,
2618 )
2619 {
2620 return Some(match hit {
2621 crate::gpu::page::SceneHit::Outline(hit) => hit,
2622 crate::gpu::page::SceneHit::Date(field) => Hit::Date(field),
2623 _ => return None,
2624 });
2625 }
2626 return column.outlines.iter().rev().find_map(|(id, _, _)| hit(*id));
2627 }
23462628 /// Grips and text resolve front to back before any outline's padding, as a native
23472629 /// width handle stays reachable under the next outline's left padding; the typing room
23482630 /// below an outline comes last.
......@@ -2548,6 +2830,7 @@ pub(crate) fn rule_primitives(
25482830fn page_primitives<'a>(
25492831 editor: &'a CanvasEditor,
25502832 scene: Option<&'a (PageScene, [f32; 2])>,
2833 column: Option<&'a crate::reading::Column>,
25512834 preview: Option<PointerFeedback<'a>>,
25522835 object_focus: Option<ObjectFocus>,
25532836 paint: Paint,
......@@ -2563,6 +2846,11 @@ fn page_primitives<'a>(
25632846 None => false,
25642847 };
25652848 let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| {
2849 let offset = if column.is_some() {
2850 scene.map_or([0.0; 2], |(_, offset)| *offset)
2851 } else {
2852 offset
2853 };
25662854 // An emptied page outline leaves the editor but keeps its paint slot for undo.
25672855 let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else {
25682856 return Ok(());
......@@ -2571,7 +2859,8 @@ fn page_primitives<'a>(
25712859 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,
25722860 _ => outline,
25732861 };
2574 let [x, y] = outline.origin();
2862 let (geometry, displayed) = crate::reading::geometry(outline, column);
2863 let [x, y] = displayed;
25752864 let origin = match preview {
25762865 Some(PointerFeedback::Move(id, [dx, dy]))
25772866 if id == outline.id || whole == Some(Whole::Page) && !outline.title =>
......@@ -2581,11 +2870,12 @@ fn page_primitives<'a>(
25812870 _ => [x, y],
25822871 };
25832872 // OneNote frames the title whether or not it is focused or hovered.
2584 if outline.title
2585 || selected(outline)
2586 || (paint.chrome && object_focus.is_none() && outline.id == active)
2587 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
2588 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
2873 if column.is_none()
2874 && (outline.title
2875 || selected(outline)
2876 || (paint.chrome && object_focus.is_none() && outline.id == active)
2877 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
2878 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id))
25892879 {
25902880 append_outline_chrome(
25912881 outline,
......@@ -2596,13 +2886,27 @@ fn page_primitives<'a>(
25962886 primitives,
25972887 );
25982888 }
2889 if column.is_some() && outline.title {
2890 let [x, y] = [origin[0] + offset[0], origin[1] + offset[1]];
2891 append_title_frame(
2892 [
2893 x - 8.25,
2894 y - 3.75,
2895 x + geometry.shaped.size[0] + 2.25,
2896 y + geometry.shaped.size[1] + 3.75,
2897 ],
2898 paint.pixel,
2899 paint.colors.paper,
2900 primitives,
2901 );
2902 }
25992903 append_outline(
26002904 (object_focus.is_none()
26012905 && outline.id == active
26022906 && !matches!(preview, Some(PointerFeedback::Resize(_))))
26032907 .then_some(editor),
26042908 selected(outline),
2605 outline,
2909 geometry,
26062910 [origin[0] + offset[0], origin[1] + offset[1]],
26072911 paint,
26082912 primitives,
......@@ -2633,6 +2937,7 @@ fn page_primitives<'a>(
26332937 &mut primitives,
26342938 *origin,
26352939 Some(editor),
2940 column,
26362941 moving,
26372942 paint.colors.paper,
26382943 &draw_outline,
......@@ -2650,8 +2955,16 @@ fn page_primitives<'a>(
26502955 append_outline(
26512956 Some(editor),
26522957 false,
2653 outline,
2654 outline.origin(),
2958 crate::reading::geometry(outline, column).0,
2959 {
2960 let origin = crate::reading::geometry(outline, column).1;
2961 let offset = if column.is_some() {
2962 scene.map_or([0.0; 2], |(_, offset)| *offset)
2963 } else {
2964 [0.0; 2]
2965 };
2966 [origin[0] + offset[0], origin[1] + offset[1]]
2967 },
26552968 paint,
26562969 &mut primitives,
26572970 )?;
......@@ -2926,14 +3239,7 @@ fn append_outline_chrome(
29263239 let [left, top, right, bottom] = crate::translated(bounds, [dx, dy]);
29273240 let body_top = body_top + dy;
29283241 if outline.title {
2929 primitives.push(Primitive::RoundedRect {
2930 rect: [left, top, right, bottom],
2931 radius: [4.5, (bottom - top) * 0.5],
2932 // A page pixel at 100% zoom, so it scales when zoomed in but stays a screen pixel
2933 // when zoomed out.
2934 stroke: Some(Stroke::Dashed(pixel.max(0.75))),
2935 color: paper.shade(crate::gpu::colorref(0x007f7f7f)),
2936 });
3242 append_title_frame([left, top, right, bottom], pixel, paper, primitives);
29373243 return;
29383244 }
29393245 primitives.push(Primitive::RoundedRect {
......@@ -2991,16 +3297,33 @@ fn append_outline_chrome(
29913297 }
29923298}
29933299
3300fn append_title_frame(
3301 [left, top, right, bottom]: [f32; 4],
3302 pixel: f32,
3303 paper: Paper,
3304 primitives: &mut Vec<Primitive<'_>>,
3305) {
3306 primitives.push(Primitive::RoundedRect {
3307 rect: [left, top, right, bottom],
3308 radius: [4.5, (bottom - top) * 0.5],
3309 // A page pixel at 100% zoom, so it scales when zoomed in but stays a screen pixel
3310 // when zoomed out.
3311 stroke: Some(Stroke::Dashed(pixel.max(0.75))),
3312 color: paper.shade(crate::gpu::colorref(0x007f7f7f)),
3313 });
3314}
3315
29943316/// `editor` paints its selection and caret in the focused outline; `selected` highlights all
29953317/// the text of another.
29963318fn append_outline<'a>(
29973319 editor: Option<&'a CanvasEditor>,
29983320 selected: bool,
2999 outline: &'a TextOutline,
3321 geometry: crate::editor::OutlineGeometry<'a>,
30003322 origin: [f32; 2],
30013323 paint: Paint,
30023324 primitives: &mut Vec<Primitive<'a>>,
30033325) -> Result<()> {
3326 let outline = geometry.source;
30043327 let [x, y] = origin;
30053328 let Paint {
30063329 caret,
......@@ -3015,14 +3338,14 @@ fn append_outline<'a>(
30153338 ..
30163339 } = paint;
30173340 let rows = [visible[0] - y, visible[1] - y];
3018 outline
3019 .shaped()
3341 geometry
3342 .shaped
30203343 .append_table_primitives(primitives, origin, colors.paper);
3021 outline
3022 .shaped()
3344 geometry
3345 .shaped
30233346 .append_block_tag_primitives(primitives, origin, paint.tag_art);
3024 outline
3025 .shaped()
3347 geometry
3348 .shaped
30263349 .append_background_primitives(primitives, origin, rows, colors.paper);
30273350 // OneNote's search highlight: yellow, deepened to gold under light text on dark paper.
30283351 let [red, green, blue, _] = colors.paper.color;
......@@ -3032,7 +3355,7 @@ fn append_outline<'a>(
30323355 draw::srgb(0xff, 0xe6, 0x00)
30333356 };
30343357 for (_, selection) in found.iter().filter(|(id, _)| *id == outline.id) {
3035 for rect in outline.range_rects(*selection)? {
3358 for rect in geometry.range_rects(*selection)? {
30363359 primitives.push(Primitive::Rect {
30373360 rect: [
30383361 rect.x0 as f32 + x,
......@@ -3045,13 +3368,13 @@ fn append_outline<'a>(
30453368 }
30463369 }
30473370 let mut highlight = match editor {
3048 Some(editor) => editor.selection_rects()?,
3049 None if selected => outline.range_rects(outline.whole())?,
3371 Some(editor) => geometry.range_rects(editor.selection())?,
3372 None if selected => geometry.range_rects(outline.whole())?,
30503373 None => Vec::new(),
30513374 };
30523375 // OneNote highlights the note playing as it highlights a selection.
30533376 if let Some((_, selection)) = played.filter(|(id, _)| *id == outline.id) {
3054 highlight.extend(outline.range_rects(*selection)?);
3377 highlight.extend(geometry.range_rects(*selection)?);
30553378 }
30563379 for rect in highlight {
30573380 primitives.push(Primitive::Rect {
......@@ -3064,8 +3387,8 @@ fn append_outline<'a>(
30643387 color: colors.selection,
30653388 });
30663389 }
3067 for (index, paragraph) in outline.shaped().visible(rows) {
3068 outline.shaped().append_paragraph_primitives(
3390 for (index, paragraph) in geometry.shaped.visible(rows) {
3391 geometry.shaped.append_paragraph_primitives(
30693392 index,
30703393 paragraph,
30713394 origin,
......@@ -3081,7 +3404,7 @@ fn append_outline<'a>(
30813404 .map(|(_, at)| at);
30823405 append_marks(
30833406 spelling,
3084 outline,
3407 geometry,
30853408 typing,
30863409 origin,
30873410 rows,
......@@ -3091,7 +3414,17 @@ fn append_outline<'a>(
30913414 )?;
30923415 }
30933416 if let Some(editor) = editor {
3094 for rect in editor.marked_rects()? {
3417 for rect in editor
3418 .marked_range()
3419 .map(|range| {
3420 geometry.range_rects(Selection {
3421 positions: [range.start, range.end],
3422 affinities: [parley::Affinity::Downstream, parley::Affinity::Upstream],
3423 })
3424 })
3425 .transpose()?
3426 .unwrap_or_default()
3427 {
30953428 primitives.push(Primitive::Rect {
30963429 rect: [
30973430 rect.x0 as f32 + x,
......@@ -3105,7 +3438,8 @@ fn append_outline<'a>(
31053438 let [anchor, focus] = editor.selection().positions;
31063439 if caret > 0.0 && anchor == focus {
31073440 // AppKit centres the caret on the insertion point.
3108 let rect = editor.caret(0.0)?;
3441 let selection = editor.selection();
3442 let rect = geometry.caret_at(selection.positions[1], selection.affinities[1], 0.0)?;
31093443 let half = edit::CARET_WIDTH / 2.0 * pixel;
31103444 let middle = rect.x0 as f32 + x;
31113445 primitives.push(Primitive::RoundedRect {
......@@ -3131,7 +3465,7 @@ fn append_outline<'a>(
31313465#[allow(clippy::too_many_arguments)]
31323466fn append_marks(
31333467 spelling: &crate::spelling::Spelling,
3134 outline: &TextOutline,
3468 geometry: crate::editor::OutlineGeometry<'_>,
31353469 typing: Option<crate::document::TextPosition>,
31363470 origin: [f32; 2],
31373471 rows: [f32; 2],
......@@ -3139,9 +3473,10 @@ fn append_marks(
31393473 pixel: f32,
31403474 primitives: &mut Vec<Primitive<'_>>,
31413475) -> Result<()> {
3476 let outline = geometry.source;
31423477 let color = draw::srgb(0xff, 0x00, 0x00);
31433478 let dot = (pixel * scale).round().max(1.0);
3144 let shown = outline.layouts().filter(|(_, paragraph)| {
3479 let shown = geometry.layouts().filter(|(_, paragraph)| {
31453480 paragraph.origin[1] + paragraph.text.height() >= rows[0] && paragraph.origin[1] <= rows[1]
31463481 });
31473482 for (index, _) in shown {
......@@ -3154,7 +3489,7 @@ fn append_marks(
31543489 if typing.is_some_and(|at| at.paragraph == index && at.offset == end) {
31553490 continue;
31563491 }
3157 for [left, right, baseline] in outline.underlines(index, start..end)? {
3492 for [left, right, baseline] in geometry.underlines(index, start..end)? {
31583493 let right = ((origin[0] + right) * scale + device_x).round();
31593494 let mut x = ((origin[0] + left) * scale + device_x).round();
31603495 let top = ((origin[1] + baseline) * scale + device_y).round() + dot;
crates/canvas/src/interaction/profile.rs+6-4
......@@ -60,7 +60,7 @@ fn editing_cost(
6060 let mut access = accessibility::Accessibility::default();
6161 let update = timed(case, "ax_first", 0, || {
6262 access
63 .update(editor, scene, viewport, "Profile", None, None)
63 .update_with(editor, scene, viewport, "Profile", None, None, None)
6464 .unwrap()
6565 });
6666 eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len());
......@@ -107,6 +107,7 @@ fn editing_cost(
107107 scene,
108108 None,
109109 None,
110 None,
110111 super::tests::paint(true, viewport.scale, 1.0),
111112 )
112113 .unwrap();
......@@ -117,7 +118,7 @@ fn editing_cost(
117118 timed(case, "ax_insert", sample, || {
118119 black_box(
119120 access
120 .update(editor, scene, viewport, "Profile", None, None)
121 .update_with(editor, scene, viewport, "Profile", None, None, None)
121122 .unwrap(),
122123 );
123124 });
......@@ -125,7 +126,7 @@ fn editing_cost(
125126 timed(case, "ax_scroll", sample, || {
126127 black_box(
127128 access
128 .update(editor, scene, viewport, "Profile", None, None)
129 .update_with(editor, scene, viewport, "Profile", None, None, None)
129130 .unwrap(),
130131 );
131132 });
......@@ -136,7 +137,7 @@ fn editing_cost(
136137 timed(case, "ax_undo", sample, || {
137138 black_box(
138139 access
139 .update(editor, scene, viewport, "Profile", None, None)
140 .update_with(editor, scene, viewport, "Profile", None, None, None)
140141 .unwrap(),
141142 );
142143 });
......@@ -188,6 +189,7 @@ fn canvas_pipeline_cost() {
188189 Some(&scene),
189190 None,
190191 None,
192 None,
191193 super::tests::paint(false, 96.0 / 72.0, 1.0),
192194 )
193195 .unwrap(),
crates/canvas/src/interaction/tests.rs+680-49
......@@ -90,7 +90,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow()
9090 .unwrap();
9191 }
9292 let update = access
93 .update(&editor, Some(&scene), viewport, "Header", None, None)
93 .update_with(&editor, Some(&scene), viewport, "Header", None, None, None)
9494 .unwrap();
9595 let buttons: Vec<_> =
9696 accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update))
......@@ -177,7 +177,8 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
177177 matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id)
178178 );
179179 }
180 let primitives = page_primitives(&editor, None, None, None, paint(false, 1.0, 1.0)).unwrap();
180 let primitives =
181 page_primitives(&editor, None, None, None, None, paint(false, 1.0, 1.0)).unwrap();
181182 let borders = primitives
182183 .iter()
183184 .filter_map(|p| match p {
......@@ -189,7 +190,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
189190 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])));
190191 let mut access = accessibility::Accessibility::default();
191192 let update = access
192 .update(
193 .update_with(
193194 &editor,
194195 None,
195196 Viewport {
......@@ -200,6 +201,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
200201 "Header",
201202 None,
202203 None,
204 None,
203205 )
204206 .unwrap();
205207 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() {
232234 for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] {
233235 let scale = zoom * 96.0 / 72.0;
234236 let primitives =
235 page_primitives(&editor, None, None, None, paint(false, scale, 1.0)).unwrap();
237 page_primitives(&editor, None, None, None, None, paint(false, scale, 1.0)).unwrap();
236238 let dashed = primitives
237239 .iter()
238240 .filter_map(|primitive| match primitive {
......@@ -265,7 +267,8 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
265267 .move_selection(&mut engine, Movement::Down, false)
266268 .unwrap();
267269 }
268 let primitives = page_primitives(&editor, None, None, None, paint(true, 1.0, 1.0)).unwrap();
270 let primitives =
271 page_primitives(&editor, None, None, None, None, paint(true, 1.0, 1.0)).unwrap();
269272 assert_eq!(
270273 primitives
271274 .iter()
......@@ -279,7 +282,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
279282 );
280283 let mut access = accessibility::Accessibility::default();
281284 let update = access
282 .update(
285 .update_with(
283286 &editor,
284287 None,
285288 Viewport {
......@@ -290,6 +293,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
290293 "Test",
291294 None,
292295 None,
296 None,
293297 )
294298 .unwrap();
295299 let fields = update
......@@ -334,21 +338,28 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
334338 origin: [48.0; 2],
335339 };
336340 let initial = access
337 .update(&editor, None, viewport, "Page", None, None)
341 .update_with(&editor, None, viewport, "Page", None, None, None)
338342 .unwrap();
339343 let id = editor.active_outline().id;
340344 assert_eq!(access.outline_for_node(initial.focus), Some(id));
341345 let rectangles = |editor: &CanvasEditor| {
342 page_primitives(editor, None, None, None, paint(true, viewport.scale, 1.0))
343 .unwrap()
344 .into_iter()
345 .filter_map(|p| match p {
346 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {
347 Some((rect, color))
348 }
349 _ => None,
350 })
351 .collect::<Vec<_>>()
346 page_primitives(
347 editor,
348 None,
349 None,
350 None,
351 None,
352 paint(true, viewport.scale, 1.0),
353 )
354 .unwrap()
355 .into_iter()
356 .filter_map(|p| match p {
357 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {
358 Some((rect, color))
359 }
360 _ => None,
361 })
362 .collect::<Vec<_>>()
352363 };
353364 assert_eq!(rectangles(&editor).len(), 1);
354365 assert_eq!(
......@@ -363,12 +374,12 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
363374 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret)
364375 );
365376 let preedit = access
366 .update(&editor, None, viewport, "Page", None, None)
377 .update_with(&editor, None, viewport, "Page", None, None, None)
367378 .unwrap();
368379 assert_eq!(preedit.focus, initial.focus);
369380 editor.commit_text(&mut engine, "日本".into()).unwrap();
370381 let committed = access
371 .update(&editor, None, viewport, "Page", None, None)
382 .update_with(&editor, None, viewport, "Page", None, None, None)
372383 .unwrap();
373384 assert_eq!(committed.focus, initial.focus);
374385 assert!(rectangles(&editor).len() > 1);
......@@ -376,7 +387,7 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
376387 assert!(editor.outlines().is_empty());
377388 assert_eq!(rectangles(&editor).len(), 1);
378389 let empty = access
379 .update(&editor, None, viewport, "Page", None, None)
390 .update_with(&editor, None, viewport, "Page", None, None, None)
380391 .unwrap();
381392 assert_eq!(empty.focus, committed.focus);
382393 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() {
386397 assert!(editor.outlines().is_empty());
387398 assert_eq!(rectangles(&editor).len(), 1);
388399 let retired = access
389 .update(&editor, None, viewport, "Page", None, None)
400 .update_with(&editor, None, viewport, "Page", None, None, None)
390401 .unwrap();
391402 assert_eq!(retired.focus, committed.focus);
392403 assert_eq!(access.outline_for_node(retired.focus), Some(id));
393404 editor.undo(&mut engine).unwrap();
394405 assert!(rectangles(&editor).len() > 1);
395406 let restored = access
396 .update(&editor, None, viewport, "Page", None, None)
407 .update_with(&editor, None, viewport, "Page", None, None, None)
397408 .unwrap();
398409 assert_eq!(restored.focus, committed.focus);
399410 editor.redo(&mut engine).unwrap();
......@@ -463,6 +474,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
463474 Some(&scene),
464475 None,
465476 None,
477 None,
466478 paint(true, viewport.scale, 1.0),
467479 )
468480 .unwrap()
......@@ -493,7 +505,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
493505 )
494506 .unwrap();
495507 let before = access
496 .update(&editor, None, viewport, "Test", None, None)
508 .update_with(&editor, None, viewport, "Test", None, None, None)
497509 .unwrap();
498510 let before = accessibility::tests::apply(&mut tree, before)
499511 .focus()
......@@ -503,7 +515,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
503515 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();
504516 {
505517 let update = access
506 .update(&editor, None, viewport, "Test", None, None)
518 .update_with(&editor, None, viewport, "Test", None, None, None)
507519 .unwrap();
508520 assert_eq!(
509521 accessibility::tests::apply(&mut tree, update)
......@@ -518,6 +530,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
518530 Some(&scene),
519531 None,
520532 None,
533 None,
521534 paint(true, viewport.scale, 1.0),
522535 )
523536 .unwrap();
......@@ -532,7 +545,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
532545 assert_eq!(editor.active_outline().document(), &original);
533546 assert_eq!(tags(&editor), original_tags);
534547 let update = access
535 .update(&editor, None, viewport, "Test", None, None)
548 .update_with(&editor, None, viewport, "Test", None, None, None)
536549 .unwrap();
537550 assert_eq!(
538551 accessibility::tests::apply(&mut tree, update)
......@@ -622,7 +635,7 @@ fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
622635 let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap();
623636 let second = editor.outlines()[1].id;
624637 let borders = |feedback| {
625 page_primitives(&editor, None, feedback, None, paint(false, 1.0, 1.0))
638 page_primitives(&editor, None, None, feedback, None, paint(false, 1.0, 1.0))
626639 .unwrap()
627640 .into_iter()
628641 .filter_map(|p| match p {
......@@ -900,7 +913,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
900913 append_outline(
901914 Some(&editor),
902915 false,
903 editor.active_outline(),
916 crate::reading::geometry(editor.active_outline(), None).0,
904917 [24.0, 48.0],
905918 Paint {
906919 caret: 0.0,
......@@ -978,7 +991,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
978991 append_outline(
979992 Some(&editor),
980993 false,
981 editor.active_outline(),
994 crate::reading::geometry(editor.active_outline(), None).0,
982995 [24.0, 48.0],
983996 Paint {
984997 caret: 0.0,
......@@ -1047,6 +1060,7 @@ fn resize_preview_paints_reflowed_text_without_changing_the_editor() {
10471060 let primitives = page_primitives(
10481061 &editor,
10491062 None,
1063 None,
10501064 Some(PointerFeedback::Resize(&resized)),
10511065 None,
10521066 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
11411155 None
11421156 );
11431157 let primitives =
1144 page_primitives(&editor, None, None, None, paint(false, scale, dpr)).unwrap();
1158 page_primitives(&editor, None, None, None, None, paint(false, scale, dpr)).unwrap();
11451159 assert!(
11461160 matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])
11471161 );
......@@ -1266,15 +1280,21 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
12661280 .1
12671281 .text
12681282 .id();
1269 let texts: Vec<_> =
1270 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0))
1271 .unwrap()
1272 .into_iter()
1273 .filter_map(|p| match p {
1274 Primitive::Text { text, .. } => Some(painted_layout(text).id()),
1275 _ => None,
1276 })
1277 .collect();
1283 let texts: Vec<_> = page_primitives(
1284 &editor,
1285 Some(&scene),
1286 None,
1287 None,
1288 None,
1289 paint(false, 1.0, 1.0),
1290 )
1291 .unwrap()
1292 .into_iter()
1293 .filter_map(|p| match p {
1294 Primitive::Text { text, .. } => Some(painted_layout(text).id()),
1295 _ => None,
1296 })
1297 .collect();
12781298 assert_eq!(texts.len(), 2);
12791299 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);
12801300 let expected = if readonly_on_top {
......@@ -1318,15 +1338,21 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
13181338 grab: [10.0, -8.0]
13191339 })
13201340 );
1321 let texts: Vec<_> =
1322 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0))
1323 .unwrap()
1324 .into_iter()
1325 .filter_map(|p| match p {
1326 Primitive::Text { text, .. } => Some(painted_layout(text).id()),
1327 _ => None,
1328 })
1329 .collect();
1341 let texts: Vec<_> = page_primitives(
1342 &editor,
1343 Some(&scene),
1344 None,
1345 None,
1346 None,
1347 paint(false, 1.0, 1.0),
1348 )
1349 .unwrap()
1350 .into_iter()
1351 .filter_map(|p| match p {
1352 Primitive::Text { text, .. } => Some(painted_layout(text).id()),
1353 _ => None,
1354 })
1355 .collect();
13301356 assert_eq!(
13311357 *texts.last().unwrap(),
13321358 editor
......@@ -1480,7 +1506,15 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
14801506 };
14811507 for scale in [1.0, 2.0] {
14821508 let text = rectangles(
1483 page_primitives(&editor, Some(&scene), None, None, paint(true, scale, 1.0)).unwrap(),
1509 page_primitives(
1510 &editor,
1511 Some(&scene),
1512 None,
1513 None,
1514 None,
1515 paint(true, scale, 1.0),
1516 )
1517 .unwrap(),
14841518 );
14851519 assert!(text.iter().any(|(_, color)| *color == COLORS.caret));
14861520 let selected = rectangles(
......@@ -1488,6 +1522,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
14881522 &editor,
14891523 Some(&scene),
14901524 None,
1525 None,
14911526 Some(ObjectFocus::ReadOnly(0)),
14921527 paint(true, scale, 1.0),
14931528 )
......@@ -1501,6 +1536,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
15011536 &editor,
15021537 Some(&scene),
15031538 None,
1539 None,
15041540 Some(ObjectFocus::ReadOnly(0)),
15051541 paint(false, scale, 1.0)
15061542 )
......@@ -1514,7 +1550,15 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
15141550 }
15151551 editor.select_all().unwrap();
15161552 let text = rectangles(
1517 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)).unwrap(),
1553 page_primitives(
1554 &editor,
1555 Some(&scene),
1556 None,
1557 None,
1558 None,
1559 paint(false, 1.0, 1.0),
1560 )
1561 .unwrap(),
15181562 );
15191563 assert!(text.iter().any(|(_, color)| *color == COLORS.selection));
15201564 let selected = rectangles(
......@@ -1522,6 +1566,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
15221566 &editor,
15231567 Some(&scene),
15241568 None,
1569 None,
15251570 Some(ObjectFocus::ReadOnly(0)),
15261571 paint(true, 1.0, 1.0),
15271572 )
......@@ -3639,3 +3684,589 @@ fn copied(response: Response) -> Option<String> {
36393684 _ => None,
36403685 }
36413686}
3687
3688fn reader_page() -> Page {
3689 let mut engine = TextEngine::default();
3690 let mut outlines = [
3691 vec!["A title that wraps across several displayed lines on a narrow phone"],
3692 vec!["Monday", "6:14 AM"],
3693 vec!["Retained header"],
3694 vec!["Body 😀 with enough words to wrap across many displayed lines. Body words continue here.", "", "Last paragraph"],
3695 ].map(|text| TextOutline::new(
3696 &mut engine,
3697 TextDocument::new(text.into_iter().map(|text| Paragraph::new(text.into(), Default::default())).collect()).unwrap(),
3698 468.0,
3699 [36.0, 240.0],
3700 ).unwrap().snapshot());
3701 outlines[0].title = true;
3702 outlines[0].layout.x = Some(0.0);
3703 outlines[0].layout.y = Some(0.0);
3704 outlines[1].layout.x = Some(0.0);
3705 outlines[1].layout.y = Some(8.0);
3706 outlines[1].layout.width_set_by_user = Some(false);
3707 outlines[2].layout.x = Some(0.0);
3708 outlines[2].layout.y = Some(30.0);
3709 outlines[2].layout.max_width = Some(120.0);
3710 outlines[2].layout.width_set_by_user = Some(false);
3711 let [title, date, header, body] = outlines;
3712 Page {
3713 identity: None,
3714 title: "Reader".into(),
3715 created: Some(1),
3716 margin_origin: [36.0, 14.4],
3717 rtl: false,
3718 color: None,
3719 rule_lines: None,
3720 definitions: Default::default(),
3721 objects: vec![
3722 onestore::page::PageObject::Title(onestore::page::Title {
3723 id: onestore::ExGuid::default(),
3724 date: Some(date.id),
3725 layout: Default::default(),
3726 outlines: vec![title, date, header],
3727 }),
3728 onestore::page::PageObject::Outline(body),
3729 ],
3730 }
3731}
3732
3733fn reader_view(page: Page) -> PageView {
3734 let mut engine = TextEngine::default();
3735 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
3736 let mut view = PageView::new(
3737 editor,
3738 engine,
3739 Some((scene, [20.0, 40.0])),
3740 [360, 320],
3741 1.0,
3742 Duration::ZERO,
3743 );
3744 view.host_viewport = true;
3745 view.viewport.scale = 1.0;
3746 view.viewport.origin = [0.0; 2];
3747 view
3748}
3749
3750#[test]
3751fn reader_consumers_share_title_header_date_and_body_geometry() {
3752 let mut view = reader_view(reader_page());
3753 let original = view.editor.page().unwrap();
3754 let mut access = accessibility::Accessibility::default();
3755 let mut tree = None;
3756 let mut dates = Vec::new();
3757 let mut body_tops = Vec::new();
3758 let mut hanging_whitespace = false;
3759 for width in [240.0, 160.0] {
3760 assert!(view.set_reading(Some(width)));
3761 let body = view
3762 .editor
3763 .visible_outlines()
3764 .find(|outline| !outline.title)
3765 .unwrap()
3766 .id;
3767 let column = view.column.as_ref().unwrap();
3768 for (_, _, shaped) in &column.outlines {
3769 for paragraph in &shaped.paragraphs {
3770 for (line, _) in paragraph.text.lines() {
3771 let metrics = line.metrics();
3772 assert!(
3773 paragraph.origin[0] + metrics.advance - metrics.trailing_whitespace
3774 <= width + 1.0
3775 );
3776 hanging_whitespace |= paragraph.origin[0] + metrics.advance > width + 1.0;
3777 }
3778 }
3779 }
3780 let (_, origin, shaped) = column
3781 .outlines
3782 .iter()
3783 .find(|(id, _, _)| *id == body)
3784 .unwrap();
3785 body_tops.push(origin[1]);
3786 let (scene, offset) = view.scene.as_ref().unwrap();
3787 assert!(
3788 scene
3789 .content_bounds(&view.editor, Some(column))
3790 .all(|(rect, _)| rect[3] < origin[1])
3791 );
3792 let primitives = view.primitives(COLORS).unwrap();
3793 assert!(primitives.iter().any(|primitive| matches!(primitive,
3794 Primitive::Text { text, origin: painted, .. }
3795 if painted_layout(*text).id() == shaped.paragraphs[0].text.id()
3796 && *painted == [origin[0] + offset[0] + shaped.paragraphs[0].origin[0], origin[1] + offset[1] + shaped.paragraphs[0].origin[1]])));
3797 let header = view
3798 .editor
3799 .objects
3800 .iter()
3801 .find_map(|object| match object {
3802 crate::editor::page::Content::Outline { layout, .. } => {
3803 Some(layout.paragraphs[0].text.id())
3804 }
3805 _ => None,
3806 })
3807 .unwrap();
3808 assert!(primitives.iter().any(|primitive| matches!(primitive,
3809 Primitive::Text { text, .. } if painted_layout(*text).id() == header)));
3810 drop(primitives);
3811 let nodes = accessibility::tests::nodes(accessibility::tests::apply(
3812 &mut tree,
3813 access.update(&view, view.viewport, "Reader").unwrap(),
3814 ));
3815 let field = nodes
3816 .iter()
3817 .find(|node| access.outline_for_node(node.locate().0) == Some(body))
3818 .unwrap();
3819 assert!(
3820 field
3821 .data()
3822 .supports_action(accesskit::Action::ReplaceSelectedText)
3823 );
3824 assert_eq!(
3825 field.bounding_box().unwrap().y0,
3826 f64::from(origin[1] + offset[1])
3827 );
3828 let header = nodes
3829 .iter()
3830 .find(|node| {
3831 node.role() == accesskit::Role::Label
3832 && node.data().value() == Some("Retained header")
3833 })
3834 .unwrap();
3835 assert!(header.bounding_box().unwrap().y1 < field.bounding_box().unwrap().y0);
3836 let buttons: Vec<_> = nodes
3837 .iter()
3838 .filter(|node| node.role() == accesskit::Role::Button)
3839 .collect();
3840 assert_eq!(buttons.len(), 2);
3841 for (index, node) in buttons.iter().enumerate() {
3842 let rect = node.bounding_box().unwrap();
3843 assert_eq!(
3844 view.hit([
3845 ((rect.x0 + rect.x1) / 2.0) as f32,
3846 ((rect.y0 + rect.y1) / 2.0) as f32
3847 ]),
3848 Some(Hit::Date([DateField::Date, DateField::Time][index]))
3849 );
3850 if dates.len() < 2 {
3851 dates.push(node.locate().0);
3852 } else {
3853 assert_eq!(node.locate().0, dates[index]);
3854 }
3855 }
3856 assert_eq!(view.editor.page().unwrap(), original);
3857 assert_eq!(view.editor.history_depth(), [0, 0]);
3858 assert!(view.editor.take_ops().unwrap().is_empty());
3859 }
3860 assert!(body_tops[1] > body_tops[0]);
3861 assert!(hanging_whitespace);
3862 let mut page = reader_page();
3863 let onestore::page::PageObject::Title(title) = &mut page.objects[0] else {
3864 unreachable!()
3865 };
3866 title.outlines[1].layout.width_set_by_user = Some(true);
3867 let mut wide_date = reader_view(page);
3868 assert_eq!(wide_date.editor.date().unwrap().layout().size[0], 468.0);
3869 assert!(!wide_date.reading_status(240.0).0);
3870 assert!(!wide_date.set_reading(Some(240.0)));
3871 assert!(wide_date.editor.take_ops().unwrap().is_empty());
3872}
3873
3874#[test]
3875fn reader_width_changes_preserve_original_ops_undo_and_source_coordinates() {
3876 let page = reader_page();
3877 let mut results = Vec::new();
3878 for width in [160.0, 240.0] {
3879 let mut view = reader_view(page.clone());
3880 let original = view.editor.page().unwrap();
3881 let body = view
3882 .editor
3883 .outlines()
3884 .iter()
3885 .find(|outline| !outline.title)
3886 .unwrap()
3887 .id;
3888 view.editor.focus_outline(body).unwrap();
3889 view.editor
3890 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
3891 .unwrap();
3892 assert!(view.set_reading(Some(width)));
3893 assert!(view.commit_text(" accepted 😀".into()).unwrap().changed);
3894 let committed = view.editor.page().unwrap();
3895 let ops = view.editor.take_ops().unwrap();
3896 assert!(!ops.is_empty());
3897 let history = view.editor.history_depth();
3898 let selection = view.editor.selection();
3899 assert!(view.set_reading(Some(200.0)));
3900 assert_eq!(view.editor.selection(), selection);
3901 assert_eq!(view.editor.history_depth(), history);
3902 assert!(view.editor.take_ops().unwrap().is_empty());
3903 assert!(view.undo(false).unwrap().changed);
3904 assert_eq!(view.editor.page().unwrap(), original);
3905 assert!(view.undo(true).unwrap().changed);
3906 assert_eq!(view.editor.page().unwrap(), committed);
3907 assert_eq!(view.editor.selection(), selection);
3908 results.push((committed, ops, history));
3909 }
3910 assert_eq!(results[0], results[1]);
3911}
3912
3913#[test]
3914fn reader_page_navigation_prepares_a_marked_draft_before_its_first_geometry_query() {
3915 for up in [false, true] {
3916 let mut view = reader_view(reader_page());
3917 let body = view
3918 .editor
3919 .outlines()
3920 .iter()
3921 .find(|outline| !outline.title)
3922 .unwrap()
3923 .id;
3924 view.editor.focus_outline(body).unwrap();
3925 let bottom = view.editor.active_outline().shaped().size[1];
3926 assert!(
3927 view.editor
3928 .select_below(&mut view.engine, body, [10.0, bottom + 20.0])
3929 .unwrap()
3930 );
3931 let first_blank = view
3932 .editor
3933 .outlines()
3934 .iter()
3935 .find(|outline| outline.id == body)
3936 .unwrap()
3937 .document()
3938 .nodes()
3939 .len();
3940 view.editor
3941 .select(
3942 [TextPosition {
3943 paragraph: first_blank,
3944 offset: 0,
3945 }; 2]
3946 .into(),
3947 )
3948 .unwrap();
3949 assert!(view.set_reading(Some(160.0)));
3950 assert!(view.compose("IME 😀".into(), None).unwrap().changed);
3951 let before = view.editor.active_outline().document().nodes().len();
3952 let marked = view.editor.marked_range().unwrap();
3953 let preview = view.editor.page().unwrap();
3954 assert!(view.set_reading(Some(180.0)));
3955 assert_eq!(view.editor.marked_range(), Some(marked.clone()));
3956 assert_eq!(view.editor.page().unwrap(), preview);
3957 assert!(view.relayout().unwrap().moved);
3958 assert_eq!(view.editor.marked_range(), Some(marked.clone()));
3959 assert!(view.editor.take_ops().unwrap().is_empty());
3960 view.move_page(up, true).unwrap();
3961 assert!(view.editor.active_outline().document().nodes().len() < before);
3962 assert!(view.editor.marked_range().is_none());
3963 assert_eq!(view.editor.history_depth(), [1, 0]);
3964 assert!(!view.editor.take_ops().unwrap().is_empty());
3965 let source = view.editor.active_outline();
3966 let geometry = crate::reading::geometry(source, view.column.as_ref()).0;
3967 assert_eq!(
3968 geometry
3969 .layouts()
3970 .map(|(index, _)| source.document().leaf(index).unwrap().2.id)
3971 .collect::<Vec<_>>(),
3972 geometry
3973 .shaped
3974 .paragraphs
3975 .iter()
3976 .map(|paragraph| paragraph.id)
3977 .collect::<Vec<_>>()
3978 );
3979 view.caret_area().unwrap();
3980 view.primitives(COLORS).unwrap();
3981 let committed = view.editor.page().unwrap();
3982 view.move_page(up, false).unwrap();
3983 assert_eq!(view.editor.page().unwrap(), committed);
3984 assert_eq!(view.editor.history_depth(), [1, 0]);
3985 assert!(view.editor.take_ops().unwrap().is_empty());
3986 }
3987}
3988
3989#[test]
3990fn reader_projection_failure_after_an_accepted_edit_keeps_ops_and_full_canvas() {
3991 let mut view = reader_view(reader_page());
3992 let body = view
3993 .editor
3994 .outlines()
3995 .iter()
3996 .find(|outline| !outline.title)
3997 .unwrap()
3998 .id;
3999 view.editor.focus_outline(body).unwrap();
4000 assert!(view.set_reading(Some(200.0)));
4001 view.column.as_mut().unwrap().width = f32::NAN;
4002 assert!(
4003 view.commit_text(" accepted before failed shaping".into())
4004 .unwrap()
4005 .changed
4006 );
4007 assert!(view.column.is_none());
4008 assert!(
4009 view.editor
4010 .active_outline()
4011 .shown_text()
4012 .contains("accepted before failed shaping")
4013 );
4014 assert_eq!(view.editor.history_depth(), [1, 0]);
4015 assert!(!view.editor.take_ops().unwrap().is_empty());
4016 view.primitives(COLORS).unwrap();
4017 assert!(view.set_reading(Some(200.0)));
4018 assert!(view.insert_table(2, 2).unwrap().changed);
4019 assert!(view.column.is_none());
4020 assert!(!view.editor.take_ops().unwrap().is_empty());
4021 view.primitives(COLORS).unwrap();
4022}
4023
4024#[test]
4025fn reader_native_queries_and_accessibility_selection_use_source_utf16() {
4026 let mut view = reader_view(reader_page());
4027 let body = view
4028 .editor
4029 .outlines()
4030 .iter()
4031 .find(|outline| !outline.title)
4032 .unwrap()
4033 .id;
4034 view.editor.focus_outline(body).unwrap();
4035 assert!(view.set_reading(Some(160.0)));
4036 let mut access = accessibility::Accessibility::default();
4037 let mut tree = None;
4038 for offset in [0, 8, 25, 50] {
4039 let at = view.editor.active_outline().utf16_position(offset).unwrap();
4040 view.editor.select([at; 2].into()).unwrap();
4041 assert!(view.focus_text().unwrap().changed);
4042 let caret = view
4043 .caret_at(body, at, parley::Affinity::Downstream, 0.0)
4044 .unwrap();
4045 let hit = view
4046 .selection_at(
4047 body,
4048 [caret.x0 as f32, (caret.y0 + caret.y1) as f32 / 2.0],
4049 SelectionUnit::Grapheme,
4050 )
4051 .unwrap();
4052 assert_eq!(hit.positions, [at; 2]);
4053 let nodes = accessibility::tests::nodes(accessibility::tests::apply(
4054 &mut tree,
4055 access.update(&view, view.viewport, "Reader").unwrap(),
4056 ));
4057 let field = nodes
4058 .iter()
4059 .find(|node| access.outline_for_node(node.locate().0) == Some(body))
4060 .unwrap();
4061 assert_eq!(
4062 access
4063 .selection(body, *field.data().text_selection().unwrap())
4064 .unwrap(),
4065 view.editor.selection()
4066 );
4067 let rect = view.caret_area().unwrap();
4068 assert_eq!(rect[0], caret.x0 as f32);
4069 assert_eq!(rect[1], caret.y0 as f32);
4070 }
4071 for movement in [
4072 Movement::DocumentStart,
4073 Movement::Left,
4074 Movement::Up,
4075 Movement::DocumentEnd,
4076 Movement::Right,
4077 Movement::Down,
4078 ] {
4079 view.move_selection(movement, false).unwrap();
4080 }
4081 assert_eq!(view.editor.outlines().len(), 2);
4082 assert!(view.editor.caret_outline().is_none());
4083 assert!(view.editor.take_ops().unwrap().is_empty());
4084}
4085
4086#[test]
4087fn reader_preserves_nested_visibility_rich_text_and_link_glyph_hits() {
4088 let mut page = reader_page();
4089 let onestore::page::PageObject::Outline(body) = &mut page.objects[1] else {
4090 unreachable!()
4091 };
4092 body.paragraphs[0].collapsed = true;
4093 body.paragraphs[1].parent = Some(body.paragraphs[0].id);
4094 body.paragraphs[1].level = 2;
4095 body.paragraphs[1].text_mut().unwrap().text = Paragraph::new(
4096 "Hidden 😀 child".into(),
4097 Format {
4098 bold: Some(true),
4099 font_size: Some(18.0),
4100 ..Default::default()
4101 },
4102 );
4103 body.paragraphs[2].text_mut().unwrap().text = Paragraph::new(
4104 "Last rich paragraph".into(),
4105 Format {
4106 bold: Some(true),
4107 font_size: Some(18.0),
4108 ..Default::default()
4109 },
4110 );
4111 let mut view = reader_view(page);
4112 let body = view
4113 .editor
4114 .outlines()
4115 .iter()
4116 .find(|outline| !outline.title)
4117 .unwrap()
4118 .id;
4119 view.editor.focus_outline(body).unwrap();
4120 assert!(view.set_reading(Some(160.0)));
4121 let source = view.editor.active_outline();
4122 let geometry = crate::reading::geometry(source, view.column.as_ref()).0;
4123 assert_eq!(
4124 geometry
4125 .layouts()
4126 .map(|(index, _)| index)
4127 .collect::<Vec<_>>(),
4128 [0, 2]
4129 );
4130 let mut access = accessibility::Accessibility::default();
4131 let mut tree = None;
4132 let update = access.update(&view, view.viewport, "Reader").unwrap();
4133 let nodes = accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update));
4134 let field = nodes
4135 .iter()
4136 .find(|node| access.outline_for_node(node.locate().0) == Some(body))
4137 .unwrap();
4138 assert!(!field.data().value().unwrap().contains("Hidden"));
4139 let text = view
4140 .editor
4141 .active_outline()
4142 .document()
4143 .paragraph(0)
4144 .unwrap();
4145 let end = text.utf16_offset(text.text().len()).unwrap();
4146 view.editor
4147 .select(
4148 [
4149 TextPosition {
4150 paragraph: 0,
4151 offset: 0,
4152 },
4153 TextPosition {
4154 paragraph: 0,
4155 offset: end,
4156 },
4157 ]
4158 .into(),
4159 )
4160 .unwrap();
4161 assert!(view.link_prefill().is_some());
4162 assert!(
4163 view.set_link(
4164 "A hyperlink label with several wrapped words",
4165 "https://example.test/reader",
4166 )
4167 .unwrap()
4168 .changed
4169 );
4170 assert_eq!(
4171 view.editor
4172 .link_at(view.editor.selection().positions[1])
4173 .unwrap()
4174 .target,
4175 "https://example.test/reader"
4176 );
4177 assert!(!view.editor.take_ops().unwrap().is_empty());
4178 let source = view.editor.active_outline();
4179 let (geometry, _) = crate::reading::geometry(source, view.column.as_ref());
4180 let paragraph = &geometry.shaped.paragraphs[0];
4181 assert!(paragraph.text.lines().count() > 1);
4182 for (line, bounds) in paragraph.text.lines() {
4183 assert_eq!(
4184 geometry
4185 .link_under([
4186 paragraph.origin[0] + line.metrics().advance / 2.0,
4187 paragraph.origin[1] + bounds.top + bounds.height / 2.0
4188 ])
4189 .as_deref(),
4190 Some("https://example.test/reader")
4191 );
4192 assert!(
4193 geometry
4194 .link_under([
4195 paragraph.origin[0] + line.metrics().advance + 8.0,
4196 paragraph.origin[1] + bounds.top + bounds.height / 2.0
4197 ])
4198 .is_none()
4199 );
4200 }
4201}
4202
4203#[test]
4204fn reader_empty_title_only_and_remote_unsupported_content_follow_current_source() {
4205 let mut page = reader_page();
4206 page.objects.truncate(1);
4207 let mut title_only = reader_view(page.clone());
4208 assert!(title_only.reading_status(160.0).0);
4209 assert!(title_only.set_reading(Some(160.0)));
4210 assert!(title_only.editor.take_ops().unwrap().is_empty());
4211 page.objects.clear();
4212 page.title.clear();
4213 page.created = None;
4214 let mut empty = reader_view(page);
4215 assert!(empty.reading_status(160.0).0);
4216 assert!(empty.set_reading(Some(160.0)));
4217 let outline = empty.editor.active_outline().id;
4218 let caret = empty
4219 .caret_at(
4220 outline,
4221 empty.editor.selection().positions[1],
4222 parley::Affinity::Downstream,
4223 1.0,
4224 )
4225 .unwrap();
4226 assert!(
4227 matches!(empty.hit([caret.x0 as f32 + 0.5, (caret.y0 + caret.y1) as f32 / 2.0]), Some(Hit::Text { id, .. }) if id == outline)
4228 );
4229 assert!(empty.editor.take_ops().unwrap().is_empty());
4230 let mut view = reader_view(reader_page());
4231 assert!(view.set_reading(Some(160.0)));
4232 assert!(
4233 view.commit_text(" preserved history".into())
4234 .unwrap()
4235 .changed
4236 );
4237 view.editor.take_ops().unwrap();
4238 let title = view.editor.active_outline().id;
4239 let history = view.editor.history_depth();
4240 let mut remote = view.editor.page().unwrap();
4241 let mut engine = TextEngine::default();
4242 remote.objects.push(onestore::page::PageObject::Outline(
4243 TextOutline::new(
4244 &mut engine,
4245 TextDocument::new(vec![Paragraph::new(
4246 "Remote annotation".into(),
4247 Default::default(),
4248 )])
4249 .unwrap(),
4250 120.0,
4251 [600.0, 20.0],
4252 )
4253 .unwrap()
4254 .snapshot(),
4255 ));
4256 assert!(view.refresh(remote).unwrap().moved);
4257 assert!(view.column.is_none());
4258 assert_eq!(view.editor.history_depth(), history);
4259 assert_eq!(view.editor.active_outline().id, title);
4260 assert!(
4261 view.editor
4262 .active_outline()
4263 .shown_text()
4264 .contains("preserved history")
4265 );
4266 view.primitives(COLORS).unwrap();
4267 assert!(!view.reading_status(160.0).0);
4268 assert!(view.editor.take_ops().unwrap().is_empty());
4269 let mut rtl = reader_page();
4270 rtl.rtl = true;
4271 assert!(!reader_view(rtl).reading_status(160.0).0);
4272}
crates/canvas/src/reading.rs+179-1
......@@ -9,7 +9,7 @@
99//! it. Text rewraps to the column, pictures shrink to it, and everything else keeps its size.
1010
1111use crate::{
12 editor::{CanvasEditor, EditorError, TextOutline, page::Content},
12 editor::{CanvasEditor, EditorError},
1313 layout::TextEngine,
1414};
1515use onestore::{
......@@ -18,6 +18,184 @@ use onestore::{
1818};
1919use std::{collections::BTreeMap, ops::Range};
2020
21#[cfg(feature = "gpu")]
22use crate::editor::TextOutline;
23#[cfg(feature = "interaction")]
24use crate::editor::page::Content;
25
26#[cfg(feature = "gpu")]
27pub(crate) struct Column {
28 #[cfg(feature = "interaction")]
29 pub(crate) width: f32,
30 pub(crate) outlines: Vec<(ExGuid, [f32; 2], crate::outline::OutlineLayout)>,
31}
32
33#[cfg(feature = "gpu")]
34pub(crate) fn geometry<'a>(
35 source: &'a TextOutline,
36 column: Option<&'a Column>,
37) -> (crate::editor::OutlineGeometry<'a>, [f32; 2]) {
38 let (shaped, origin) = match column {
39 Some(column) => {
40 let (_, origin, shaped) = column
41 .outlines
42 .iter()
43 .find(|(id, _, _)| *id == source.id)
44 .expect("active column contains every current outline");
45 (shaped, *origin)
46 }
47 None => (source.shaped(), source.origin()),
48 };
49 (crate::editor::OutlineGeometry { source, shaped }, origin)
50}
51
52#[cfg(feature = "interaction")]
53impl Column {
54 pub(crate) fn build(
55 editor: &CanvasEditor,
56 scene: Option<&crate::gpu::page::PageScene>,
57 width: f32,
58 engine: &mut TextEngine,
59 ) -> Result<Option<Self>, crate::layout::LayoutError> {
60 use crate::outline::{OutlineLayout, ParagraphLayout};
61 if !width.is_finite() || width <= 0.0 {
62 return Err(crate::layout::LayoutError::InvalidWidth);
63 }
64 if editor.rtl() {
65 return Ok(None);
66 }
67 let title = editor
68 .visible_outlines()
69 .find(|outline| outline.title)
70 .map(|outline| outline.id);
71 if editor.objects.iter().any(|object| {
72 if let Content::Date { below_title } | Content::Outline { below_title, .. } = object
73 && (below_title.is_none() || *below_title != title)
74 {
75 return true;
76 }
77 if let Content::Outline { source, .. } = object
78 && source
79 .paragraphs
80 .iter()
81 .any(|paragraph| !matches!(paragraph.content, ParagraphContent::Text(_)))
82 {
83 return true;
84 }
85 !matches!(
86 object,
87 Content::Editable(_)
88 | Content::Date { .. }
89 | Content::Outline {
90 below_title: Some(_),
91 ..
92 }
93 )
94 }) {
95 return Ok(None);
96 }
97 if scene.is_none()
98 && editor
99 .objects
100 .iter()
101 .any(|object| !matches!(object, Content::Editable(_)))
102 {
103 return Ok(None);
104 }
105 let mut sources: Vec<_> = editor
106 .visible_outlines()
107 .chain(editor.caret_outline())
108 .collect();
109 if sources.iter().filter(|outline| !outline.title).count() > 1
110 || sources.iter().filter(|outline| outline.title).count() > 1
111 {
112 return Ok(None);
113 }
114 sources.sort_by_key(|outline| !outline.title);
115 let mut column = Self {
116 width,
117 outlines: Vec::new(),
118 };
119 let mut top = 14.4;
120 for source in sources {
121 if source
122 .document()
123 .nodes()
124 .iter()
125 .any(|node| !matches!(node.content, ParagraphContent::Text(_)))
126 {
127 return Ok(None);
128 }
129 if source.document().paragraphs().any(|text| {
130 text.spans()
131 .iter()
132 .any(|span| span.format.math == Some(true) || span.format.math_object.is_some())
133 }) {
134 return Ok(None);
135 }
136 let shaped = OutlineLayout::flow(
137 source.document().nodes().iter(),
138 &source.indents,
139 width,
140 false,
141 0,
142 None,
143 &mut |node, previous, width, indents| {
144 ParagraphLayout::shape(
145 engine,
146 node,
147 previous,
148 width,
149 indents,
150 &editor.definitions,
151 )
152 },
153 )?;
154 if shaped.size[0] > width + 1.0
155 || shaped.paragraphs.iter().any(|paragraph| {
156 paragraph.origin[0] < 0.0
157 || paragraph.origin[0]
158 + paragraph
159 .text
160 .lines()
161 .map(|(line, _)| {
162 line.metrics().advance - line.metrics().trailing_whitespace
163 })
164 .fold(0.0_f32, f32::max)
165 > width + 1.0
166 })
167 {
168 return Ok(None);
169 }
170 let origin = [36.0, top];
171 top += shaped.size[1] + GAP;
172 column.outlines.push((source.id, origin, shaped));
173 }
174 if let Some(scene) = scene {
175 let mut bottom = column
176 .outlines
177 .iter()
178 .find(|(id, _, _)| Some(*id) == title)
179 .map(|(_, origin, shaped)| origin[1] + shaped.size[1] + GAP)
180 .unwrap_or(14.4);
181 for (rect, _) in scene.content_bounds(editor, Some(&column)) {
182 if rect[0] < 36.0 || rect[2] > 36.0 + width + 1.0 {
183 return Ok(None);
184 }
185 bottom = bottom.max(rect[3] + GAP);
186 }
187 if let Some((_, origin, _)) = column
188 .outlines
189 .iter_mut()
190 .find(|(id, _, _)| Some(*id) != title)
191 {
192 origin[1] = bottom;
193 }
194 }
195 Ok(Some(column))
196 }
197}
198
21199/// Points between blocks in the column: OneNote's grid row.
22200const GAP: f32 = 18.0;
23201/// Strokes this near one another are one drawing, as handwriting is many strokes.
crates/mobile/Cargo.toml+1
......@@ -29,4 +29,5 @@ objc2-foundation = { version = "0.2.2", default-features = false, features = ["s
2929
3030[dev-dependencies]
3131relay = { path = "../relay" }
32rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] }
3233tempfile = "3"
crates/mobile/include/snowbound.h+5-2
......@@ -100,8 +100,11 @@ bool sb_view_frame_pending(const View *view);
100100void sb_view_set_dark(View *view, bool dark);
101101bool sb_view_read_only(const View *view);
102102char *sb_view_reading(const View *view, float width);
103bool sb_view_set_reading(View *view, float width);
104bool sb_view_reload(View *view, bool discard);
103// Preserves the nearest source caret's host offset from the page-point anchor at scale 1.
104bool sb_view_set_reading(View *view, float width, float anchor[2]);
105bool sb_view_reload_ready(const View *view);
106// A non-null page-point anchor follows its source caret across the refresh.
107bool sb_view_reload(View *view, bool discard, float anchor[2]);
105108void sb_view_focus(View *view, bool focused);
106109void sb_view_content(View *view, float bounds[4]);
107110bool sb_view_block(const View *view, float x, float y, float rect[4]);
crates/mobile/src/lib.rs+259-144
......@@ -33,7 +33,7 @@ use onestore::{
3333 op::{Edit, Op, PageOp},
3434 page::{Page, ink::ShapeKind},
3535};
36use parley::{Affinity, BoundingBox};
36use parley::Affinity;
3737use std::{
3838 error::Error,
3939 ffi::{CStr, CString, c_char, c_void},
......@@ -228,14 +228,6 @@ impl Canvas {
228228 self.page.viewport.origin = corner.map(|value| -value * self.scale * zoom);
229229 }
230230
231 fn outline(&self, id: ExGuid) -> Option<&TextOutline> {
232 let editor = &self.page.editor;
233 editor
234 .visible_outlines()
235 .chain(editor.caret_outline())
236 .find(|outline| outline.id == id)
237 }
238
239231 fn target(&self, point: [f32; 2]) -> Target {
240232 let device = self.device(point);
241233 let [x, y] = self.page.viewport.document_point(device);
......@@ -254,10 +246,13 @@ impl Canvas {
254246 {
255247 Target::Grip
256248 }
257 Some(Hit::Text { id, point })
258 if !self
259 .outline(id)
260 .is_some_and(|outline| outline.contains_extension(point)) =>
249 Some(Hit::Text { id, .. })
250 if self
251 .page
252 .text_block([x, y])
253 .is_some_and(|[x0, y0, x1, y1]| {
254 (x0..=x1).contains(&x) && (y0..=y1).contains(&y)
255 }) =>
261256 {
262257 if id == self.page.editor.active_outline().id {
263258 Target::ActiveText
......@@ -301,25 +296,11 @@ impl Canvas {
301296 Ok(changed)
302297 }
303298
304 /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom.
299 /// The nearest displayed text outline, in points at 100% zoom.
305300 fn block(&self, point: [f32; 2]) -> Option<[f32; 4]> {
306 let [x, y] = self
307 .page
308 .viewport
309 .document_point(self.device(point))
310 .map(f64::from);
311 let distance = |rect: &BoundingBox| {
312 let dx = (rect.x0 - x).max(x - rect.x1).max(0.0);
313 let dy = (rect.y0 - y).max(y - rect.y1).max(0.0);
314 dx.hypot(dy)
315 };
316 let rect = self
317 .page
318 .editor
319 .visible_outlines()
320 .map(TextOutline::bounds)
321 .min_by(|a, b| distance(a).total_cmp(&distance(b)))?;
322 Some([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32 * POINT))
301 self.page
302 .text_block(self.page.viewport.document_point(self.device(point)))
303 .map(|rect| rect.map(|value| value * POINT))
323304 }
324305
325306 fn active(&self) -> &TextOutline {
......@@ -562,14 +543,6 @@ impl Canvas {
562543 .map(|rect| self.document_rect(rect))
563544 }
564545
565 /// A rectangle in the active outline's coordinates, in the view's points as `[x, y,
566 /// width, height]`.
567 fn view_rect(&self, rect: BoundingBox) -> [f32; 4] {
568 let [x, y] = self.active().origin();
569 let [x0, y0, x1, y1] = [rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32);
570 self.document_rect([x0 + x, y0 + y, x1 + x, y1 + y])
571 }
572
573546 /// Page rectangle `[x0, y0, x1, y1]` in the view's points as `[x, y, width, height]`.
574547 fn document_rect(&self, [x0, y0, x1, y1]: [f32; 4]) -> [f32; 4] {
575548 let viewport = self.page.viewport;
......@@ -583,30 +556,33 @@ impl Canvas {
583556
584557 fn caret_rect(&self, offset: u32) -> Result<[f32; 4]> {
585558 let position = self.active().utf16_position(offset)?;
586 let caret = self
559 let rect = self
587560 .page
588 .editor
589 .caret_at(position, Affinity::Downstream, 1.0)?;
590 Ok(self.view_rect(caret))
561 .caret_at(self.active().id, position, Affinity::Downstream, 1.0)?;
562 Ok(self.document_rect([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32)))
591563 }
592564
593 /// The selection rectangles of a range, one or more per line.
594565 fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> {
595 let rects = self.active().range_rects(Selection {
596 positions: self.positions(range)?,
597 affinities: [Affinity::Downstream, Affinity::Upstream],
598 })?;
599 Ok(rects.into_iter().map(|rect| self.view_rect(rect)).collect())
566 let rects = self.page.range_rects(
567 self.active().id,
568 Selection {
569 positions: self.positions(range)?,
570 affinities: [Affinity::Downstream, Affinity::Upstream],
571 },
572 )?;
573 Ok(rects
574 .into_iter()
575 .map(|rect| {
576 self.document_rect([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32))
577 })
578 .collect())
600579 }
601580
602581 fn closest(&self, point: [f32; 2]) -> Result<u32> {
603582 let point = self.page.viewport.document_point(self.device(point));
604 let origin = self.active().origin();
605 let selection = self.page.editor.selection_at(
606 point[0] - origin[0],
607 point[1] - origin[1],
608 SelectionUnit::Grapheme,
609 )?;
583 let selection = self
584 .page
585 .selection_at(self.active().id, point, SelectionUnit::Grapheme)?;
610586 Ok(self.active().utf16_offset(selection.positions[1])?)
611587 }
612588}
......@@ -803,6 +779,44 @@ impl Presentation {
803779 }
804780}
805781
782#[derive(Default)]
783struct LocalEdits {
784 pending: usize,
785 failed: bool,
786}
787
788/// Settles the original apply even when failed ingress drops its callback.
789struct ApplyReply(Option<Arc<Mutex<LocalEdits>>>);
790
791impl ApplyReply {
792 fn new(edits: &Arc<Mutex<LocalEdits>>) -> Self {
793 if let Ok(mut edits) = edits.lock() {
794 edits.pending += 1;
795 }
796 Self(Some(Arc::clone(edits)))
797 }
798
799 fn complete(mut self, result: std::result::Result<u64, notebook::Error>) {
800 if let Some(edits) = self.0.take()
801 && let Ok(mut edits) = edits.lock()
802 {
803 edits.pending -= 1;
804 edits.failed |= result.is_err();
805 }
806 }
807}
808
809impl Drop for ApplyReply {
810 fn drop(&mut self) {
811 if let Some(edits) = self.0.take()
812 && let Ok(mut edits) = edits.lock()
813 {
814 edits.pending -= 1;
815 edits.failed = true;
816 }
817 }
818}
819
806820pub struct View {
807821 /// Taken apart on drop, which returns its text engine to `ENGINES`.
808822 canvas: ManuallyDrop<Canvas>,
......@@ -811,8 +825,9 @@ pub struct View {
811825 section: Arc<Shared>,
812826 /// A conflict page, which shows what is stored and takes no edits.
813827 read_only: bool,
814 /// The column, in page points, a reading view reflows the page into; it takes no edits.
815 reading: Option<f32>,
828 reload_pending: bool,
829 /// Original applies stay counted across an explicit discard.
830 local_edits: Arc<Mutex<LocalEdits>>,
816831}
817832
818833impl View {
......@@ -829,11 +844,17 @@ impl View {
829844 // SAFETY: the caller supplies the platform window's lifetime contract.
830845 let presentation = unsafe { Presentation::new(window, pixels) }?;
831846 let (mut page, read_only) = section.shared.page(space)?;
847 let local_edits = Arc::new(Mutex::new(LocalEdits::default()));
832848 // The page wears its theme: style objects it gives otherwise are restyled first.
833849 let sheet = section.sheet(page.identity);
834850 let restyle = onestore::op::restyle(&page, &sheet)?;
835851 if !read_only && !restyle.is_empty() {
836 section.shared.apply(page_edit(space, restyle))?;
852 let reply = ApplyReply::new(&local_edits);
853 section
854 .shared
855 .apply_with(page_edit(space, restyle), move |result| {
856 reply.complete(result)
857 })?;
837858 page = section.shared.page(space)?.0;
838859 }
839860 let frame = Arc::<Frame>::default();
......@@ -852,7 +873,8 @@ impl View {
852873 frame,
853874 section: Arc::clone(&section.shared),
854875 read_only,
855 reading: None,
876 reload_pending: false,
877 local_edits,
856878 })
857879 }
858880
......@@ -901,10 +923,9 @@ impl View {
901923 /// conflict page returns to what is stored instead.
902924 fn stored(&mut self, result: Result<bool>) -> bool {
903925 let changed = report(result).unwrap_or(false);
904 match report(self.canvas.edit()) {
905 Some(Some(_)) if self.read_only || self.reading.is_some() => {
906 let _ = report(self.reload());
907 }
926 self.canvas.page.reconcile_reading();
927 let submitted = match report(self.canvas.edit()) {
928 Some(Some(_)) if self.read_only => report(self.reload(true)).is_some(),
908929 Some(Some(edit)) => {
909930 // Pictures that came or went, by insertion or undo, are drawn or dropped.
910931 if edit.ops.iter().any(|op| {
......@@ -922,53 +943,128 @@ impl View {
922943 let _ = report(refreshed.map_err(Into::into));
923944 }
924945 }
925 let _ = report(self.section.apply(edit));
946 self.canvas.page.reconcile_reading();
947 let reply = ApplyReply::new(&self.local_edits);
948 report(
949 self.section
950 .apply_with(edit, move |result| reply.complete(result)),
951 )
952 .is_some()
953 }
954 Some(None) => true,
955 None => {
956 if let Ok(mut edits) = self.local_edits.lock() {
957 edits.failed = true;
958 }
959 false
960 }
961 };
962 let refreshed =
963 submitted && sb_view_reload_ready(self) && report(self.reload(false)).unwrap_or(false);
964 changed || refreshed
965 }
966
967 fn anchored<T>(
968 &mut self,
969 anchor: Option<&mut [f32; 2]>,
970 target_zoom: Option<f32>,
971 change: impl FnOnce(&mut Self) -> T,
972 ) -> T {
973 let Some(anchor) = anchor else {
974 return change(self);
975 };
976 let point = (*anchor).map(|value| value / POINT);
977 let zoom = self.canvas.page.viewport.scale / (self.canvas.scale * POINT);
978 let target_zoom = target_zoom.unwrap_or(zoom);
979 let page = &self.canvas.page;
980 let source = point
981 .iter()
982 .all(|value| value.is_finite())
983 .then(|| {
984 page.editor
985 .visible_outlines()
986 .chain(page.editor.caret_outline())
987 .filter_map(|outline| {
988 let selection = page
989 .selection_at(outline.id, point, SelectionUnit::Grapheme)
990 .ok()?;
991 let position = selection.positions[1];
992 let affinity = selection.affinities[1];
993 let rect = page.caret_at(outline.id, position, affinity, 0.0).ok()?;
994 let dx = (rect.x0 - f64::from(point[0])).abs();
995 let dy = (rect.y0 - f64::from(point[1]))
996 .max(f64::from(point[1]) - rect.y1)
997 .max(0.0);
998 Some((
999 dx.hypot(dy),
1000 outline,
1001 position,
1002 affinity,
1003 [rect.x0, rect.y0],
1004 ))
1005 })
1006 .min_by(|a, b| a.0.total_cmp(&b.0))
1007 .map(|(_, outline, position, affinity, before)| {
1008 (
1009 outline.id,
1010 outline.document().clone(),
1011 position,
1012 affinity,
1013 before,
1014 )
1015 })
1016 })
1017 .flatten();
1018 let result = change(self);
1019 if let Some((id, document, position, affinity, before)) = source {
1020 let page = &self.canvas.page;
1021 let outline = page
1022 .editor
1023 .visible_outlines()
1024 .chain(page.editor.caret_outline())
1025 .find(|outline| outline.id == id)
1026 .unwrap_or_else(|| page.editor.active_outline());
1027 let position = canvas::editor::follow(&document, outline.document(), position);
1028 if let Ok(rect) = page.caret_at(outline.id, position, affinity, 0.0) {
1029 for (axis, after) in [rect.x0, rect.y0].into_iter().enumerate() {
1030 let relative = (before[axis] * f64::from(POINT) - f64::from(anchor[axis]))
1031 * f64::from(zoom);
1032 anchor[axis] =
1033 (after * f64::from(POINT) - relative / f64::from(target_zoom)) as f32;
1034 }
9261035 }
927 _ => {}
9281036 }
929 changed
1037 result
9301038 }
9311039
932 /// Shows the page as stored, in place: the caret, selection and drawn pictures stay.
933 /// Waits for marked text to end.
934 fn reload(&mut self) -> Result<bool> {
935 if self.canvas.page.editor.marked_range().is_some() {
1040 /// Defers automatic replacement until marking ends and original local replies succeed.
1041 fn reload(&mut self, discard: bool) -> Result<bool> {
1042 self.reload_pending = true;
1043 if self.canvas.page.editor.marked_range().is_some()
1044 || (!discard && !sb_view_reload_ready(self))
1045 {
9361046 return Ok(false);
9371047 }
938 let (page, read_only) = self.section.page(self.canvas.space)?;
939 self.read_only = read_only;
940 if let Some(column) = self.reading {
941 return self.show(page, Some(column));
1048 // Reset before reading so an outstanding reply's failure cannot be cleared afterward.
1049 let failed = discard
1050 && self
1051 .local_edits
1052 .lock()
1053 .is_ok_and(|mut edits| std::mem::replace(&mut edits.failed, false));
1054 let result = (|| {
1055 let (page, read_only) = self.section.page(self.canvas.space)?;
1056 self.read_only = read_only;
1057 let response = self.canvas.page.refresh(page)?;
1058 self.reload_pending = false;
1059 Ok(moved(response))
1060 })();
1061 if result.is_err()
1062 && failed
1063 && let Ok(mut edits) = self.local_edits.lock()
1064 {
1065 edits.failed = true;
9421066 }
943 Ok(moved(self.canvas.page.refresh(page)?))
944 }
945
946 /// Shows `page` anew, reflowed into `column` where given and the page reflows; whether
947 /// it shows reflowed.
948 fn show(&mut self, page: Page, column: Option<f32>) -> Result<bool> {
949 let mut engine = pooled_engine();
950 let reflowed = match column {
951 Some(column) => canvas::reading::read(&page, column, &mut engine)?.page,
952 None => None,
953 };
954 pool_engine(engine);
955 self.reading = column.filter(|_| reflowed.is_some());
956 let pixels = self.canvas.page.viewport.size;
957 let mut canvas = Canvas::new(
958 self.canvas.space,
959 reflowed.unwrap_or(page),
960 pixels,
961 self.canvas.scale,
962 )?;
963 canvas.paper = self.canvas.paper;
964 canvas.page.spelling = self.canvas.page.spelling.take();
965 let _ = canvas.page.focus_changed(false)?;
966 canvas.page.editor.styles = std::mem::take(&mut self.canvas.page.editor.styles);
967 // SAFETY: `self.canvas` is replaced at once and not used in between.
968 let old = unsafe { ManuallyDrop::take(&mut self.canvas) };
969 self.canvas = ManuallyDrop::new(canvas);
970 pool_engine(old.page.engine);
971 Ok(self.reading.is_some())
1067 result
9721068 }
9731069}
9741070
......@@ -1105,49 +1201,58 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) {
11051201 view.canvas.set_dark(dark);
11061202}
11071203
1108/// A conflict page or a reading view: it shows what is stored, and any edit returns it there.
1204/// A conflict page shows what is stored, and any edit returns it there.
11091205#[unsafe(no_mangle)]
11101206pub extern "C" fn sb_view_read_only(view: &View) -> bool {
1111 view.read_only || view.reading.is_some()
1207 view.read_only
11121208}
11131209
1114/// Whether the page has a reading view `width` points wide, as JSON: `offered`, and the
1115/// `verdict` saying how it reflows or why not.
1210/// Reading eligibility and active projection as JSON: `offered`, `shown`, and `verdict`.
11161211#[unsafe(no_mangle)]
11171212pub extern "C" fn sb_view_reading(view: &View, width: f32) -> *mut c_char {
1118 let result = view.section.page(view.canvas.space).and_then(|(page, _)| {
1119 let mut engine = pooled_engine();
1120 let read = canvas::reading::read(&page, width / POINT, &mut engine);
1121 pool_engine(engine);
1122 Ok(read?.verdict)
1123 });
1124 report(result).map_or(std::ptr::null_mut(), |verdict| {
1125 let json =
1126 serde_json::json!({ "offered": verdict.offered(), "verdict": format!("{verdict:?}") });
1127 owned(json.to_string())
1213 let (offered, shown) = view.canvas.page.reading_status(width / POINT);
1214 let json = serde_json::json!({ "offered": offered, "shown": shown, "verdict": if offered { "Reflows" } else { "Refused(Unsupported)" } });
1215 owned(json.to_string())
1216}
1217
1218/// Shows editable text reflowed into a column `width` points wide, or with 0 as laid
1219/// out; whether it now shows reflowed. Moves the page-point viewport `anchor` so its
1220/// nearest source caret keeps its relative host offset at scale 1; unchanged without text.
1221#[unsafe(no_mangle)]
1222pub extern "C" fn sb_view_set_reading(view: &mut View, width: f32, anchor: &mut [f32; 2]) -> bool {
1223 view.anchored(Some(anchor), Some(1.0), |view| {
1224 view.canvas
1225 .page
1226 .set_reading((width > 0.0).then_some(width / POINT))
11281227 })
11291228}
11301229
1131/// Shows the page reflowed into a column `width` points wide, read-only, or with 0 as laid
1132/// out; whether it now shows reflowed.
1230/// Whether an existing deferred reload can read without replacing unconfirmed local edits.
11331231#[unsafe(no_mangle)]
1134pub extern "C" fn sb_view_set_reading(view: &mut View, width: f32) -> bool {
1135 let column = (width > 0.0).then_some(width / POINT);
1136 let shown = view
1137 .section
1138 .page(view.canvas.space)
1139 .and_then(|(page, _)| view.show(page, column));
1140 report(shown).unwrap_or(false)
1232pub extern "C" fn sb_view_reload_ready(view: &View) -> bool {
1233 view.reload_pending
1234 && view.canvas.page.editor.marked_range().is_none()
1235 && view
1236 .local_edits
1237 .lock()
1238 .is_ok_and(|edits| edits.pending == 0 && !edits.failed)
11411239}
11421240
11431241/// Shows the page as stored after a change made elsewhere, keeping the caret; with
11441242/// `discard`, after the section refused an edit, drops what the page took since.
1243/// A supplied page-point anchor follows its source caret across the refresh.
11451244#[unsafe(no_mangle)]
1146pub extern "C" fn sb_view_reload(view: &mut View, discard: bool) -> bool {
1147 if discard {
1148 let _ = view.canvas.page.editor.take_ops();
1149 }
1150 report(view.reload()).unwrap_or(false)
1245pub extern "C" fn sb_view_reload(
1246 view: &mut View,
1247 discard: bool,
1248 anchor: Option<&mut [f32; 2]>,
1249) -> bool {
1250 view.anchored(anchor, None, |view| {
1251 if discard {
1252 let _ = view.canvas.page.editor.take_ops();
1253 }
1254 report(view.reload(discard)).unwrap_or(false)
1255 })
11511256}
11521257
11531258/// 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) {
12121317
12131318#[unsafe(no_mangle)]
12141319pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool {
1215 report(view.canvas.drag([x, y])).unwrap_or(false)
1320 let result = view.canvas.drag([x, y]);
1321 if view.canvas.page.inking() {
1322 return report(result).unwrap_or(false);
1323 }
1324 view.stored(result)
12161325}
12171326
12181327#[unsafe(no_mangle)]
......@@ -1350,7 +1459,8 @@ pub extern "C" fn sb_view_title(view: &View) -> *mut c_char {
13501459/// Puts the caret at the end of the page title; false on a page without one.
13511460#[unsafe(no_mangle)]
13521461pub extern "C" fn sb_view_focus_title(view: &mut View) -> bool {
1353 report(view.canvas.focus_title()).unwrap_or(false)
1462 let result = view.canvas.focus_title();
1463 view.stored(result)
13541464}
13551465
13561466/// 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 {
13591469/// `query` is NUL-terminated UTF-8.
13601470#[unsafe(no_mangle)]
13611471pub unsafe extern "C" fn sb_view_find(view: &mut View, query: *const c_char) -> bool {
1362 report(view.canvas.find(&string(query))).unwrap_or(false)
1472 let result = view.canvas.find(&string(query));
1473 view.stored(result)
13631474}
13641475
13651476/// 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_
13721483 .parse()
13731484 .map_err(Into::into)
13741485 .and_then(|id| view.canvas.select_paragraph(id));
1375 report(result).unwrap_or(false)
1486 view.stored(result)
13761487}
13771488
13781489/// 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]) {
16641775
16651776#[unsafe(no_mangle)]
16661777pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool {
1667 report(view.canvas.select([start, end])).is_some()
1778 let result = view.canvas.select([start, end]);
1779 let success = result.is_ok();
1780 view.stored(result.map(|()| false));
1781 success
16681782}
16691783
16701784/// Widens the selection as OneNote's Ctrl+A does, one enclosing unit per call.
16711785#[unsafe(no_mangle)]
16721786pub extern "C" fn sb_select_more(view: &mut View) -> bool {
1673 report(
1674 view.canvas
1675 .page
1676 .editor
1677 .widen_selection()
1678 .map_err(Into::into),
1679 )
1680 .is_some()
1787 let result = view
1788 .canvas
1789 .page
1790 .editor
1791 .widen_selection()
1792 .map_err(Into::into);
1793 let success = result.is_ok();
1794 view.stored(result.map(|()| false));
1795 success
16811796}
16821797
16831798#[unsafe(no_mangle)]
crates/mobile/src/library.rs+13-1
......@@ -810,8 +810,20 @@ impl Shared {
810810 }
811811
812812 pub fn apply(&self, edit: Edit) -> Result<()> {
813 self.section.apply(&self.author(), edit)?;
813 self.apply_with(edit, |_| {})
814 }
815
816 pub fn apply_with(
817 &self,
818 edit: Edit,
819 reply: impl FnOnce(std::result::Result<u64, notebook::Error>) + Send + 'static,
820 ) -> Result<()> {
814821 self.status.store(Status::Saving as u8, Ordering::Relaxed);
822 if let Err(error) = self.section.apply_with(&self.author(), edit, reply) {
823 self.status
824 .store(Status::NotSaving as u8, Ordering::Relaxed);
825 return Err(error.into());
826 }
815827 Ok(())
816828 }
817829
crates/mobile/src/recording.rs+1-1
......@@ -117,7 +117,7 @@ pub unsafe extern "C" fn sb_view_start_recording(
117117 date: *const c_char,
118118 time: *const c_char,
119119) -> bool {
120 if view.read_only || view.reading.is_some() {
120 if view.read_only {
121121 return false;
122122 }
123123 let started = view
crates/mobile/src/tests.rs+978-1
......@@ -1748,7 +1748,8 @@ fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() {
17481748 frame: Arc::default(),
17491749 section: Arc::clone(&section.shared),
17501750 read_only,
1751 reading: None,
1751 reload_pending: false,
1752 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
17521753 };
17531754 assert!(sb_view_focus_title(&mut view));
17541755 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() {
23642365 }
23652366 assert!(!Path::new(cache.to_str().unwrap()).exists());
23662367}
2368
2369#[test]
2370fn reader_native_selection_commits_marked_ops_and_keeps_the_original_offline_editor() {
2371 let (directory, section) = features();
2372 sb_library_set_offline(&section.library, true);
2373 let file = directory.path().join("Features.one");
2374 let canonical = std::fs::read(&file).unwrap();
2375 let space = section.new_page(None, "Mon", "6:14 AM").unwrap();
2376 let (page, read_only) = section.shared.page(space).unwrap();
2377 let mut view = View {
2378 canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()),
2379 presentation: None,
2380 frame: Arc::default(),
2381 section: Arc::clone(&section.shared),
2382 read_only,
2383 reload_pending: false,
2384 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
2385 };
2386 view.canvas.set_transform(1.0, [0.0; 2]);
2387 let reading = |view: &View| {
2388 let query = sb_view_reading(view, 240.0);
2389 let status = serde_json::from_str::<serde_json::Value>(&string(query)).unwrap();
2390 unsafe { sb_string_free(query) };
2391 status
2392 };
2393 assert_eq!(reading(&view)["offered"], true);
2394 assert_eq!(reading(&view)["shown"], false);
2395 assert!(sb_view_focus_title(&mut view));
2396 assert!(unsafe { sb_insert(&mut view, c"A title that wraps on a narrow phone".as_ptr()) });
2397 let title = view.canvas.active().id;
2398 view.canvas.set_transform(1.5, [0.0; 2]);
2399 let before = view
2400 .canvas
2401 .page
2402 .caret_at(
2403 title,
2404 TextPosition {
2405 paragraph: 0,
2406 offset: 0,
2407 },
2408 Affinity::Downstream,
2409 0.0,
2410 )
2411 .unwrap();
2412 let mut anchor = [
2413 before.x0 as f32 * POINT - 4.0,
2414 before.y0 as f32 * POINT - 7.0,
2415 ];
2416 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
2417 let after = view
2418 .canvas
2419 .page
2420 .caret_at(
2421 title,
2422 TextPosition {
2423 paragraph: 0,
2424 offset: 0,
2425 },
2426 Affinity::Downstream,
2427 0.0,
2428 )
2429 .unwrap();
2430 for (axis, caret) in [after.x0, after.y0].into_iter().enumerate() {
2431 assert!(
2432 (caret * f64::from(POINT) - f64::from(anchor[axis]) - [4.0, 7.0][axis] * 1.5).abs()
2433 < 0.001
2434 );
2435 }
2436 view.canvas.set_transform(1.0, [0.0; 2]);
2437 assert!(!sb_view_read_only(&view));
2438 assert_eq!(reading(&view)["offered"], true);
2439 assert_eq!(reading(&view)["shown"], true);
2440 assert!(unsafe { sb_insert(&mut view, c"\n".as_ptr()) });
2441 assert!(unsafe {
2442 sb_insert(
2443 &mut view,
2444 c"Body 😀 with several wrapped words before the last line".as_ptr(),
2445 )
2446 });
2447 let body = view.canvas.active().id;
2448 let original = view.canvas.page.editor.page().unwrap();
2449 let history = view.canvas.page.editor.history_depth();
2450 let mut anchored_moved = false;
2451 for (source, offset) in [(title, 0), (body, 35)] {
2452 for width in [320.0, 240.0] {
2453 let before = view
2454 .canvas
2455 .page
2456 .caret_at(
2457 source,
2458 TextPosition {
2459 paragraph: 0,
2460 offset,
2461 },
2462 Affinity::Downstream,
2463 0.0,
2464 )
2465 .unwrap();
2466 anchor = [
2467 before.x0 as f32 * POINT,
2468 (before.y0 + (before.y1 - before.y0) / 2.0) as f32 * POINT,
2469 ];
2470 let old_anchor = anchor;
2471 assert!(sb_view_set_reading(&mut view, width, &mut anchor));
2472 let after = view
2473 .canvas
2474 .page
2475 .caret_at(
2476 source,
2477 TextPosition {
2478 paragraph: 0,
2479 offset,
2480 },
2481 Affinity::Downstream,
2482 0.0,
2483 )
2484 .unwrap();
2485 assert!(
2486 (f64::from(anchor[1] - old_anchor[1]) - (after.y0 - before.y0) * f64::from(POINT))
2487 .abs()
2488 < 0.001
2489 );
2490 eprintln!(
2491 "Reader anchor source={source} utf16={offset} width={width} before=({},{}) after=({},{}) anchor_delta_y={}",
2492 before.x0,
2493 before.y0,
2494 after.x0,
2495 after.y0,
2496 anchor[1] - old_anchor[1]
2497 );
2498 anchored_moved |= (after.y0 - before.y0).abs() > 0.1;
2499 for offset in [0, 8, 24] {
2500 let mut rect = [0.0; 4];
2501 assert!(sb_caret_rect(&view, offset, &mut rect));
2502 assert_eq!(
2503 view.canvas
2504 .closest([rect[0], rect[1] + rect[3] / 2.0])
2505 .unwrap(),
2506 offset
2507 );
2508 assert_eq!(
2509 view.canvas.target([rect[0] + 1.0, rect[1] + rect[3] / 2.0]),
2510 Target::ActiveText
2511 );
2512 }
2513 assert!(
2514 view.canvas
2515 .range_rects([0, view.canvas.length()])
2516 .unwrap()
2517 .len()
2518 > 1
2519 );
2520 assert_eq!(view.canvas.page.editor.page().unwrap(), original);
2521 assert_eq!(view.canvas.page.editor.history_depth(), history);
2522 assert_eq!(view.canvas.active().id, body);
2523 }
2524 }
2525 assert!(anchored_moved);
2526 assert!(sb_select(&mut view, 0, 0));
2527 assert!(unsafe { sb_set_marked(&mut view, c"marked 😀 ".as_ptr(), 10, 10) });
2528 let mut marked = [0; 2];
2529 assert!(sb_marked(&view, &mut marked));
2530 let preview = view.canvas.page.editor.page().unwrap();
2531 let selection = view.canvas.page.editor.selection();
2532 let history = view.canvas.page.editor.history_depth();
2533 for width in [320.0, 0.0, 240.0] {
2534 assert_eq!(
2535 sb_view_set_reading(&mut view, width, &mut anchor),
2536 width > 0.0
2537 );
2538 let mut unchanged = [0; 2];
2539 assert!(sb_marked(&view, &mut unchanged));
2540 assert_eq!(unchanged, marked);
2541 assert_eq!(view.canvas.page.editor.selection(), selection);
2542 assert_eq!(view.canvas.page.editor.history_depth(), history);
2543 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
2544 assert_eq!(view.canvas.active().id, body);
2545 assert!(!sb_view_reload(&mut view, false, Some(&mut anchor)));
2546 assert!(sb_marked(&view, &mut unchanged));
2547 assert_eq!(unchanged, marked);
2548 assert_eq!(view.canvas.page.editor.selection(), selection);
2549 assert_eq!(view.canvas.page.editor.history_depth(), history);
2550 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
2551 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2552 }
2553 assert!(sb_select(&mut view, 0, 0));
2554 assert!(!sb_marked(&view, &mut marked));
2555 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2556 let stored = section.shared.page(space).unwrap().0;
2557 let mut engine = pooled_engine();
2558 let stored = canvas::editor::CanvasEditor::from_page(stored, &mut engine).unwrap();
2559 pool_engine(engine);
2560 assert!(
2561 stored
2562 .outlines()
2563 .iter()
2564 .any(|outline| outline.id == body && outline.shown_text().contains("marked 😀"))
2565 );
2566 assert!(sb_view_undo(&mut view, false));
2567 assert!(!view.canvas.active().shown_text().contains("marked 😀"));
2568 assert!(sb_view_undo(&mut view, true));
2569 assert!(view.canvas.active().shown_text().contains("marked 😀"));
2570 for route in 0..6 {
2571 assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) });
2572 assert!(sb_select(&mut view, 0, 0));
2573 let paragraph = CString::new(
2574 view.canvas
2575 .active()
2576 .document()
2577 .text_nodes()
2578 .next()
2579 .unwrap()
2580 .id
2581 .to_string(),
2582 )
2583 .unwrap();
2584 if route == 5 {
2585 let mut rect = [0.0; 4];
2586 assert!(sb_caret_rect(&view, 0, &mut rect));
2587 assert!(sb_view_press(&mut view, rect[0], rect[1] + rect[3] / 2.0));
2588 }
2589 let text = format!("route{route} 😀 ");
2590 let marked_text = CString::new(text.clone()).unwrap();
2591 let end = u32::try_from(text.encode_utf16().count()).unwrap();
2592 assert!(unsafe { sb_set_marked(&mut view, marked_text.as_ptr(), end, end) });
2593 assert!(sb_marked(&view, &mut marked));
2594 match route {
2595 0 => assert!(sb_view_focus_title(&mut view)),
2596 1 => assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) }),
2597 2 => assert!(unsafe { sb_view_select_paragraph(&mut view, paragraph.as_ptr()) }),
2598 3 => assert!(sb_select_more(&mut view)),
2599 4 => sb_unmark(&mut view),
2600 _ => {
2601 let mut rect = [0.0; 4];
2602 assert!(sb_caret_rect(&view, end, &mut rect));
2603 assert!(sb_view_drag(&mut view, rect[0], rect[1] + rect[3] / 2.0));
2604 sb_view_release(&mut view);
2605 }
2606 }
2607 assert!(!sb_marked(&view, &mut marked));
2608 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2609 let mut rect = [0.0; 4];
2610 assert!(sb_caret_rect(&view, 0, &mut rect));
2611 let mut engine = pooled_engine();
2612 let stored = canvas::editor::CanvasEditor::from_page(
2613 section.shared.page(space).unwrap().0,
2614 &mut engine,
2615 )
2616 .unwrap();
2617 pool_engine(engine);
2618 assert!(
2619 stored
2620 .outlines()
2621 .iter()
2622 .any(|outline| outline.id == body && outline.shown_text().contains(text.trim()))
2623 );
2624 assert_eq!(reading(&view)["shown"], true);
2625 }
2626 let title_text = view
2627 .canvas
2628 .page
2629 .editor
2630 .visible_outlines()
2631 .find(|outline| outline.title)
2632 .unwrap()
2633 .document()
2634 .text_nodes()
2635 .next()
2636 .unwrap()
2637 .text()
2638 .unwrap()
2639 .id;
2640 for shown in [true, false] {
2641 for select in [true, false] {
2642 assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) });
2643 assert!(sb_select(&mut view, 0, 0));
2644 assert_eq!(
2645 sb_view_set_reading(&mut view, if shown { 240.0 } else { 0.0 }, &mut anchor),
2646 shown
2647 );
2648 if sb_view_format(&view) & 1 == 0 {
2649 assert!(sb_view_apply(&mut view, 0));
2650 }
2651 assert_ne!(sb_view_format(&view) & 1, 0);
2652 let local_before = view.canvas.active().shown_text();
2653 let text = view
2654 .canvas
2655 .active()
2656 .document()
2657 .text_nodes()
2658 .next()
2659 .unwrap()
2660 .text()
2661 .unwrap()
2662 .id;
2663 let history = view.canvas.page.editor.history_depth();
2664 let local = format!("retained {shown} {select} 😀 ");
2665 let local_text = CString::new(local.clone()).unwrap();
2666 let end = u32::try_from(local.encode_utf16().count()).unwrap();
2667 assert!(unsafe { sb_set_marked(&mut view, local_text.as_ptr(), end, end) });
2668 let preview = view.canvas.page.editor.page().unwrap();
2669 assert_eq!(
2670 view.canvas.active().shown_text(),
2671 format!("{local}{local_before}")
2672 );
2673 let local_nodes = view.canvas.active().document().nodes().to_vec();
2674 let selection = view.canvas.page.editor.selection();
2675 let peer = format!("peer {shown} {select} ");
2676 section
2677 .shared
2678 .section
2679 .apply(
2680 "Reader peer",
2681 page_edit(
2682 space,
2683 vec![PageOp::Text {
2684 text: title_text,
2685 range: 0..0,
2686 with: peer.clone(),
2687 }],
2688 ),
2689 )
2690 .unwrap();
2691 section.shared.section.written().unwrap();
2692 let pending_before = section.shared.section.pending().unwrap();
2693 let mut predicted = section.shared.page(space).unwrap().0;
2694 assert!(!sb_view_reload(&mut view, false, Some(&mut anchor)));
2695 assert!(view.reload_pending);
2696 assert!(sb_marked(&view, &mut marked));
2697 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
2698 assert_eq!(view.canvas.page.editor.selection(), selection);
2699 assert_eq!(view.canvas.page.editor.history_depth(), history);
2700 if select {
2701 assert!(sb_select(&mut view, 0, 0));
2702 } else {
2703 sb_unmark(&mut view);
2704 }
2705 assert!(!sb_marked(&view, &mut marked));
2706 let deadline = Instant::now() + Duration::from_secs(10);
2707 while view.reload_pending && !sb_view_reload_ready(&view) {
2708 let _ = section.poll();
2709 assert!(Instant::now() < deadline, "Original apply did not complete");
2710 std::thread::sleep(Duration::from_millis(10));
2711 }
2712 if sb_view_reload_ready(&view) {
2713 sb_view_reload(&mut view, false, Some(&mut anchor));
2714 }
2715 assert!(!view.reload_pending);
2716 assert_eq!(reading(&view)["shown"], shown);
2717 assert_eq!(view.canvas.active().id, body);
2718 assert!(view.canvas.page.editor.history_depth()[0] <= history[0] + 1);
2719 let expected_selection = if select {
2720 Selection::from(
2721 [TextPosition {
2722 paragraph: 0,
2723 offset: 0,
2724 }; 2],
2725 )
2726 } else {
2727 selection
2728 };
2729 assert_eq!(view.canvas.page.editor.selection(), expected_selection);
2730 assert!(
2731 view.canvas.page.editor.visible_outlines().any(|outline| {
2732 outline.id == title && outline.shown_text().starts_with(&peer)
2733 })
2734 );
2735 section.shared.section.written().unwrap();
2736 let pending = section.shared.section.pending().unwrap();
2737 assert_eq!(pending.len(), pending_before.len() + 1);
2738 assert_eq!(&pending[..pending_before.len()], pending_before.as_slice());
2739 let row = pending.last().unwrap();
2740 assert_eq!(row.author, "Clover Test");
2741 let edit = &row.edit;
2742 assert!(edit.ops.iter().any(|op| matches!(op,
2743 Op::Page { space: target, op: PageOp::Text { text: target_text, .. } }
2744 if *target == space && *target_text == text
2745 )));
2746 for op in &edit.ops {
2747 let Op::Page { space: target, op } = op else {
2748 panic!("Composition changed the section")
2749 };
2750 assert_eq!(*target, space);
2751 onestore::op::predict(&mut predicted, op).unwrap();
2752 }
2753 let mut normalized = preview.clone();
2754 for node in &local_nodes {
2755 let text = node.text().unwrap();
2756 onestore::op::predict(
2757 &mut normalized,
2758 &PageOp::Format {
2759 text: text.id,
2760 range: 0..text.text.utf16_offset(text.text.text().len()).unwrap(),
2761 set: Vec::new(),
2762 clear: Vec::new(),
2763 },
2764 )
2765 .unwrap();
2766 }
2767 let [stored_body, marked_body] = [&predicted, &normalized].map(|page| {
2768 page.objects
2769 .iter()
2770 .find_map(|object| match object {
2771 onestore::page::PageObject::Outline(outline) if outline.id == body => {
2772 Some(&outline.paragraphs)
2773 }
2774 _ => None,
2775 })
2776 .unwrap()
2777 });
2778 assert_eq!(stored_body, marked_body);
2779 assert_eq!(section.shared.page(space).unwrap().0, predicted);
2780 assert_eq!(view.canvas.active().document().nodes(), local_nodes);
2781 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2782 assert!(sb_view_undo(&mut view, false));
2783 assert_eq!(view.canvas.active().shown_text(), local_before);
2784 assert!(
2785 view.canvas.page.editor.visible_outlines().any(|outline| {
2786 outline.id == title && outline.shown_text().starts_with(&peer)
2787 })
2788 );
2789 assert!(sb_view_undo(&mut view, true));
2790 assert_eq!(
2791 view.canvas.active().shown_text(),
2792 format!("{local}{local_before}")
2793 );
2794 assert_eq!(reading(&view)["shown"], shown);
2795 }
2796 }
2797 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
2798 let before = view
2799 .canvas
2800 .page
2801 .caret_at(
2802 body,
2803 TextPosition {
2804 paragraph: 0,
2805 offset: 8,
2806 },
2807 Affinity::Downstream,
2808 0.0,
2809 )
2810 .unwrap();
2811 anchor = [
2812 before.x0 as f32 * POINT,
2813 (before.y0 + (before.y1 - before.y0) / 2.0) as f32 * POINT,
2814 ];
2815 let old_anchor = anchor;
2816 let selection = view.canvas.page.editor.selection();
2817 let history = view.canvas.page.editor.history_depth();
2818 section
2819 .shared
2820 .section
2821 .apply(
2822 "Reader peer",
2823 page_edit(
2824 space,
2825 vec![PageOp::Text {
2826 text: title_text,
2827 range: 0..0,
2828 with: "Peer title text before the original title ".into(),
2829 }],
2830 ),
2831 )
2832 .unwrap();
2833 section.shared.section.written().unwrap();
2834 let pending_before_reload = section.shared.section.pending().unwrap();
2835 assert!(sb_view_reload(&mut view, false, Some(&mut anchor)));
2836 let after = view
2837 .canvas
2838 .page
2839 .caret_at(
2840 body,
2841 TextPosition {
2842 paragraph: 0,
2843 offset: 8,
2844 },
2845 Affinity::Downstream,
2846 0.0,
2847 )
2848 .unwrap();
2849 assert!((after.y0 - before.y0).abs() > 0.1);
2850 assert!(
2851 (f64::from(anchor[1] - old_anchor[1]) - (after.y0 - before.y0) * f64::from(POINT)).abs()
2852 < 0.001
2853 );
2854 assert_eq!(view.canvas.page.editor.selection(), selection);
2855 assert!(view.canvas.page.editor.history_depth()[0] <= history[0]);
2856 assert_eq!(view.canvas.active().id, body);
2857 assert_eq!(reading(&view)["shown"], true);
2858 assert_eq!(
2859 section.shared.section.pending().unwrap(),
2860 pending_before_reload
2861 );
2862 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2863 let page = view.canvas.page.editor.page().unwrap();
2864 let created = page.created.unwrap();
2865 let fields: Vec<_> = view
2866 .canvas
2867 .page
2868 .editor
2869 .date()
2870 .unwrap()
2871 .source()
2872 .paragraphs
2873 .iter()
2874 .map(|node| {
2875 let text = node.text().unwrap();
2876 (text.id, text.text.text().to_owned())
2877 })
2878 .collect();
2879 for overwide in [true, false] {
2880 let mut next = fields.clone();
2881 if overwide {
2882 next[0].1 = "W".repeat(80);
2883 }
2884 section
2885 .shared
2886 .section
2887 .apply(
2888 "Reader peer",
2889 page_edit(
2890 space,
2891 vec![PageOp::Date {
2892 created,
2893 fields: next,
2894 }],
2895 ),
2896 )
2897 .unwrap();
2898 section.shared.section.written().unwrap();
2899 let pending_before_reload = section.shared.section.pending().unwrap();
2900 let history = view.canvas.page.editor.history_depth();
2901 assert!(sb_view_reload(&mut view, false, Some(&mut anchor)));
2902 assert_eq!(reading(&view)["shown"], false);
2903 assert_eq!(reading(&view)["offered"], !overwide);
2904 assert_eq!(view.canvas.page.editor.selection(), selection);
2905 assert_eq!(view.canvas.page.editor.history_depth(), history);
2906 assert_eq!(view.canvas.active().id, body);
2907 assert_eq!(
2908 section.shared.section.pending().unwrap(),
2909 pending_before_reload
2910 );
2911 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2912 }
2913 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
2914 assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) });
2915 assert!(!sb_view_set_reading(&mut view, 0.0, &mut anchor));
2916 assert_eq!(reading(&view)["shown"], false);
2917 assert_eq!(view.canvas.active().id, body);
2918 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
2919 sb_view_set_tool(&mut view, 1, 0, 0);
2920 assert_eq!(reading(&view)["shown"], false);
2921 assert_eq!(reading(&view)["offered"], true);
2922 sb_view_set_tool(&mut view, 0, 0, 0);
2923 assert_eq!(reading(&view)["shown"], false);
2924 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
2925 section.shared.section.written().unwrap();
2926 let mut predicted = section.shared.page(space).unwrap().0;
2927 let pending_before = section.shared.section.pending().unwrap();
2928 let result = view.canvas.page.insert_table(2, 2).map(moved);
2929 assert!(view.stored(result));
2930 assert_eq!(reading(&view)["shown"], false);
2931 assert_eq!(reading(&view)["offered"], false);
2932 assert!(!sb_view_read_only(&view));
2933 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2934 section.shared.section.written().unwrap();
2935 let pending = section.shared.section.pending().unwrap();
2936 assert_eq!(pending.len(), pending_before.len() + 1);
2937 assert_eq!(&pending[..pending_before.len()], pending_before.as_slice());
2938 let table = view
2939 .canvas
2940 .active()
2941 .document()
2942 .nodes()
2943 .iter()
2944 .find(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_)))
2945 .unwrap();
2946 let edit = &pending.last().unwrap().edit;
2947 assert!(edit.ops.iter().any(|op| matches!(op, Op::Page { space: target, op: PageOp::Insert { container, paragraphs, .. } }
2948 if *target == space && *container == body && paragraphs.iter().any(|node| node.id == table.id))));
2949 let mut normalized = predicted.clone();
2950 let definitions = &view.canvas.page.editor.page().unwrap().definitions;
2951 for op in
2952 onestore::op::lower(body, &[], std::slice::from_ref(table), None, definitions).unwrap()
2953 {
2954 onestore::op::predict(&mut normalized, &op).unwrap();
2955 }
2956 for op in &edit.ops {
2957 let Op::Page { space: target, op } = op else {
2958 panic!("Table insertion changed the section")
2959 };
2960 assert_eq!(*target, space);
2961 onestore::op::predict(&mut predicted, op).unwrap();
2962 }
2963 let tables = [&normalized, &predicted].map(|page| {
2964 page.objects
2965 .iter()
2966 .find_map(|object| match object {
2967 onestore::page::PageObject::Outline(outline) if outline.id == body => {
2968 outline.paragraphs.iter().find(|node| node.id == table.id)
2969 }
2970 _ => None,
2971 })
2972 .unwrap()
2973 });
2974 assert_eq!(tables[0], tables[1]);
2975 assert_eq!(section.shared.page(space).unwrap().0, predicted);
2976 assert!(pending.iter().all(|edit| {
2977 section.shared.section.status(edit.id).unwrap() == Some(notebook::EditStatus::Pending)
2978 }));
2979 assert_eq!(std::fs::read(file).unwrap(), canonical);
2980}
2981
2982#[test]
2983fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() {
2984 use std::sync::mpsc;
2985
2986 let (directory, file) = copy("media-edit/candidate/Features.one");
2987 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
2988 sb_library_set_offline(&library, true);
2989 let section = open(&library, file.to_str().unwrap());
2990 let space = space(&section, "Paragraph controls");
2991 let (page, read_only) = section.shared.page(space).unwrap();
2992 let mut view = View {
2993 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
2994 presentation: None,
2995 frame: Arc::default(),
2996 section: Arc::clone(&section.shared),
2997 read_only,
2998 reload_pending: true,
2999 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3000 };
3001 focus(&mut view.canvas, "Collapsed parent");
3002 assert!(sb_view_reload_ready(&view));
3003 let before = section.shared.section.pending().unwrap();
3004 let (started, start) = mpsc::channel();
3005 let (release, released) = mpsc::channel();
3006 assert!(view.canvas.insert("first 🦀 ".into()).unwrap());
3007 let first = view.canvas.edit().unwrap().unwrap();
3008 let expected_first = first.clone();
3009 let first_reply = ApplyReply::new(&view.local_edits);
3010 section
3011 .shared
3012 .apply_with(first, move |result| {
3013 started.send(()).unwrap();
3014 released.recv_timeout(Duration::from_secs(10)).unwrap();
3015 first_reply.complete(result);
3016 })
3017 .unwrap();
3018 start.recv_timeout(Duration::from_secs(10)).unwrap();
3019 assert!(!sb_view_reload_ready(&view));
3020 assert!(view.canvas.insert("second 😀 ".into()).unwrap());
3021 let second = view.canvas.edit().unwrap().unwrap();
3022 let expected_second = second.clone();
3023 let second_reply = ApplyReply::new(&view.local_edits);
3024 let (started, start) = mpsc::channel();
3025 let (release_second, released_second) = mpsc::channel();
3026 section
3027 .shared
3028 .apply_with(second, move |result| {
3029 started.send(()).unwrap();
3030 released_second
3031 .recv_timeout(Duration::from_secs(10))
3032 .unwrap();
3033 second_reply.complete(result);
3034 })
3035 .unwrap();
3036 assert_eq!(view.local_edits.lock().unwrap().pending, 2);
3037 release.send(()).unwrap();
3038 start.recv_timeout(Duration::from_secs(10)).unwrap();
3039 assert_eq!(view.local_edits.lock().unwrap().pending, 1);
3040 assert!(!sb_view_reload_ready(&view));
3041 release_second.send(()).unwrap();
3042 let deadline = Instant::now() + Duration::from_secs(10);
3043 while !sb_view_reload_ready(&view) {
3044 assert!(Instant::now() < deadline);
3045 std::thread::sleep(Duration::from_millis(10));
3046 }
3047 sb_view_reload(&mut view, false, None);
3048 assert!(!view.reload_pending);
3049 assert!(!sb_view_reload_ready(&view));
3050 let pending = section.shared.section.pending().unwrap();
3051 assert_eq!(pending.len(), before.len() + 2);
3052 assert_eq!(&pending[..before.len()], before.as_slice());
3053 assert_eq!(pending[before.len()].edit, expected_first);
3054 assert_eq!(pending[before.len() + 1].edit, expected_second);
3055 assert!(pending[before.len()].id < pending[before.len() + 1].id);
3056 assert!(view.canvas.active().shown_text().contains("first 🦀"));
3057 assert!(view.canvas.active().shown_text().contains("second 😀"));
3058
3059 let (started, start) = mpsc::channel();
3060 let (release, released) = mpsc::channel();
3061 let (finished, finish) = mpsc::channel();
3062 assert!(view.canvas.insert("late reply ".into()).unwrap());
3063 let third = view.canvas.edit().unwrap().unwrap();
3064 let third_reply = ApplyReply::new(&view.local_edits);
3065 let weak = Arc::downgrade(&view.local_edits);
3066 section
3067 .shared
3068 .apply_with(third, move |result| {
3069 started.send(()).unwrap();
3070 released.recv_timeout(Duration::from_secs(10)).unwrap();
3071 third_reply.complete(result);
3072 finished.send(()).unwrap();
3073 })
3074 .unwrap();
3075 start.recv_timeout(Duration::from_secs(10)).unwrap();
3076 drop(view);
3077 assert!(weak.upgrade().is_some());
3078 release.send(()).unwrap();
3079 finish.recv_timeout(Duration::from_secs(10)).unwrap();
3080 assert!(weak.upgrade().is_none());
3081 section.shared.section.written().unwrap();
3082 assert_eq!(
3083 section.shared.section.pending().unwrap().len(),
3084 before.len() + 3
3085 );
3086 drop((section, directory));
3087}
3088
3089#[test]
3090fn reader_dropped_and_synchronous_failed_apply_callbacks_never_release_the_reload_fence() {
3091 for synchronous in [false, true] {
3092 let edits = Arc::new(Mutex::new(LocalEdits::default()));
3093 let reply = ApplyReply::new(&edits);
3094 let callback: Box<dyn FnOnce(std::result::Result<u64, notebook::Error>)> =
3095 Box::new(move |result| reply.complete(result));
3096 if synchronous {
3097 callback(Err(
3098 std::io::Error::other("Disposable ingress failure").into()
3099 ));
3100 } else {
3101 drop(callback);
3102 }
3103 assert_eq!(edits.lock().unwrap().pending, 0);
3104 assert!(edits.lock().unwrap().failed);
3105 }
3106}
3107
3108#[test]
3109fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_failure() {
3110 for select in [false, true] {
3111 let directory = tempfile::tempdir().unwrap();
3112 let file = directory.path().join("Reader fault.one");
3113 let cache = directory.path().join("cache");
3114 let original =
3115 onestore::create_section("Reader fault.one", "Original body", "Fixture").unwrap();
3116 std::fs::write(&file, &original).unwrap();
3117 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
3118 sb_library_set_offline(&library, true);
3119 let section = Section::open(
3120 Arc::clone(&library),
3121 file.to_str().unwrap(),
3122 "Fixture".into(),
3123 || {},
3124 )
3125 .unwrap();
3126 let identity = section.shared.section.identity().unwrap();
3127 let space = section.shared.section.pages().unwrap()[0].0;
3128 let (page, read_only) = section.shared.page(space).unwrap();
3129 let mut view = View {
3130 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
3131 presentation: None,
3132 frame: Arc::default(),
3133 section: Arc::clone(&section.shared),
3134 read_only,
3135 reload_pending: false,
3136 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3137 };
3138 focus(&mut view.canvas, "Original body");
3139 assert!(sb_select(&mut view, 0, 0));
3140 if sb_view_format(&view) & 1 == 0 {
3141 assert!(sb_view_apply(&mut view, 0));
3142 }
3143 assert!(unsafe { sb_insert(&mut view, c"SEED ".as_ptr()) });
3144 section.shared.section.written().unwrap();
3145 assert_eq!(view.local_edits.lock().unwrap().pending, 0);
3146 assert!(!view.local_edits.lock().unwrap().failed);
3147 let seed = section.shared.section.pending().unwrap();
3148 assert_eq!(seed.len(), 1);
3149 let location = notebook::location::local(&file).unwrap();
3150 let root = onestore::RevisionIndex::parse(&onestore::Store::parse(&original).unwrap())
3151 .unwrap()
3152 .root
3153 .guid;
3154 let database = notebook::location::folder(&cache, &location).join(format!(
3155 "{}.sqlite",
3156 root.iter()
3157 .map(|byte| format!("{byte:02x}"))
3158 .collect::<String>(),
3159 ));
3160 assert!(database.is_file());
3161 let shared = Arc::clone(&section.shared);
3162 drop((view, section, library));
3163 Arc::into_inner(shared)
3164 .expect("No other seed writer")
3165 .section
3166 .close()
3167 .unwrap();
3168 let sql = rusqlite::Connection::open(&database).unwrap();
3169 sql.busy_timeout(Duration::from_secs(1)).unwrap();
3170 sql.execute_batch(
3171 "CREATE TRIGGER reader_fault BEFORE INSERT ON edits WHEN NEW.author = 'Fixture'
3172 BEGIN SELECT RAISE(ABORT, 'Disposable Reader write failure'); END;",
3173 )
3174 .unwrap();
3175 sql.close().unwrap();
3176
3177 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
3178 sb_library_set_offline(&library, true);
3179 let section = Section::open(
3180 Arc::clone(&library),
3181 file.to_str().unwrap(),
3182 "Fixture".into(),
3183 || {},
3184 )
3185 .unwrap();
3186 assert_eq!(section.shared.section.identity().unwrap(), identity);
3187 assert_eq!(section.shared.section.pending().unwrap(), seed);
3188 let (page, read_only) = section.shared.page(space).unwrap();
3189 let mut view = View {
3190 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
3191 presentation: None,
3192 frame: Arc::default(),
3193 section: Arc::clone(&section.shared),
3194 read_only,
3195 reload_pending: false,
3196 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3197 };
3198 focus(&mut view.canvas, "Original body");
3199 assert!(sb_select(&mut view, 0, 0));
3200 let mut anchor = [0.0; 2];
3201 assert_eq!(
3202 sb_view_set_reading(&mut view, if select { 0.0 } else { 240.0 }, &mut anchor),
3203 !select
3204 );
3205 assert_ne!(sb_view_format(&view) & 1, 0);
3206 assert!(unsafe { sb_set_marked(&mut view, c"FAILED rich 😀 ".as_ptr(), 15, 15) });
3207 let nodes = view.canvas.active().document().nodes().to_vec();
3208 let preview = view.canvas.page.editor.page().unwrap();
3209 section
3210 .shared
3211 .section
3212 .apply(
3213 "Reader peer",
3214 page_edit(space, vec![PageOp::Color(Some(0x0011_2233))]),
3215 )
3216 .unwrap();
3217 section.shared.section.written().unwrap();
3218 let before = section.shared.section.pending().unwrap();
3219 assert_eq!(before.len(), seed.len() + 1);
3220 assert_eq!(&before[..seed.len()], seed.as_slice());
3221 assert!(!sb_view_reload(&mut view, false, Some(&mut anchor)));
3222 assert!(view.reload_pending);
3223 assert!(!sb_view_reload_ready(&view));
3224 if select {
3225 assert!(sb_select(&mut view, 0, 0));
3226 } else {
3227 sb_unmark(&mut view);
3228 }
3229 let history = view.canvas.page.editor.history_depth();
3230 let selection = view.canvas.page.editor.selection();
3231 let deadline = Instant::now() + Duration::from_secs(10);
3232 let mut flags = 0;
3233 while section.shared.status.load(Ordering::Relaxed) != library::Status::NotSaving as u8 {
3234 flags |= section.poll();
3235 assert!(Instant::now() < deadline);
3236 std::thread::sleep(Duration::from_millis(10));
3237 }
3238 flags |= section.poll();
3239 assert_eq!(flags & 4, 0);
3240 assert!(view.local_edits.lock().unwrap().failed);
3241 assert_eq!(view.canvas.active().document().nodes(), nodes);
3242 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
3243 assert!(view.canvas.active().shown_text().contains("FAILED rich 😀"));
3244 assert!(view.canvas.edit().unwrap().is_none());
3245 section.shared.section.written().unwrap();
3246 assert_eq!(section.shared.section.pending().unwrap(), before);
3247 assert!(!sb_view_reload(&mut view, false, Some(&mut anchor)));
3248 assert!(!sb_view_reload_ready(&view));
3249 assert!(view.reload_pending);
3250 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
3251 assert_eq!(view.canvas.page.editor.history_depth(), history);
3252 assert_eq!(view.canvas.page.editor.selection(), selection);
3253 assert_eq!(std::fs::read(&file).unwrap(), original);
3254
3255 section
3256 .shared
3257 .section
3258 .apply(
3259 "Reader peer",
3260 page_edit(space, vec![PageOp::Color(Some(0x0044_5566))]),
3261 )
3262 .unwrap();
3263 section.shared.section.written().unwrap();
3264 let _ = section.poll();
3265 assert!(!sb_view_reload_ready(&view));
3266 assert!(!sb_view_reload(&mut view, false, None));
3267 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
3268 unsafe { library::sb_section_set_author(&section, c"Reader peer".as_ptr()) };
3269 assert!(sb_view_set_paper(&mut view, 0x77, 0x88, 0x99, -1));
3270 section.shared.section.written().unwrap();
3271 let successful_later = section.shared.section.pending().unwrap();
3272 assert_eq!(successful_later.len(), before.len() + 2);
3273 assert_eq!(successful_later.last().unwrap().author, "Reader peer");
3274 assert!(view.local_edits.lock().unwrap().failed);
3275 assert!(!sb_view_reload_ready(&view));
3276 assert!(view.canvas.active().shown_text().contains("FAILED rich 😀"));
3277 let outstanding = ApplyReply::new(&view.local_edits);
3278 let (returned, returned_reply) = std::sync::mpsc::channel();
3279 section
3280 .shared
3281 .section
3282 .apply_with(
3283 "Fixture",
3284 page_edit(space, vec![PageOp::Color(Some(0x0010_2030))]),
3285 move |result| returned.send((outstanding, result)).unwrap(),
3286 )
3287 .unwrap();
3288 let (outstanding, result) = returned_reply
3289 .recv_timeout(Duration::from_secs(10))
3290 .unwrap();
3291 assert!(result.is_err());
3292 assert_eq!(section.shared.section.pending().unwrap(), successful_later);
3293 sb_view_reload(&mut view, true, None);
3294 assert!(!view.canvas.active().shown_text().contains("FAILED rich 😀"));
3295 assert_eq!(view.local_edits.lock().unwrap().pending, 1);
3296 assert!(!view.local_edits.lock().unwrap().failed);
3297 sb_view_reload(&mut view, false, None);
3298 assert!(!sb_view_reload_ready(&view));
3299 outstanding.complete(result);
3300 assert!(view.local_edits.lock().unwrap().failed);
3301 assert!(!sb_view_reload_ready(&view));
3302 sb_view_reload(&mut view, true, None);
3303 assert!(!view.local_edits.lock().unwrap().failed);
3304 assert_eq!(view.local_edits.lock().unwrap().pending, 0);
3305 assert!(!view.reload_pending);
3306 let _ = section.poll();
3307 assert!(sb_view_set_paper(&mut view, 0x11, 0x22, 0x33, -1));
3308 section.shared.section.written().unwrap();
3309 section
3310 .shared
3311 .section
3312 .apply(
3313 "Reader peer",
3314 page_edit(space, vec![PageOp::Color(Some(0x0012_3456))]),
3315 )
3316 .unwrap();
3317 section.shared.section.written().unwrap();
3318 sb_view_reload(&mut view, false, None);
3319 assert!(!view.reload_pending);
3320 assert!(!view.local_edits.lock().unwrap().failed);
3321 assert_eq!(
3322 view.canvas.page.editor.page().unwrap(),
3323 section.shared.page(space).unwrap().0
3324 );
3325 assert_eq!(std::fs::read(&file).unwrap(), original);
3326 let preserved = section.shared.section.pending().unwrap();
3327 let shared = Arc::clone(&section.shared);
3328 drop((view, section, library));
3329 Arc::into_inner(shared)
3330 .expect("No other failed writer")
3331 .section
3332 .close()
3333 .unwrap();
3334 let sql = rusqlite::Connection::open(&database).unwrap();
3335 sql.busy_timeout(Duration::from_secs(1)).unwrap();
3336 sql.execute_batch("DROP TRIGGER reader_fault").unwrap();
3337 sql.close().unwrap();
3338 let cold = notebook::Replica::open(&database).unwrap();
3339 assert_eq!(cold.identity().unwrap(), identity);
3340 assert_eq!(cold.pending().unwrap(), preserved);
3341 assert!(!canvas::search::page_text(&cold.page(space).unwrap()).contains("FAILED rich 😀"));
3342 }
3343}
crates/notebook/src/session.rs+21-6
......@@ -2471,6 +2471,16 @@ impl Section {
24712471 /// Applies an edit without waiting: it becomes durable on the section thread, which
24722472 /// reports a refusal as `Event::Rejected`. Edits apply in the order they arrive.
24732473 pub fn apply(&self, author: &str, edit: Edit) -> Result<()> {
2474 self.apply_with(author, edit, |_| {})
2475 }
2476
2477 /// Submits without waiting; on completion, calls `reply` before waking the host.
2478 pub fn apply_with(
2479 &self,
2480 author: &str,
2481 edit: Edit,
2482 reply: impl FnOnce(Result<u64>) + Send + 'static,
2483 ) -> Result<()> {
24742484 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));
24752485 let root = self.replica.root;
24762486 let spaces = crate::queue::spaces(&edit, root);
......@@ -2478,15 +2488,20 @@ impl Section {
24782488 author,
24792489 edit,
24802490 Box::new(move |result| {
2481 let event = match result {
2482 Ok(_) => return,
2483 Err(Error::Rejected(error)) => Event::Rejected {
2491 let event = match &result {
2492 Ok(_) => None,
2493 Err(Error::Rejected(error)) => Some(Event::Rejected {
24842494 spaces,
24852495 error: error.to_string(),
2486 },
2487 Err(error) => Event::Failed(error.to_string()),
2496 }),
2497 Err(error) => Some(Event::Failed(error.to_string())),
24882498 };
2489 if sender.send(event).is_ok() {
2499 reply(result);
2500 if let Some(event) = event {
2501 if sender.send(event).is_ok() {
2502 notify();
2503 }
2504 } else {
24902505 notify();
24912506 }
24922507 }),
crates/notebook/src/session/tests.rs+46
......@@ -624,3 +624,49 @@ fn initially_offline_workers_keep_the_original_queue_until_explicitly_woken() {
624624 assert!(Replica::open(&cache).unwrap().pending().unwrap().is_empty());
625625 }
626626}
627
628#[test]
629fn associated_apply_reply_precedes_success_wake_without_a_publication_event() {
630 use std::sync::atomic::{AtomicBool, Ordering};
631
632 let temporary = tempfile::tempdir().unwrap();
633 let file = temporary.path().join("Reply.one");
634 let source = onestore::create_section("Reply.one", "Original", "Fixture").unwrap();
635 fs::write(&file, &source).unwrap();
636 let (woken, wake) = mpsc::channel();
637 let completed = Arc::new(AtomicBool::new(false));
638 let observed = Arc::clone(&completed);
639 let section = Section::open(&file, temporary.path().join("cache"), true, move || {
640 let _ = woken.send(observed.load(Ordering::Acquire));
641 })
642 .unwrap();
643 while wake.try_recv().is_ok() {}
644 let creation = PageCreation::new(None, Some("Associated original"), "Fixture").unwrap();
645 let space = creation.space();
646 let edit = Edit {
647 at: crate::now(),
648 ops: vec![Op::Section(SectionOp::Create(creation))],
649 };
650 let original = edit.clone();
651 let (replied, reply) = mpsc::channel();
652 section
653 .apply_with("Original author", edit, move |result| {
654 completed.store(true, Ordering::Release);
655 replied.send(result).unwrap();
656 })
657 .unwrap();
658 let id = reply
659 .recv_timeout(Duration::from_secs(10))
660 .unwrap()
661 .unwrap();
662 assert!(wake.recv_timeout(Duration::from_secs(10)).unwrap());
663 let pending = section.pending().unwrap();
664 assert_eq!(pending.len(), 1);
665 assert_eq!(pending[0].id, id);
666 assert_eq!(pending[0].author, "Original author");
667 assert_eq!(pending[0].edit, original);
668 assert_eq!(section.page(space).unwrap().title, "Associated original");
669 assert!(section.events().is_empty());
670 section.close().unwrap();
671 assert_eq!(fs::read(file).unwrap(), source);
672}
crates/snowbound/src/main.rs+1-8
......@@ -4957,14 +4957,7 @@ fn page_tree(
49574957 scale: view.viewport.scale * ratio,
49584958 origin: [0, 1].map(|axis| (view.viewport.origin[axis] + corner[axis] * scale) * ratio),
49594959 };
4960 let update = access.update(
4961 &view.editor,
4962 view.scene.as_ref(),
4963 viewport,
4964 "Page",
4965 view.outline_preview(),
4966 view.object_focus().and_then(|focus| focus.read_only()),
4967 )?;
4960 let update = access.update(view, viewport, "Page")?;
49684961 Ok(accesskit::TreeUpdate {
49694962 tree_id: PAGE_TREE,
49704963 ..update