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 = [...@@ -2070,6 +2070,7 @@ dependencies = [
2070 "pollster",2070 "pollster",
2071 "relay",2071 "relay",
2072 "resvg",2072 "resvg",
2073 "rusqlite",
2073 "serde",2074 "serde",
2074 "serde_json",2075 "serde_json",
2075 "tempfile",2076 "tempfile",
apps/ios/Snowbound/CanvasView.swift+139-43
...@@ -90,7 +90,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -90,7 +90,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
90 private var left: CGFloat = 090 private var left: CGFloat = 0
91 /// The zoom a double tap returns to.91 /// The zoom a double tap returns to.
92 private var resting: CGFloat = 192 private var resting: CGFloat = 1
93 private var tapZooming = false93 private var canvasPlace: (zoom: CGFloat, resting: CGFloat, corner: CGPoint, left: CGFloat, scrolled: Bool, edited: Bool)?
94 private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped))94 private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
95 private lazy var doubleTap: UITapGestureRecognizer = {95 private lazy var doubleTap: UITapGestureRecognizer = {
96 let recognizer = UITapGestureRecognizer(target: self, action: #selector(doubleTapped))96 let recognizer = UITapGestureRecognizer(target: self, action: #selector(doubleTapped))
...@@ -230,7 +230,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -230,7 +230,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
230 // Scrolling lays the view out too; only a new size reconfigures the surface.230 // Scrolling lays the view out too; only a new size reconfigures the surface.
231 guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return }231 guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return }
232 sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale))232 sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale))
233 transform()233 if readingShown { reflowReading(readingWidth) } else { transform() }
234 } else {234 } else {
235 handle = sb_view_new(235 handle = sb_view_new(
236 Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page,236 Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page,
...@@ -251,6 +251,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -251,6 +251,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
251 }251 }
252 }252 }
253253
254 override func safeAreaInsetsDidChange() {
255 super.safeAreaInsetsDidChange()
256 if readingShown { reflowReading(readingWidth) }
257 }
258
254 override func didMoveToWindow() {259 override func didMoveToWindow() {
255 super.didMoveToWindow()260 super.didMoveToWindow()
256 link?.invalidate()261 link?.invalidate()
...@@ -288,9 +293,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -288,9 +293,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
288 /// reveal know neither the navigation bar nor the keyboard, so `transform` overrides them.293 /// reveal know neither the navigation bar nor the keyboard, so `transform` overrides them.
289 private func sync() {294 private func sync() {
290 guard let handle else { return }295 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 }
291 var bounds: [Float] = [0, 0, 0, 0]310 var bounds: [Float] = [0, 0, 0, 0]
292 sb_view_content(handle, &bounds)311 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)
294 origin = CGPoint(x: CGFloat(bounds[0]), y: CGFloat(bounds[1]))313 origin = CGPoint(x: CGFloat(bounds[0]), y: CGFloat(bounds[1]))
295 let size = CGSize(width: CGFloat(bounds[2]) - origin.x, height: CGFloat(bounds[3]) - origin.y)314 let size = CGSize(width: CGFloat(bounds[2]) - origin.x, height: CGFloat(bounds[3]) - origin.y)
296 syncing = true315 syncing = true
...@@ -304,6 +323,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -304,6 +323,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
304323
305 private func transform() {324 private func transform() {
306 guard let handle, !syncing else { return }325 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 }
307 sb_view_set_transform(332 sb_view_set_transform(
308 handle, Float(zoomScale), Float(origin.x + contentOffset.x / zoomScale),333 handle, Float(zoomScale), Float(origin.x + contentOffset.x / zoomScale),
309 Float(origin.y + contentOffset.y / zoomScale))334 Float(origin.y + contentOffset.y / zoomScale))
...@@ -338,7 +363,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -338,7 +363,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
338 }363 }
339364
340 private func home() {365 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) }
342 }368 }
343369
344 /// A drag that starts within 30° of an axis sticks to it; a 24-point stretch of the370 /// 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...@@ -362,6 +388,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
362388
363 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {389 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
364 scrolled = true390 scrolled = true
391 if readingShown { drag = nil; return }
365 let moved = panGestureRecognizer.translation(in: self)392 let moved = panGestureRecognizer.translation(in: self)
366 let cross: WritableKeyPath<CGPoint, CGFloat>? =393 let cross: WritableKeyPath<CGPoint, CGFloat>? =
367 abs(moved.x) <= abs(moved.y) * Drag.snap ? \.x : abs(moved.y) <= abs(moved.x) * Drag.snap ? \.y : nil394 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...@@ -369,6 +396,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
369 }396 }
370397
371 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {398 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
399 guard !readingShown else { return }
372 scrolled = true400 scrolled = true
373 drag = nil401 drag = nil
374 pinchStart = spread.map { (zoomScale, $0) }402 pinchStart = spread.map { (zoomScale, $0) }
...@@ -376,9 +404,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -376,9 +404,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
376 detentTick.prepare()404 detentTick.prepare()
377 }405 }
378406
379 func viewForZooming(in scrollView: UIScrollView) -> UIView? { content }407 func viewForZooming(in scrollView: UIScrollView) -> UIView? { readingShown ? nil : content }
380408
381 func scrollViewDidScroll(_ scrollView: UIScrollView) {409 func scrollViewDidScroll(_ scrollView: UIScrollView) {
410 guard !syncing else { return }
382 if var drag, isDragging || isDecelerating, !isZooming {411 if var drag, isDragging || isDecelerating, !isZooming {
383 let cross = drag.cross412 let cross = drag.cross
384 let along = cross == \CGPoint.x ? \CGPoint.y : \CGPoint.x413 let along = cross == \CGPoint.x ? \CGPoint.y : \CGPoint.x
...@@ -419,6 +448,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -419,6 +448,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
419 /// Pinching near 100% sticks there: within `detent` of it the zoom follows the fingers448 /// Pinching near 100% sticks there: within `detent` of it the zoom follows the fingers
420 /// on a curve flat at 100% that meets them again at the band's edges.449 /// on a curve flat at 100% that meets them again at the band's edges.
421 func scrollViewDidZoom(_ scrollView: UIScrollView) {450 func scrollViewDidZoom(_ scrollView: UIScrollView) {
451 guard !syncing else { return }
452 if readingShown {
453 transform()
454 inputDelegate?.selectionDidChange(self)
455 return
456 }
422 if let pinch = pinchGestureRecognizer, pinch.state == .changed, let pinchStart, let spread {457 if let pinch = pinchGestureRecognizer, pinch.state == .changed, let pinchStart, let spread {
423 // The scroll view's pinch reports the zoom it shows, so the fingers give the raw one.458 // The scroll view's pinch reports the zoom it shows, so the fingers give the raw one.
424 let raw = log(pinchStart.zoom * spread / pinchStart.spread)459 let raw = log(pinchStart.zoom * spread / pinchStart.spread)
...@@ -441,15 +476,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -441,15 +476,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
441 }476 }
442477
443 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {478 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
479 let pinched = pinchStart != nil
444 pinchStart = nil480 pinchStart = nil
445 if tapZooming {481 guard !readingShown, pinched else { return }
446 tapZooming = false482 // A pinch let go within the detent settles on 100%.
447 } else {483 resting = abs(log(scale)) < Self.detent ? 1 : scale
448 // A pinch let go within the detent settles on 100%.484 if resting != scale { setZoomScale(resting, animated: true) }
449 resting = abs(log(scale)) < Self.detent ? 1 : scale485 UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey)
450 if resting != scale { setZoomScale(resting, animated: true) }
451 UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey)
452 }
453 }486 }
454487
455 @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) {488 @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) {
...@@ -459,8 +492,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -459,8 +492,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
459 /// Zooms to fit the outline nearest `point` across the view, or back out, as a double492 /// Zooms to fit the outline nearest `point` across the view, or back out, as a double
460 /// tap does in Safari.493 /// tap does in Safari.
461 func zoom(at point: CGPoint) {494 func zoom(at point: CGPoint) {
462 guard let handle else { return }495 guard let handle, !readingShown else { return }
463 tapZooming = true
464 var block: [Float] = [0, 0, 0, 0]496 var block: [Float] = [0, 0, 0, 0]
465 let local = visible(point)497 let local = visible(point)
466 guard zoomScale <= resting * 1.05, sb_view_block(handle, Float(local.x), Float(local.y), &block) else {498 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...@@ -601,7 +633,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
601 /// Gives the canvas `tool` for the touch starting, where it has another: switching lets633 /// Gives the canvas `tool` for the touch starting, where it has another: switching lets
602 /// picked drawings go.634 /// picked drawings go.
603 private func use(_ tool: InkTool) {635 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 }
605 appliedTool = tool639 appliedTool = tool
606 sb_view_set_tool(handle, tool.code.tool, tool.code.detail, sectionColor)640 sb_view_set_tool(handle, tool.code.tool, tool.code.detail, sectionColor)
607 dirty = true641 dirty = true
...@@ -652,6 +686,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -652,6 +686,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
652 func showPicker(_ shown: Bool, fingerDraws draws: Bool = true) {686 func showPicker(_ shown: Bool, fingerDraws draws: Bool = true) {
653 guard shown != pickerShown, let host = superview else { return }687 guard shown != pickerShown, let host = superview else { return }
654 if shown {688 if shown {
689 if readingShown { showReading(false) }
655 picker.translatesAutoresizingMaskIntoConstraints = false690 picker.translatesAutoresizingMaskIntoConstraints = false
656 host.addSubview(picker)691 host.addSubview(picker)
657 NSLayoutConstraint.activate([692 NSLayoutConstraint.activate([
...@@ -770,15 +805,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -770,15 +805,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
770 inputDelegate?.textWillChange(self)805 inputDelegate?.textWillChange(self)
771 }806 }
772 let changed = change()807 let changed = change()
773 if external {
774 inputDelegate?.textDidChange(self)
775 inputDelegate?.selectionDidChange(self)
776 }
777 dirty = true808 dirty = true
778 if changed {809 if changed {
810 canvasPlace?.edited = true
779 sync()811 sync()
780 revealCaret()812 revealCaret()
781 }813 }
814 if external {
815 inputDelegate?.textDidChange(self)
816 inputDelegate?.selectionDidChange(self)
817 }
782 changedSelection()818 changedSelection()
783 return changed819 return changed
784 }820 }
...@@ -950,30 +986,62 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -950,30 +986,62 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
950 /// The column a reading view fills: the screen's width less the room tags hang in.986 /// The column a reading view fills: the screen's width less the room tags hang in.
951 private var readingWidth: Float { Float(bounds.inset(by: safeAreaInsets).width - 34 - 12) }987 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.989 private var readingAnchor: [Float] {
954 var reading: (offered: Bool, verdict: String)? {990 [Float(origin.x + (contentOffset.x + adjustedContentInset.left + 34) / zoomScale),
955 struct Reading: Decodable {991 Float(origin.y + (contentOffset.y + adjustedContentInset.top) / zoomScale)]
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)
961 }992 }
962993
963 /// Whether the page shows reflowed, as `showReading` left it.994 struct Reading: Decodable {
964 private(set) var readingShown = false995 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.
967 func showReading(_ on: Bool) {1010 func showReading(_ on: Bool) {
1011 guard on != readingShown else { return }
1012 reflowReading(on ? readingWidth : 0)
1013 }
1014
1015 private func reflowReading(_ width: Float) {
968 guard let handle else { return }1016 guard let handle else { return }
969 _ = resignFirstResponder()1017 let wasShown = readingShown
970 readingShown = sb_view_set_reading(handle, on ? readingWidth : 0)1018 let place = (zoom: zoomScale, resting: resting,
971 zoomScale = 11019 corner: CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale),
972 resting = 11020 left: left, scrolled: scrolled, edited: false)
973 scrolled = false1021 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 }
974 sync()1037 sync()
975 left = margin1038 if shown {
976 home()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)
977 dirty = true1045 dirty = true
978 onChange?()1046 onChange?()
979 }1047 }
...@@ -1018,6 +1086,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -1018,6 +1086,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
1018 /// Insert Space: the next drag moves what lies below or right of where it starts.1086 /// Insert Space: the next drag moves what lies below or right of where it starts.
1019 func insertSpace() {1087 func insertSpace() {
1020 guard let handle else { return }1088 guard let handle else { return }
1089 if readingShown { showReading(false) }
1021 sb_view_insert_space(handle)1090 sb_view_insert_space(handle)
1022 spaceHint.show("Drag down or right to add space", in: self)1091 spaceHint.show("Drag down or right to add space", in: self)
1023 }1092 }
...@@ -1056,9 +1125,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -1056,9 +1125,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
1056 var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 }1125 var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 }
10571126
1058 /// Shows the page as stored; with `discard`, after an edit was refused.1127 /// 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) {
1060 guard let handle else { return }1129 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()
1062 }1150 }
10631151
1064 /// Selects the first place `query` occurs and brings it into view.1152 /// Selects the first place `query` occurs and brings it into view.
...@@ -1163,9 +1251,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -1163,9 +1251,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
1163 /// Commits marked text, so it is stored before the app leaves the screen.1251 /// Commits marked text, so it is stored before the app leaves the screen.
1164 func commitComposition() {1252 func commitComposition() {
1165 guard markedTextRange != nil else { return }1253 guard markedTextRange != nil else { return }
1166 inputDelegate?.textWillChange(self)
1167 unmarkText()1254 unmarkText()
1168 inputDelegate?.textDidChange(self)
1169 }1255 }
11701256
1171 override var keyCommands: [UIKeyCommand]? {1257 override var keyCommands: [UIKeyCommand]? {
...@@ -1264,7 +1350,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -1264,7 +1350,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
1264 }1350 }
1265 set {1351 set {
1266 guard let newValue else { return }1352 guard let newValue else { return }
1267 _ = choose(newValue)1353 if choose(newValue) { canvasPlace?.edited = true }
1354 sync()
1355 revealCaret()
1268 dirty = true1356 dirty = true
1269 changedSelection()1357 changedSelection()
1270 }1358 }
...@@ -1289,8 +1377,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -1289,8 +1377,16 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
12891377
1290 func unmarkText() {1378 func unmarkText() {
1291 guard let handle else { return }1379 guard let handle else { return }
1380 inputDelegate?.selectionWillChange(self)
1381 inputDelegate?.textWillChange(self)
1292 sb_unmark(handle)1382 sb_unmark(handle)
1383 canvasPlace?.edited = true
1384 sync()
1385 revealCaret()
1386 inputDelegate?.textDidChange(self)
1387 inputDelegate?.selectionDidChange(self)
1293 dirty = true1388 dirty = true
1389 changedSelection()
1294 }1390 }
12951391
1296 var beginningOfDocument: UITextPosition { Position(0) }1392 var beginningOfDocument: UITextPosition { Position(0) }
apps/ios/Snowbound/Lists.swift+5-3
...@@ -150,7 +150,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -150,7 +150,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
150 }150 }
151 updateSelectionActions()151 updateSelectionActions()
152 for name in [Sync.changed, Section.changed] {152 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)
154 }154 }
155 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in155 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
156 guard let self else { return }156 guard let self else { return }
...@@ -244,7 +244,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -244,7 +244,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
244244
245 /// Reads every notebook's sync status, marks the rows it changed, and tells of a notebook245 /// Reads every notebook's sync status, marks the rows it changed, and tells of a notebook
246 /// that has just stopped syncing with edits waiting.246 /// 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 }
248 Sync.status { [weak self] notebooks in249 Sync.status { [weak self] notebooks in
249 guard let self else { return }250 guard let self else { return }
250 var changed: [Item] = []251 var changed: [Item] = []
...@@ -1176,7 +1177,8 @@ final class PagesViewController: UITableViewController {...@@ -1176,7 +1177,8 @@ final class PagesViewController: UITableViewController {
1176 }1177 }
11771178
1178 @objc private func changed(_ notification: Notification) {1179 @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 }
1180 reload()1182 reload()
1181 }1183 }
11821184
apps/ios/Snowbound/Notebooks.swift+4-2
...@@ -701,10 +701,12 @@ final class Section {...@@ -701,10 +701,12 @@ final class Section {
701 var status: UInt8 = 0701 var status: UInt8 = 0
702 let flags = sb_section_poll(handle, &status)702 let flags = sb_section_poll(handle, &status)
703 let changed = SaveStatus(rawValue: status) ?? .unknown703 let changed = SaveStatus(rawValue: status) ?? .unknown
704 guard flags != 0 || changed != self.status else { return }704 let unchanged = flags == 0 && changed == self.status
705 self.status = changed705 self.status = changed
706 if flags & Self.listed != 0 { reloadRows() }706 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])
708 }710 }
709711
710 /// A page's title as typed, shown in the list before the section reports it.712 /// 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...@@ -32,9 +32,9 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
32 private lazy var done = UIBarButtonItem(32 private lazy var done = UIBarButtonItem(
33 systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() })33 systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() })
34 private lazy var undo = UIBarButtonItem(34 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) })
36 private lazy var redo = UIBarButtonItem(36 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) })
38 /// Shows the drawing tools, which a finger then draws with too.38 /// Shows the drawing tools, which a finger then draws with too.
39 private lazy var draw = UIBarButtonItem(39 private lazy var draw = UIBarButtonItem(
40 title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"),40 title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"),
...@@ -231,11 +231,11 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -231,11 +231,11 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
231 ]231 ]
232 }232 }
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.
235 private func readingAction() -> UIAction {235 private func readingAction() -> UIAction {
236 let reading = canvas.reading236 let reading = canvas.reading
237 return UIAction(237 return UIAction(
238 title: "Reading View", subtitle: reading.flatMap { Prototype.readingNote($0.verdict) },238 title: "Reading View",
239 image: UIImage(systemName: "text.justify.left"),239 image: UIImage(systemName: "text.justify.left"),
240 attributes: reading?.offered == true || canvas.readingShown ? [] : .disabled,240 attributes: reading?.offered == true || canvas.readingShown ? [] : .disabled,
241 state: canvas.readingShown ? .on : .off241 state: canvas.readingShown ? .on : .off
...@@ -414,6 +414,9 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -414,6 +414,9 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
414 alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.")414 alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.")
415 } else if flags & Section.remote != 0 {415 } else if flags & Section.remote != 0 {
416 canvas.reload(discard: false)416 canvas.reload(discard: false)
417 } else {
418 canvas.reload(discard: false, pendingOnly: true)
419 if notification.userInfo?["unchanged"] as? Bool == true { return }
417 }420 }
418 if flags & Section.listed != 0 {421 if flags & Section.listed != 0 {
419 if section.row(of: page) == nil {422 if section.row(of: page) == nil {
apps/ios/Snowbound/Prototypes.swift+1-19
...@@ -4,28 +4,10 @@ import UIKit...@@ -4,28 +4,10 @@ import UIKit
4/// `-SnowboundReading YES`, `-SnowboundWelcome YES`, `-SnowboundSectionStrip YES` or4/// `-SnowboundReading YES`, `-SnowboundWelcome YES`, `-SnowboundSectionStrip YES` or
5/// `-SnowboundQuickNote YES` (Xcode's scheme, `simctl launch`, or `devicectl … --arguments`).5/// `-SnowboundQuickNote YES` (Xcode's scheme, `simctl launch`, or `devicectl … --arguments`).
6enum Prototype {6enum 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.
8 static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") }8 static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") }
9}9}
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
29extension Prototype {11extension Prototype {
30 /// First launch: a welcome where the list would be empty, the first notebook found opened12 /// First launch: a welcome where the list would be empty, the first notebook found opened
31 /// at its first page, and the name asked at the first edit rather than the first open.13 /// 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 {...@@ -203,7 +203,7 @@ final class SyncViewController: UITableViewController {
203 Sync.offline = self?.offline.isOn ?? false203 Sync.offline = self?.offline.isOn ?? false
204 }, for: .valueChanged)204 }, for: .valueChanged)
205 for name in [Sync.changed, Section.changed] {205 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)
207 }207 }
208 }208 }
209209
...@@ -219,7 +219,8 @@ final class SyncViewController: UITableViewController {...@@ -219,7 +219,8 @@ final class SyncViewController: UITableViewController {
219 timer?.invalidate()219 timer?.invalidate()
220 }220 }
221221
222 @objc private func refresh() {222 @objc private func refresh(_ notification: Notification? = nil) {
223 guard notification?.userInfo?["unchanged"] as? Bool != true else { return }
223 Sync.status { [weak self] notebooks in224 Sync.status { [weak self] notebooks in
224 self?.notebooks = notebooks.filter { !$0.1.isEmpty }225 self?.notebooks = notebooks.filter { !$0.1.isEmpty }
225 self?.tableView.reloadData()226 self?.tableView.reloadData()
crates/canvas/src/editor.rs+1255-474
...@@ -220,7 +220,7 @@ pub struct TextOutline {...@@ -220,7 +220,7 @@ pub struct TextOutline {
220 min_width: Option<f32>,220 min_width: Option<f32>,
221 layout: onestore::document::Layout,221 layout: onestore::document::Layout,
222 document: TextDocument,222 document: TextDocument,
223 indents: Vec<f32>,223 pub(crate) indents: Vec<f32>,
224 shaped: OutlineLayout,224 shaped: OutlineLayout,
225 selection: Selection,225 selection: Selection,
226}226}
...@@ -428,9 +428,149 @@ impl TextOutline {...@@ -428,9 +428,149 @@ impl TextOutline {
428 }428 }
429 }429 }
430430
431 fn geometry(&self) -> OutlineGeometry<'_> {
432 OutlineGeometry {
433 source: self,
434 shaped: &self.shaped,
435 }
436 }
437
431 pub fn layouts(&self) -> impl Iterator<Item = (usize, &ParagraphLayout)> {438 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 {
432 let mut shaped = self.shaped.paragraphs.iter().peekable();571 let mut shaped = self.shaped.paragraphs.iter().peekable();
433 self.document572 self.source
573 .document
434 .text_nodes()574 .text_nodes()
435 .enumerate()575 .enumerate()
436 .filter_map(move |(index, node)| {576 .filter_map(move |(index, node)| {
...@@ -445,10 +585,10 @@ impl TextOutline {...@@ -445,10 +585,10 @@ impl TextOutline {
445 })585 })
446 }586 }
447587
448 /// Layouts follow the text leaves minus hidden ones, so an index shared by a leaf and its588 /// Hidden leaves have no layout; matching IDs avoid searching the common flat case.
449 /// layout needs no search.589 fn visible_index(self, source: usize) -> Result<usize, EditError> {
450 fn visible_index(&self, source: usize) -> Result<usize, EditError> {
451 let id = self590 let id = self
591 .source
452 .document592 .document
453 .leaf(source)593 .leaf(source)
454 .ok_or(EditError::InvalidRange)?594 .ok_or(EditError::InvalidRange)?
...@@ -469,76 +609,41 @@ impl TextOutline {...@@ -469,76 +609,41 @@ impl TextOutline {
469 .ok_or(EditError::InvalidRange)609 .ok_or(EditError::InvalidRange)
470 }610 }
471611
472 fn source_index(&self, visible: usize) -> usize {612 fn source_index(self, visible: usize) -> usize {
473 let id = self.shaped.paragraphs[visible].id;613 let id = self.shaped.paragraphs[visible].id;
474 if self614 if self
615 .source
475 .document616 .document
476 .leaf(visible)617 .leaf(visible)
477 .is_some_and(|(_, _, node)| node.id == id)618 .is_some_and(|(_, _, node)| node.id == id)
478 {619 {
479 return visible;620 return visible;
480 }621 }
481 self.document622 self.source
623 .document
482 .text_nodes()624 .text_nodes()
483 .position(|paragraph| paragraph.id == id)625 .position(|paragraph| paragraph.id == id)
484 .unwrap()626 .unwrap()
485 }627 }
486628
487 /// The start of the paragraph after visible paragraph `index` when it follows in the same629 /// Including a sibling's start lets whole-paragraph deletion take the break.
488 /// container, where a whole-paragraph selection ends so that deleting it takes the break.630 fn next_sibling_start(self, index: usize) -> Option<TextPosition> {
489 fn next_sibling_start(&self, index: usize) -> Option<TextPosition> {
490 let next =631 let next =
491 (index + 1 < self.shaped.paragraphs.len()).then(|| self.source_index(index + 1))?;632 (index + 1 < self.shaped.paragraphs.len()).then(|| self.source_index(index + 1))?;
492 let (container, local, _) = self.document.leaf(self.source_index(index))?;633 let (container, local, _) = self.source.document.leaf(self.source_index(index))?;
493 let (next_container, next_local, _) = self.document.leaf(next)?;634 let (next_container, next_local, _) = self.source.document.leaf(next)?;
494 (container == next_container && next_local == local + 1).then_some(TextPosition {635 (container == next_container && next_local == local + 1).then_some(TextPosition {
495 paragraph: next,636 paragraph: next,
496 offset: 0,637 offset: 0,
497 })638 })
498 }639 }
499640
500 pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {641 fn paragraph_layout(self, source: usize) -> Result<&'a ParagraphLayout, EditError> {
501 Ok(&self.shaped.paragraphs[self.visible_index(source)?])642 Ok(&self.shaped.paragraphs[self.visible_index(source)?])
502 }643 }
503644
504 /// The shown paragraphs with their source text: those a collapsed parent hides have no645 pub(crate) fn caret_at(
505 /// layout and are left out.646 self,
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,
542 focus: TextPosition,647 focus: TextPosition,
543 affinity: Affinity,648 affinity: Affinity,
544 width: f32,649 width: f32,
...@@ -553,8 +658,7 @@ impl TextOutline {...@@ -553,8 +658,7 @@ impl TextOutline {
553 Ok(rect)658 Ok(rect)
554 }659 }
555660
556 /// Highlight rectangles of `selection` in the outline, in its coordinates.661 pub(crate) fn range_rects(self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
557 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
558 let [anchor, focus] = selection.positions;662 let [anchor, focus] = selection.positions;
559 if anchor == focus {663 if anchor == focus {
560 return Ok(Vec::new());664 return Ok(Vec::new());
...@@ -574,7 +678,7 @@ impl TextOutline {...@@ -574,7 +678,7 @@ impl TextOutline {
574 .enumerate()678 .enumerate()
575 .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph)679 .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph)
576 {680 {
577 let source = self.document.paragraph(index).unwrap();681 let source = self.source.document.paragraph(index).unwrap();
578 let first = if index == start.paragraph {682 let first = if index == start.paragraph {
579 paragraph.cursor(start.offset, affinities[0])?683 paragraph.cursor(start.offset, affinities[0])?
580 } else {684 } else {
...@@ -611,10 +715,8 @@ impl TextOutline {...@@ -611,10 +715,8 @@ impl TextOutline {
611 Ok(rectangles)715 Ok(rectangles)
612 }716 }
613717
614 /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each
615 /// line's left, right and baseline.
616 pub(crate) fn underlines(718 pub(crate) fn underlines(
617 &self,719 self,
618 index: usize,720 index: usize,
619 range: Range<u32>,721 range: Range<u32>,
620 ) -> Result<Vec<[f32; 3]>, EditError> {722 ) -> Result<Vec<[f32; 3]>, EditError> {
...@@ -632,44 +734,48 @@ impl TextOutline {...@@ -632,44 +734,48 @@ impl TextOutline {
632 .collect())734 .collect())
633 }735 }
634736
635 /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the737 pub(crate) fn selection_at(
636 /// next shown one.738 self,
637 pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> {739 x: f32,
638 let mut offset = 0;740 y: f32,
639 for (index, paragraph) in self.shown() {741 unit: SelectionUnit,
640 if index == position.paragraph {742 ) -> Result<Selection, EditError> {
641 return Ok(offset + position.offset);743 if !x.is_finite() || !y.is_finite() {
642 }744 return Err(EditError::InvalidRange);
643 if index > position.paragraph {
644 return Ok(offset);
645 }
646 offset += paragraph.utf16_offset(paragraph.text().len())? + 1;
647 }745 }
648 Ok(offset.saturating_sub(1))746 let index = self.paragraph_at(x, y)?;
649 }747 let paragraph = &self.shaped.paragraphs[index];
650748 let layout = &paragraph.text.shaped;
651 /// The source position at `offset` in `shown_text`, clamped to its end.749 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
652 pub fn utf16_position(&self, offset: u32) -> Result<TextPosition, EditError> {750 let local = edit::selection_at(
653 let mut rest = offset;751 layout,
654 let mut end = None;752 paragraph.text.hit_test(point[0], point[1]),
655 for (index, paragraph) in self.shown() {753 point[0],
656 let length = paragraph.utf16_offset(paragraph.text().len())?;754 unit,
657 if rest <= length {755 );
658 return Ok(TextPosition {756 let position = |cursor: Cursor| -> Result<TextPosition, EditError> {
659 paragraph: index,757 let visible = paragraph.projection.text().utf16_offset(cursor.index())?;
660 offset: rest,758 Ok(TextPosition {
661 });759 paragraph: self.source_index(index),
662 }760 offset: paragraph
663 rest -= length + 1;761 .projection
664 end = Some(TextPosition {762 .source_offset(visible, crate::affinity(cursor.affinity()))?,
665 paragraph: index,763 })
666 offset: length,764 };
667 });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;
668 }774 }
669 end.ok_or(EditError::InvalidRange)775 Ok(selection)
670 }776 }
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> {
673 let paragraphs = self779 let paragraphs = self
674 .shaped780 .shaped
675 .paragraphs781 .paragraphs
...@@ -684,7 +790,7 @@ impl TextOutline {...@@ -684,7 +790,7 @@ impl TextOutline {
684 .map(|table| (table.id, table))790 .map(|table| (table.id, table))
685 .collect::<BTreeMap<_, _>>();791 .collect::<BTreeMap<_, _>>();
686 let mut result = Vec::new();792 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())];
688 while let Some(nodes) = pending.last_mut() {794 while let Some(nodes) = pending.last_mut() {
689 let Some(node) = nodes.next() else {795 let Some(node) = nodes.next() else {
690 pending.pop();796 pending.pop();
...@@ -742,37 +848,237 @@ impl TextOutline {...@@ -742,37 +848,237 @@ impl TextOutline {
742 Ok(result)848 Ok(result)
743 }849 }
744850
745 fn paragraph_at(&self, x: f32, y: f32) -> Result<usize, EditError> {851 fn navigate(
746 if self.shaped.tables.is_empty() {852 self,
747 return Ok(self853 mut selection: Selection,
748 .shaped854 movement: Movement,
749 .paragraphs855 extend: bool,
750 .partition_point(|paragraph| paragraph.origin[1] <= y)856 mut preferred_x: Option<f32>,
751 .saturating_sub(1));857 ) -> Result<Navigation, EditError> {
752 }858 let [anchor, focus] = selection.positions;
753 let paragraphs = self859 if matches!(
754 .shaped860 movement,
755 .paragraphs861 Movement::ParagraphStart
756 .iter()862 | Movement::ParagraphEnd
757 .enumerate()863 | Movement::DocumentStart
758 .map(|(index, paragraph)| (paragraph.id, (index, paragraph)))864 | Movement::DocumentEnd
759 .collect::<BTreeMap<_, _>>();865 ) {
760 let tables = self866 let current = self.visible_index(focus.paragraph)?;
761 .shaped867 let end = matches!(movement, Movement::ParagraphEnd | Movement::DocumentEnd);
762 .tables868 let current_end = self.source.document.paragraph(focus.paragraph).unwrap();
763 .iter()869 let current_end = current_end.utf16_offset(current_end.text().len())?;
764 .map(|table| (table.id, table))870 let index = match movement {
765 .collect::<BTreeMap<_, _>>();871 Movement::DocumentStart => 0,
766 let mut nodes = self.document.nodes();872 Movement::DocumentEnd => self.shaped.paragraphs.len() - 1,
767 loop {873 Movement::ParagraphStart if focus.offset == 0 => current.saturating_sub(1),
768 let mut target = None;874 Movement::ParagraphEnd if focus.offset == current_end => {
769 for node in visible_paragraphs(nodes.iter()) {875 (current + 1).min(self.shaped.paragraphs.len() - 1)
770 let top = match &node.content {876 }
771 onestore::page::ParagraphContent::Text(_) => {877 _ => current,
772 paragraphs878 };
773 .get(&node.id)879 let source = self.source_index(index);
774 .ok_or(EditError::InvalidStructure)?880 let paragraph = self.source.document.paragraph(source).unwrap();
775 .1881 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
776 .origin[1]1082 .origin[1]
777 }1083 }
778 onestore::page::ParagraphContent::Table(table) => {1084 onestore::page::ParagraphContent::Table(table) => {
...@@ -822,7 +1128,7 @@ impl TextOutline {...@@ -822,7 +1128,7 @@ impl TextOutline {
822 .min_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)))1128 .min_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)))
823 .ok_or(EditError::InvalidStructure)?1129 .ok_or(EditError::InvalidStructure)?
824 .0;1130 .0;
825 nodes = self.document.container(Some(cell.id))?;1131 nodes = self.source.document.container(Some(cell.id))?;
826 }1132 }
827 }1133 }
828}1134}
...@@ -2994,37 +3300,7 @@ impl CanvasEditor {...@@ -2994,37 +3300,7 @@ impl CanvasEditor {
2994 y: f32,3300 y: f32,
2995 unit: SelectionUnit,3301 unit: SelectionUnit,
2996 ) -> Result<Selection, EditError> {3302 ) -> Result<Selection, EditError> {
2997 if !x.is_finite() || !y.is_finite() {3303 self.active_outline().geometry().selection_at(x, y, unit)
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)
3028 }3304 }
30293305
3030 pub fn caret(&self, width: f32) -> Result<BoundingBox, EditError> {3306 pub fn caret(&self, width: f32) -> Result<BoundingBox, EditError> {
...@@ -3048,12 +3324,46 @@ impl CanvasEditor {...@@ -3048,12 +3324,46 @@ impl CanvasEditor {
3048 movement: Movement,3324 movement: Movement,
3049 extend: bool,3325 extend: bool,
3050 ) -> Result<(), EditorError> {3326 ) -> 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> {
3051 self.finish_composition();3338 self.finish_composition();
3052 self.whole = None;3339 self.whole = None;
3053 self.typing = None;3340 self.typing = None;
3054 if !extend && movement == Movement::Right && self.leave_equation()? {3341 Ok(!extend && movement == Movement::Right && self.leave_equation()?)
3055 return Ok(());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,
3056 }3354 }
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> {
3057 let [anchor, focus] = self.active_outline().selection.positions;3367 let [anchor, focus] = self.active_outline().selection.positions;
3058 if !extend3368 if !extend
3059 && self.caret_outline().is_some()3369 && self.caret_outline().is_some()
...@@ -3113,308 +3423,151 @@ impl CanvasEditor {...@@ -3113,308 +3423,151 @@ impl CanvasEditor {
3113 }3423 }
3114 return Ok(());3424 return Ok(());
3115 }3425 }
3116 if matches!(3426 let navigation = self.active_outline().geometry().navigate(
3427 self.selection(),
3117 movement,3428 movement,
3118 Movement::ParagraphStart3429 extend,
3119 | Movement::ParagraphEnd3430 self.preferred_x,
3120 | Movement::DocumentStart3431 )?;
3121 | Movement::DocumentEnd3432 if navigation.boundary
3122 ) {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");
3123 let outline = self.active_outline();3441 let outline = self.active_outline();
3124 let current = outline.visible_index(focus.paragraph)?;3442 let last = outline.document.nodes().len() - 1;
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();
3191 let up = matches!(movement, Movement::Up);3443 let up = matches!(movement, Movement::Up);
3192 let target = if up {3444 let base_node = &outline.document.nodes()[if up { 0 } else { last }];
3193 line.checked_sub(1)3445 let node = self.blank_paragraph(base_node)?;
3194 .and_then(|line| paragraph.text.lines().nth(line))3446 let trailing = outline
3195 } else {3447 .document
3196 paragraph.text.lines().nth(line + 1)3448 .nodes()
3197 }3449 .iter()
3198 .map(|(_, line)| line);3450 .rev()
3199 let target = if target.is_some() {3451 .take_while(|node| {
3200 target3452 node.text()
3201 } else {3453 .is_some_and(|text| text.text.text().bytes().all(|byte| byte == b' '))
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)
3230 })3454 })
3231 };3455 .count();
3232 if target.is_none() && !extend && anchor == focus && !self.active_outline().title {3456 let position = if up {
3233 let outline = self.active_outline();3457 Some([
3234 let last = outline.document.nodes().len() - 1;3458 outline.origin()[0] + x,
3235 let up = matches!(movement, Movement::Up);3459 (((f64::from(outline.origin()[1]) / 18.0).ceil() - 3.0) * 18.0 + 14.4) as f32,
3236 let base_node = &outline.document.nodes()[if up { 0 } else { last }];3460 ])
3237 let node = self.blank_paragraph(base_node)?;3461 } else if outline.origin()[0] >= 180.0 && trailing >= 2 {
3238 let trailing = outline3462 let node = &outline.document.nodes()[last.saturating_sub(trailing)];
3239 .document3463 let bottom = match &node.content {
3240 .nodes()3464 onestore::page::ParagraphContent::Text(_) => {
3241 .iter()3465 let paragraph = outline
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
3289 .shaped3466 .shaped
3290 .objects3467 .paragraphs
3291 .iter()3468 .iter()
3292 .find(|object| object.id == *id)3469 .find(|paragraph| paragraph.id == node.id)
3293 .map(|object| f64::from(object.bottom))3470 .ok_or(EditError::InvalidStructure)?;
3294 .ok_or(EditError::InvalidStructure)?,3471 f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height())
3295 } + f64::from(outline.origin()[1]);3472 }
3296 let cell = (bottom - 14.4) / 18.0;3473 onestore::page::ParagraphContent::Table(table) => {
3297 // Stored coordinates can straddle a grid boundary by one f32 ULP.3474 let cell = outline
3298 let grid = if (cell - cell.round()).abs()3475 .shaped
3299 <= f64::from(f32::EPSILON) * cell.abs().max(1.0)3476 .tables
3300 {3477 .iter()
3301 cell.round()3478 .find(|layout| layout.id == table.id)
3302 } else {3479 .and_then(|layout| layout.cells.last())
3303 cell.ceil()3480 .ok_or(EditError::InvalidStructure)?;
3304 };3481 f64::from(cell.rect[3]) + 1.68
3305 Some([outline.origin()[0], ((grid + 3.0) * 18.0 + 14.4) as f32])3482 }
3306 } else {3483 onestore::page::ParagraphContent::Image(onestore::page::Image {
3307 None3484 id, ..
3308 };3485 })
3309 if let Some(position) = position {3486 | onestore::page::ParagraphContent::Attachment(onestore::page::Attachment {
3310 let outline = TextOutline::new(3487 id,
3311 engine,3488 ..
3312 TextDocument::from_nodes(vec![node])?,3489 })
3313 DEFAULT_OUTLINE_WIDTH,3490 | onestore::page::ParagraphContent::Ink(onestore::page::Ink { id, .. })
3314 position,3491 | onestore::page::ParagraphContent::Unsupported(
3315 )?;3492 onestore::page::Unsupported { id, .. },
3316 self.active = Focus::Caret {3493 ) => outline
3317 outline: Box::new(outline),3494 .shaped
3318 index: self.outlines.len(),3495 .objects
3319 };3496 .iter()
3320 self.preferred_x = Some(if up { 0.0 } else { x });3497 .find(|object| object.id == *id)
3321 return Ok(());3498 .map(|object| f64::from(object.bottom))
3322 }3499 .ok_or(EditError::InvalidStructure)?,
3323 let edit = DocumentEdit {3500 } + f64::from(outline.origin()[1]);
3324 columns: BTreeMap::new(),3501 let cell = (bottom - 14.4) / 18.0;
3325 container: None,3502 // Stored coordinates can straddle a grid boundary by one f32 ULP.
3326 range: last + 1..last + 1,3503 let grid = if (cell - cell.round()).abs()
3327 replacement: vec![PageParagraph {3504 <= f64::from(f32::EPSILON) * cell.abs().max(1.0)
3328 parent: base_node.parent,3505 {
3329 ..node3506 cell.round()
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 ))
3342 } else {3507 } else {
3343 None3508 cell.ceil()
3344 };3509 };
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(
3346 engine,3516 engine,
3347 edit,3517 TextDocument::from_nodes(vec![node])?,
3348 [TextPosition {3518 DEFAULT_OUTLINE_WIDTH,
3349 paragraph,3519 position,
3350 offset: 0,3520 )?;
3351 }; 2]3521 self.active = Focus::Caret {
3352 .into(),3522 outline: Box::new(outline),
3353 false,3523 index: self.outlines.len(),
3354 None,3524 };
3355 ) {3525 self.preferred_x = Some(if up { 0.0 } else { x });
3356 if let Some(previous) = previous {
3357 self.active = previous;
3358 }
3359 return Err(error);
3360 }
3361 self.preferred_x = Some(x);
3362 return Ok(());3526 return Ok(());
3363 }3527 }
3364 next = target3528 let edit = DocumentEdit {
3365 .map(|line| {3529 columns: BTreeMap::new(),
3366 self.active_outline().shaped.paragraphs[index]3530 container: None,
3367 .text3531 range: last + 1..last + 1,
3368 .hit_test(3532 replacement: vec![PageParagraph {
3369 x - self.active_outline().shaped.paragraphs[index].origin[0],3533 parent: base_node.parent,
3370 line.top + line.height * 0.5,3534 ..node
3371 )3535 }],
3372 })3536 };
3373 .unwrap_or(cursor);3537 let paragraph = outline.document.paragraphs().count();
3374 self.preferred_x = Some(x);3538 let previous = if let Focus::Outline(index) = self.active {
3375 } else {3539 let draft = self.outlines[index].clone();
3376 let local = if anchor.paragraph == focus.paragraph {3540 Some(std::mem::replace(
3377 ParagraphSelection::new(3541 &mut self.active,
3378 paragraph3542 Focus::Draft {
3379 .cursor(anchor.offset, self.active_outline().selection.affinities[0])?,3543 index,
3380 cursor,3544 outline: Box::new(draft),
3381 )3545 },
3546 ))
3382 } else {3547 } else {
3383 cursor.into()3548 None
3384 };3549 };
3385 next = edit::step(&paragraph.text.shaped, local, movement, extend).focus();3550 if let Err(error) = self.apply(
3386 if horizontal && next == cursor && (extend || anchor == focus) {3551 engine,
3387 let neighbor = if left {3552 edit,
3388 index.checked_sub(1)3553 [TextPosition {
3389 } else {3554 paragraph,
3390 (index + 1 < self.active_outline().shaped.paragraphs.len()).then_some(index + 1)3555 offset: 0,
3391 };3556 }; 2]
3392 if let Some(neighbor) = neighbor {3557 .into(),
3393 index = neighbor;3558 false,
3394 let paragraph = &self.active_outline().shaped.paragraphs[index];3559 None,
3395 let line = if left {3560 ) {
3396 paragraph.text.lines().last()3561 if let Some(previous) = previous {
3397 } else {3562 self.active = previous;
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 }
3409 }3563 }
3564 return Err(error);
3410 }3565 }
3566 self.preferred_x = Some(x);
3567 return Ok(());
3411 }3568 }
3412 if !extend3569 if navigation.boundary && matches!(movement, Movement::Left | Movement::Right) {
3413 && anchor == focus3570 let left = movement == Movement::Left;
3414 && next == cursor
3415 && self.active_outline().source_index(index) == focus.paragraph
3416 && matches!(movement, Movement::Left | Movement::Right)
3417 {
3418 let caret = self.caret(1.0)?;3571 let caret = self.caret(1.0)?;
3419 let origin = self.active_outline().origin();3572 let origin = self.active_outline().origin();
3420 let x = caret.x0 + f64::from(origin[0]);3573 let x = caret.x0 + f64::from(origin[0]);
...@@ -3443,41 +3596,17 @@ impl CanvasEditor {...@@ -3443,41 +3596,17 @@ impl CanvasEditor {
3443 return Ok(());3596 return Ok(());
3444 }3597 }
3445 }3598 }
3446 if !matches!(movement, Movement::Up | Movement::Down) {3599 self.preferred_x = navigation.preferred_x;
3447 self.preferred_x = None;3600 self.active_outline_mut().selection = navigation.selection;
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();
3463 Ok(())3601 Ok(())
3464 }3602 }
34653603
3604 #[cfg(test)]
3466 pub(crate) fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {3605 pub(crate) fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
3467 self.active_outline()3606 self.active_outline()
3468 .range_rects(self.active_outline().selection)3607 .range_rects(self.active_outline().selection)
3469 }3608 }
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
3481 pub fn replace(3610 pub fn replace(
3482 &mut self,3611 &mut self,
3483 engine: &mut TextEngine,3612 engine: &mut TextEngine,
...@@ -4993,10 +5122,8 @@ fn holds(content: &ParagraphContent, id: ExGuid) -> bool {...@@ -4993,10 +5122,8 @@ fn holds(content: &ParagraphContent, id: ExGuid) -> bool {
4993 }5122 }
4994}5123}
49955124
4996/// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same5125/// Follows paragraph IDs and UTF-16 through edits; deleted paragraphs clamp to their surviving index.
4997/// paragraph, past what changed in its text when it lies after it; at the start of the5126pub fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition {
4998/// paragraph now at its place when that one is gone.
4999fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition {
5000 let found = old.leaf(position.paragraph).and_then(|(_, _, node)| {5127 let found = old.leaf(position.paragraph).and_then(|(_, _, node)| {
5001 let paragraph = new5128 let paragraph = new
5002 .text_nodes()5129 .text_nodes()
...@@ -5087,6 +5214,660 @@ mod tests {...@@ -5087,6 +5214,660 @@ mod tests {
5087 use super::*;5214 use super::*;
5088 use onestore::document::Format;5215 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
5090 fn table_editor(engine: &mut TextEngine) -> CanvasEditor {5871 fn table_editor(engine: &mut TextEngine) -> CanvasEditor {
5091 use onestore::page::text::new_id;5872 use onestore::page::text::new_id;
5092 use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};5873 use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
crates/canvas/src/editor/link.rs+49-43
...@@ -191,49 +191,11 @@ impl CanvasEditor {...@@ -191,49 +191,11 @@ impl CanvasEditor {
191 })191 })
192 }192 }
193193
194 /// The address of the link drawn under document point `point` of outline `id`, which a194 pub(crate) fn link_under(&self, id: ExGuid, point: [f32; 2]) -> Option<String> {
195 /// click opens as OneNote's does.195 self.visible_outlines()
196 pub(crate) fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option<String> {196 .find(|outline| outline.id == id)?
197 let outline = self.visible_outlines().find(|outline| outline.id == id)?;197 .geometry()
198 let index = outline.paragraph_at(x, y).ok()?;198 .link_under(point)
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 })
237 }199 }
238200
239 /// What the Link dialog links, as OneNote 2010 picks it: the selection within one201 /// What the Link dialog links, as OneNote 2010 picks it: the selection within one
...@@ -511,6 +473,50 @@ impl CanvasEditor {...@@ -511,6 +473,50 @@ impl CanvasEditor {
511 }473 }
512}474}
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
514#[cfg(test)]520#[cfg(test)]
515mod tests {521mod tests {
516 use super::*;522 use super::*;
crates/canvas/src/gpu/page.rs+98-18
...@@ -104,12 +104,18 @@ fn outline_origin(...@@ -104,12 +104,18 @@ fn outline_origin(
104 mut origin: [f32; 2],104 mut origin: [f32; 2],
105 title: Option<onestore::ExGuid>,105 title: Option<onestore::ExGuid>,
106 editor: Option<&CanvasEditor>,106 editor: Option<&CanvasEditor>,
107 column: Option<&crate::reading::Column>,
107) -> Result<[f32; 2], SceneError> {108) -> Result<[f32; 2], SceneError> {
108 if let Some(id) = title {109 if let Some(id) = title {
109 let title = editor110 let title = editor
110 .and_then(|editor| editor.visible_outlines().find(|outline| outline.id == id))111 .and_then(|editor| editor.visible_outlines().find(|outline| outline.id == id))
111 .ok_or(SceneError::MissingOutline)?;112 .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 };
113 }119 }
114 Ok(origin)120 Ok(origin)
115}121}
...@@ -524,7 +530,7 @@ impl PageScene {...@@ -524,7 +530,7 @@ impl PageScene {
524 below_title,530 below_title,
525 } => {531 } => {
526 let origin = crate::origin(&source.layout);532 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) {
528 outline(layout, origin, &mut rects);534 outline(layout, origin, &mut rects);
529 }535 }
530 }536 }
...@@ -667,6 +673,7 @@ impl PageScene {...@@ -667,6 +673,7 @@ impl PageScene {
667 pub(crate) fn content_bounds<'a>(673 pub(crate) fn content_bounds<'a>(
668 &'a self,674 &'a self,
669 editor: &'a CanvasEditor,675 editor: &'a CanvasEditor,
676 column: Option<&'a crate::reading::Column>,
670 ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a {677 ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a {
671 self.objects(Some(editor))678 self.objects(Some(editor))
672 .into_iter()679 .into_iter()
...@@ -677,9 +684,13 @@ impl PageScene {...@@ -677,9 +684,13 @@ impl PageScene {
677 layout,684 layout,
678 below_title,685 below_title,
679 } => {686 } => {
680 let origin =687 let origin = outline_origin(
681 outline_origin(crate::origin(&source.layout), *below_title, Some(editor))688 crate::origin(&source.layout),
682 .ok()?;689 *below_title,
690 Some(editor),
691 column,
692 )
693 .ok()?;
683 Some((694 Some((
684 [695 [
685 origin[0],696 origin[0],
...@@ -696,6 +707,7 @@ impl PageScene {...@@ -696,6 +707,7 @@ impl PageScene {
696 crate::origin(&date.source().layout),707 crate::origin(&date.source().layout),
697 *below_title,708 *below_title,
698 Some(editor),709 Some(editor),
710 column,
699 )711 )
700 .ok()?;712 .ok()?;
701 let size = date.layout().size;713 let size = date.layout().size;
...@@ -721,9 +733,53 @@ impl PageScene {...@@ -721,9 +733,53 @@ impl PageScene {
721 })733 })
722 }734 }
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
724 pub(crate) fn date_fields(779 pub(crate) fn date_fields(
725 &self,780 &self,
726 editor: &CanvasEditor,781 editor: &CanvasEditor,
782 column: Option<&crate::reading::Column>,
727 ) -> impl Iterator<Item = (DateField, [f32; 4])> {783 ) -> impl Iterator<Item = (DateField, [f32; 4])> {
728 let mut fields = [None; 2];784 let mut fields = [None; 2];
729 if let Some(date) = editor.date()785 if let Some(date) = editor.date()
...@@ -737,7 +793,7 @@ impl PageScene {...@@ -737,7 +793,7 @@ impl PageScene {
737 }793 }
738 _ => None,794 _ => None,
739 })795 })
740 && let Ok(origin) = outline_origin(origin, title, Some(editor))796 && let Ok(origin) = outline_origin(origin, title, Some(editor), column)
741 {797 {
742 for (slot, (field, paragraph)) in fields.iter_mut().zip(798 for (slot, (field, paragraph)) in fields.iter_mut().zip(
743 date.fields()799 date.fields()
...@@ -765,6 +821,16 @@ impl PageScene {...@@ -765,6 +821,16 @@ impl PageScene {
765 &self,821 &self,
766 point: [f32; 2],822 point: [f32; 2],
767 editor: Option<&CanvasEditor>,823 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>,
768 mut outline: impl FnMut(onestore::ExGuid) -> Option<T>,834 mut outline: impl FnMut(onestore::ExGuid) -> Option<T>,
769 ) -> Option<SceneHit<T>> {835 ) -> Option<SceneHit<T>> {
770 let check = self.object_tags(editor, None).into_iter().find(|(_, tag)| {836 let check = self.object_tags(editor, None).into_iter().find(|(_, tag)| {
...@@ -780,10 +846,11 @@ impl PageScene {...@@ -780,10 +846,11 @@ impl PageScene {
780 match object {846 match object {
781 Content::Date { .. } => {847 Content::Date { .. } => {
782 if let Some(editor) = editor848 if let Some(editor) = editor
783 && let Some((field, _)) = self.date_fields(editor).find(|(_, rect)| {849 && let Some((field, _)) =
784 (rect[0]..=rect[2]).contains(&point[0])850 self.date_fields(editor, column).find(|(_, rect)| {
785 && (rect[1]..=rect[3]).contains(&point[1])851 (rect[0]..=rect[2]).contains(&point[0])
786 })852 && (rect[1]..=rect[3]).contains(&point[1])
853 })
787 {854 {
788 return Some(SceneHit::Date(field));855 return Some(SceneHit::Date(field));
789 }856 }
...@@ -851,7 +918,8 @@ impl PageScene {...@@ -851,7 +918,8 @@ impl PageScene {
851 layout,918 layout,
852 below_title,919 below_title,
853 } => {920 } => {
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)?;
855 cover([x, y, x + layout.size[0], y + layout.size[1]], false);923 cover([x, y, x + layout.size[0], y + layout.size[1]], false);
856 for paragraph in &layout.paragraphs {924 for paragraph in &layout.paragraphs {
857 let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]];925 let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]];
...@@ -900,18 +968,20 @@ impl PageScene {...@@ -900,18 +968,20 @@ impl PageScene {
900 offset: [f32; 2],968 offset: [f32; 2],
901 paper: Paper,969 paper: Paper,
902 ) -> Result<(), SceneError> {970 ) -> Result<(), SceneError> {
903 self.append_primitives_with(primitives, offset, None, None, paper, |_, _, _| {971 self.append_primitives_with(primitives, offset, None, None, None, paper, |_, _, _| {
904 Err(SceneError::MissingOutline)972 Err(SceneError::MissingOutline)
905 })973 })
906 }974 }
907975
908 /// `moving` draws one picture at a previewed rectangle instead of its stored layout.976 /// `moving` draws one picture at a previewed rectangle instead of its stored layout.
909 /// Outlines the editor does not hold draw their tags' symbols.977 /// Outlines the editor does not hold draw their tags' symbols.
978 #[allow(clippy::too_many_arguments)]
910 pub(crate) fn append_primitives_with<'a, E: From<SceneError>>(979 pub(crate) fn append_primitives_with<'a, E: From<SceneError>>(
911 &'a self,980 &'a self,
912 primitives: &mut Vec<Primitive<'a>>,981 primitives: &mut Vec<Primitive<'a>>,
913 offset: [f32; 2],982 offset: [f32; 2],
914 editor: Option<&'a CanvasEditor>,983 editor: Option<&'a CanvasEditor>,
984 column: Option<&'a crate::reading::Column>,
915 moving: Option<(onestore::ExGuid, [f32; 4])>,985 moving: Option<(onestore::ExGuid, [f32; 4])>,
916 paper: Paper,986 paper: Paper,
917 mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>,987 mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>,
...@@ -922,12 +992,17 @@ impl PageScene {...@@ -922,12 +992,17 @@ impl PageScene {
922 source,992 source,
923 below_title,993 below_title,
924 ..994 ..
925 } => outline_origin(crate::origin(&source.layout), *below_title, editor)?,995 } => outline_origin(crate::origin(&source.layout), *below_title, editor, column)?,
926 Content::Date { below_title } => {996 Content::Date { below_title } => {
927 let date = editor997 let date = editor
928 .and_then(CanvasEditor::date)998 .and_then(CanvasEditor::date)
929 .ok_or(SceneError::MissingOutline)?;999 .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 )?
931 }1006 }
932 Content::Image(source) => crate::origin(&source.layout),1007 Content::Image(source) => crate::origin(&source.layout),
933 Content::ReadOnly(object) => {1008 Content::ReadOnly(object) => {
...@@ -1397,7 +1472,7 @@ mod tests {...@@ -1397,7 +1472,7 @@ mod tests {
1397 assert_eq!(editor.active_outline().layout().max_width, None);1472 assert_eq!(editor.active_outline().layout().max_width, None);
1398 let bounds = |editor: &CanvasEditor| {1473 let bounds = |editor: &CanvasEditor| {
1399 scene1474 scene
1400 .content_bounds(editor)1475 .content_bounds(editor, None)
1401 .map(|(rect, _)| rect)1476 .map(|(rect, _)| rect)
1402 .collect::<Vec<_>>()1477 .collect::<Vec<_>>()
1403 };1478 };
...@@ -1453,7 +1528,7 @@ mod tests {...@@ -1453,7 +1528,7 @@ mod tests {
1453 let changed = bounds(&editor);1528 let changed = bounds(&editor);
1454 assert_eq!(changed[0][1], wrapped[0][1]);1529 assert_eq!(changed[0][1], wrapped[0][1]);
1455 assert!(changed[0][3] > wrapped[0][3]);1530 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();
1457 assert_eq!(field, DateField::Date);1532 assert_eq!(field, DateField::Date);
1458 assert_eq!(rect[1], changed[0][1]);1533 assert_eq!(rect[1], changed[0][1]);
1459 assert_eq!(rect[3], changed[0][3]);1534 assert_eq!(rect[3], changed[0][3]);
...@@ -1759,6 +1834,7 @@ mod tests {...@@ -1759,6 +1834,7 @@ mod tests {
1759 [0.0; 2],1834 [0.0; 2],
1760 Some(editor),1835 Some(editor),
1761 None,1836 None,
1837 None,
1762 Paper::WHITE,1838 Paper::WHITE,
1763 |_, _, _| Ok(()),1839 |_, _, _| Ok(()),
1764 )1840 )
...@@ -1785,7 +1861,7 @@ mod tests {...@@ -1785,7 +1861,7 @@ mod tests {
1785 assert!((old[3] + delta - new[3]).abs() < 0.002);1861 assert!((old[3] + delta - new[3]).abs() < 0.002);
1786 assert!(1862 assert!(
1787 scene1863 scene
1788 .content_bounds(&editor)1864 .content_bounds(&editor, None)
1789 .any(|(bounds, _)| bounds == *new)1865 .any(|(bounds, _)| bounds == *new)
1790 );1866 );
1791 }1867 }
...@@ -2199,6 +2275,7 @@ mod tests {...@@ -2199,6 +2275,7 @@ mod tests {
2199 [0.0; 2],2275 [0.0; 2],
2200 Some(editor),2276 Some(editor),
2201 None,2277 None,
2278 None,
2202 Paper::WHITE,2279 Paper::WHITE,
2203 |id, offset, primitives| {2280 |id, offset, primitives| {
2204 let outline = editor2281 let outline = editor
...@@ -2461,7 +2538,7 @@ mod tests {...@@ -2461,7 +2538,7 @@ mod tests {
2461 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();2538 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2462 assert!(2539 assert!(
2463 scene2540 scene
2464 .content_bounds(&editor)2541 .content_bounds(&editor, None)
2465 .any(|(bounds, _)| bounds == [9.0, 19.0, 41.0, 61.0])2542 .any(|(bounds, _)| bounds == [9.0, 19.0, 41.0, 61.0])
2466 );2543 );
2467 let mut primitives = Vec::new();2544 let mut primitives = Vec::new();
...@@ -2471,6 +2548,7 @@ mod tests {...@@ -2471,6 +2548,7 @@ mod tests {
2471 [5.0, 0.0],2548 [5.0, 0.0],
2472 Some(&editor),2549 Some(&editor),
2473 None,2550 None,
2551 None,
2474 Paper::WHITE,2552 Paper::WHITE,
2475 |_, _, _| Ok(()),2553 |_, _, _| Ok(()),
2476 )2554 )
...@@ -2682,6 +2760,7 @@ mod tests {...@@ -2682,6 +2760,7 @@ mod tests {
2682 &mut primitives,2760 &mut primitives,
2683 [0.0; 2],2761 [0.0; 2],
2684 Some(&editor),2762 Some(&editor),
2763 None,
2685 Some((id, [70.0, 5.0, 120.0, 55.0])),2764 Some((id, [70.0, 5.0, 120.0, 55.0])),
2686 Paper::WHITE,2765 Paper::WHITE,
2687 |_, _, _| Ok(()),2766 |_, _, _| Ok(()),
...@@ -2777,6 +2856,7 @@ mod tests {...@@ -2777,6 +2856,7 @@ mod tests {
2777 [0.0; 2],2856 [0.0; 2],
2778 Some(&editor),2857 Some(&editor),
2779 None,2858 None,
2859 None,
2780 Paper::WHITE,2860 Paper::WHITE,
2781 |_, _, _| Ok(()),2861 |_, _, _| Ok(()),
2782 )2862 )
crates/canvas/src/interaction/accessibility.rs+118-40
...@@ -94,7 +94,9 @@ impl Accessibility {...@@ -94,7 +94,9 @@ impl Accessibility {
94 }94 }
9595
96 pub fn read_only_for_node(&self, node: NodeId) -> Option<usize> {96 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))
98 }100 }
99101
100 pub fn date_for_node(&self, node: NodeId) -> Option<DateField> {102 pub fn date_for_node(&self, node: NodeId) -> Option<DateField> {
...@@ -114,6 +116,24 @@ impl Accessibility {...@@ -114,6 +116,24 @@ impl Accessibility {
114 /// The nodes that changed since the last update. After an error, `deactivate` before the116 /// The nodes that changed since the last update. After an error, `deactivate` before the
115 /// next update.117 /// next update.
116 pub fn update(118 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(
117 &mut self,137 &mut self,
118 editor: &CanvasEditor,138 editor: &CanvasEditor,
119 scene: Option<&(PageScene, [f32; 2])>,139 scene: Option<&(PageScene, [f32; 2])>,
...@@ -121,6 +141,7 @@ impl Accessibility {...@@ -121,6 +141,7 @@ impl Accessibility {
121 label: &str,141 label: &str,
122 preview: Option<(ExGuid, [f32; 2])>,142 preview: Option<(ExGuid, [f32; 2])>,
123 read_only_focus: Option<usize>,143 read_only_focus: Option<usize>,
144 column: Option<&crate::reading::Column>,
124 ) -> Result<TreeUpdate, EditError> {145 ) -> Result<TreeUpdate, EditError> {
125 let mut nodes = Vec::new();146 let mut nodes = Vec::new();
126 let mut focus = ROOT;147 let mut focus = ROOT;
...@@ -129,16 +150,26 @@ impl Accessibility {...@@ -129,16 +150,26 @@ impl Accessibility {
129 .drain(..)150 .drain(..)
130 .map(|field| (field.outline, field))151 .map(|field| (field.outline, field))
131 .collect();152 .collect();
132 for (ordinal, outline) in editor153 let mut outlines: Vec<_> = editor
133 .visible_outlines()154 .visible_outlines()
134 .chain(editor.caret_outline())155 .chain(editor.caret_outline())
135 .enumerate()156 .collect();
136 {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() {
137 let (id, sent, old) = match previous.remove(&outline.id) {167 let (id, sent, old) = match previous.remove(&outline.id) {
138 Some(field) => (field.id, Some(field.node), field.paragraphs),168 Some(field) => (field.id, Some(field.node), field.paragraphs),
139 None => (self.allocate(), None, Vec::new()),169 None => (self.allocate(), None, Vec::new()),
140 };170 };
141 let layouts: Vec<_> = outline.layouts().collect();171 let (geometry, displayed) = crate::reading::geometry(outline, column);
172 let layouts: Vec<_> = geometry.layouts().collect();
142 // Only a paragraph with a successor ends its last run with the paragraph break.173 // Only a paragraph with a successor ends its last run with the paragraph break.
143 let same = |old_index: usize, index: usize| {174 let same = |old_index: usize, index: usize| {
144 old[old_index].layout == layouts[index].1.text.id()175 old[old_index].layout == layouts[index].1.text.id()
...@@ -240,16 +271,26 @@ impl Accessibility {...@@ -240,16 +271,26 @@ impl Accessibility {
240 .map(|paragraph| paragraph.id)271 .map(|paragraph| paragraph.id)
241 .collect::<Vec<_>>(),272 .collect::<Vec<_>>(),
242 );273 );
243 let origin = preview274 let mut origin = preview
244 .filter(|(id, _)| *id == outline.id)275 .filter(|(id, _)| *id == outline.id)
245 .map(|(_, origin)| origin)276 .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 }
247 node.set_transform(Affine::translate((283 node.set_transform(Affine::translate((
248 f64::from(origin[0]),284 f64::from(origin[0]),
249 f64::from(origin[1]),285 f64::from(origin[1]),
250 )));286 )));
251 let bounds = outline.bounds();287 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));
253 if outline.id == editor.active_outline().id {294 if outline.id == editor.active_outline().id {
254 let Selection {295 let Selection {
255 positions: [anchor, caret],296 positions: [anchor, caret],
...@@ -271,7 +312,22 @@ impl Accessibility {...@@ -271,7 +312,22 @@ impl Accessibility {
271 paragraphs,312 paragraphs,
272 });313 });
273 }314 }
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();
275 let sent_read_only = std::mem::take(&mut self.read_only);331 let sent_read_only = std::mem::take(&mut self.read_only);
276 let sent_dates = std::mem::take(&mut self.dates);332 let sent_dates = std::mem::take(&mut self.dates);
277 if let Some((scene, offset)) = scene {333 if let Some((scene, offset)) = scene {
...@@ -297,10 +353,27 @@ impl Accessibility {...@@ -297,10 +353,27 @@ impl Accessibility {
297 if read_only_focus == Some(index) {353 if read_only_focus == Some(index) {
298 focus = id;354 focus = id;
299 }355 }
300 children.push(id);356 let bounds = object.rect();
357 children.push((id, [bounds[0], bounds[1]]));
301 self.read_only.push((id, node));358 self.read_only.push((id, node));
302 }359 }
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() {
304 let mut node = Node::new(Role::Button);377 let mut node = Node::new(Role::Button);
305 node.set_label(super::DATE_LABELS[field as usize]);378 node.set_label(super::DATE_LABELS[field as usize]);
306 node.set_value(379 node.set_value(
...@@ -317,7 +390,7 @@ impl Accessibility {...@@ -317,7 +390,7 @@ impl Accessibility {
317 if sent.is_none_or(|(_, _, sent)| *sent != node) {390 if sent.is_none_or(|(_, _, sent)| *sent != node) {
318 nodes.push((id, node.clone()));391 nodes.push((id, node.clone()));
319 }392 }
320 children.push(id);393 children.push((id, [bounds[0], bounds[1]]));
321 self.dates.push((field, id, node));394 self.dates.push((field, id, node));
322 }395 }
323 }396 }
...@@ -330,7 +403,10 @@ impl Accessibility {...@@ -330,7 +403,10 @@ impl Accessibility {
330 f64::from(viewport.origin[0]),403 f64::from(viewport.origin[0]),
331 f64::from(viewport.origin[1]),404 f64::from(viewport.origin[1]),
332 ]));405 ]));
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<_>>());
334 let mut root = Node::new(Role::Group);410 let mut root = Node::new(Role::Group);
335 root.set_label(label);411 root.set_label(label);
336 root.set_children(vec![PAGE]);412 root.set_children(vec![PAGE]);
...@@ -772,7 +848,7 @@ pub(super) mod tests {...@@ -772,7 +848,7 @@ pub(super) mod tests {
772 _ => {}848 _ => {}
773 }849 }
774 let update = access850 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)
776 .unwrap();852 .unwrap();
777 let node = nodes(apply(&mut tree, update))853 let node = nodes(apply(&mut tree, update))
778 .into_iter()854 .into_iter()
...@@ -847,7 +923,7 @@ pub(super) mod tests {...@@ -847,7 +923,7 @@ pub(super) mod tests {
847 scale,923 scale,
848 };924 };
849 let update = access925 let update = access
850 .update(&editor, Some(&scene), viewport, "Test", None, None)926 .update_with(&editor, Some(&scene), viewport, "Test", None, None, None)
851 .unwrap();927 .unwrap();
852 let state = apply(&mut tree, update);928 let state = apply(&mut tree, update);
853 let node = nodes(state)929 let node = nodes(state)
...@@ -884,7 +960,7 @@ pub(super) mod tests {...@@ -884,7 +960,7 @@ pub(super) mod tests {
884 "annotation "960 "annotation "
885 );961 );
886 let focused = access962 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)
888 .unwrap();964 .unwrap();
889 assert_eq!(focused.focus, identity.unwrap());965 assert_eq!(focused.focus, identity.unwrap());
890 assert_eq!(access.read_only_for_node(focused.focus), Some(0));966 assert_eq!(access.read_only_for_node(focused.focus), Some(0));
...@@ -995,7 +1071,7 @@ pub(super) mod tests {...@@ -995,7 +1071,7 @@ pub(super) mod tests {
995 editor.undo(&mut engine).unwrap();1071 editor.undo(&mut engine).unwrap();
996 }1072 }
997 let update = access1073 let update = access
998 .update(&editor, None, viewport, "Test", None, None)1074 .update_with(&editor, None, viewport, "Test", None, None, None)
999 .unwrap();1075 .unwrap();
1000 let state = apply(&mut tree, update);1076 let state = apply(&mut tree, update);
1001 assert_eq!(1077 assert_eq!(
...@@ -1057,7 +1133,7 @@ pub(super) mod tests {...@@ -1057,7 +1133,7 @@ pub(super) mod tests {
1057 origin: [13.0, 19.0],1133 origin: [13.0, 19.0],
1058 };1134 };
1059 let update = access1135 let update = access
1060 .update(&editor, None, viewport, "Test", None, None)1136 .update_with(&editor, None, viewport, "Test", None, None, None)
1061 .unwrap();1137 .unwrap();
1062 let field = apply(&mut tree, update).focus().unwrap();1138 let field = apply(&mut tree, update).focus().unwrap();
1063 assert_eq!(1139 assert_eq!(
...@@ -1094,7 +1170,7 @@ pub(super) mod tests {...@@ -1094,7 +1170,7 @@ pub(super) mod tests {
1094 );1170 );
1095 editor.select(source).unwrap();1171 editor.select(source).unwrap();
1096 let update = access1172 let update = access
1097 .update(&editor, None, viewport, "Test", None, None)1173 .update_with(&editor, None, viewport, "Test", None, None, None)
1098 .unwrap();1174 .unwrap();
1099 assert_eq!(1175 assert_eq!(
1100 apply(&mut tree, update)1176 apply(&mut tree, update)
...@@ -1115,7 +1191,7 @@ pub(super) mod tests {...@@ -1115,7 +1191,7 @@ pub(super) mod tests {
1115 )1191 )
1116 .unwrap();1192 .unwrap();
1117 let update = access1193 let update = access
1118 .update(&editor, None, viewport, "Test", None, None)1194 .update_with(&editor, None, viewport, "Test", None, None, None)
1119 .unwrap();1195 .unwrap();
1120 let caret = editor.caret(1.0).unwrap();1196 let caret = editor.caret(1.0).unwrap();
1121 assert_eq!(caret.x0, 27.0);1197 assert_eq!(caret.x0, 27.0);
...@@ -1172,7 +1248,7 @@ pub(super) mod tests {...@@ -1172,7 +1248,7 @@ pub(super) mod tests {
1172 scale: 2.0,1248 scale: 2.0,
1173 };1249 };
1174 let update = access1250 let update = access
1175 .update(&editor, None, viewport, "Test", None, None)1251 .update_with(&editor, None, viewport, "Test", None, None, None)
1176 .unwrap();1252 .unwrap();
1177 for (_, node) in &update.nodes {1253 for (_, node) in &update.nodes {
1178 if node.role() == Role::TextRun {1254 if node.role() == Role::TextRun {
...@@ -1199,7 +1275,7 @@ pub(super) mod tests {...@@ -1199,7 +1275,7 @@ pub(super) mod tests {
1199 .unwrap();1275 .unwrap();
1200 editor.select(source_selection).unwrap();1276 editor.select(source_selection).unwrap();
1201 let selected = access1277 let selected = access
1202 .update(&editor, None, viewport, "Test", None, None)1278 .update_with(&editor, None, viewport, "Test", None, None, None)
1203 .unwrap();1279 .unwrap();
1204 assert_eq!(1280 assert_eq!(
1205 apply(&mut tree, selected)1281 apply(&mut tree, selected)
...@@ -1211,7 +1287,7 @@ pub(super) mod tests {...@@ -1211,7 +1287,7 @@ pub(super) mod tests {
1211 text1287 text
1212 );1288 );
1213 let zoomed = access1289 let zoomed = access
1214 .update(1290 .update_with(
1215 &editor,1291 &editor,
1216 None,1292 None,
1217 Viewport {1293 Viewport {
...@@ -1221,6 +1297,7 @@ pub(super) mod tests {...@@ -1221,6 +1297,7 @@ pub(super) mod tests {
1221 "Test",1297 "Test",
1222 None,1298 None,
1223 None,1299 None,
1300 None,
1224 )1301 )
1225 .unwrap();1302 .unwrap();
1226 assert_eq!(1303 assert_eq!(
...@@ -1230,7 +1307,7 @@ pub(super) mod tests {...@@ -1230,7 +1307,7 @@ pub(super) mod tests {
1230 apply(&mut tree, zoomed);1307 apply(&mut tree, zoomed);
1231 access.deactivate();1308 access.deactivate();
1232 let update = access1309 let update = access
1233 .update(&editor, None, viewport, "Test", None, None)1310 .update_with(&editor, None, viewport, "Test", None, None, None)
1234 .unwrap();1311 .unwrap();
1235 let state = apply(&mut tree, update);1312 let state = apply(&mut tree, update);
1236 assert!(1313 assert!(
...@@ -1241,7 +1318,7 @@ pub(super) mod tests {...@@ -1241,7 +1318,7 @@ pub(super) mod tests {
1241 let selection = *state.focus().unwrap().data().text_selection().unwrap();1318 let selection = *state.focus().unwrap().data().text_selection().unwrap();
1242 editor.insert(&mut engine, "replacement").unwrap();1319 editor.insert(&mut engine, "replacement").unwrap();
1243 let update = access1320 let update = access
1244 .update(&editor, None, viewport, "Test", None, None)1321 .update_with(&editor, None, viewport, "Test", None, None, None)
1245 .unwrap();1322 .unwrap();
1246 apply(&mut tree, update);1323 apply(&mut tree, update);
1247 assert!(1324 assert!(
...@@ -1251,7 +1328,7 @@ pub(super) mod tests {...@@ -1251,7 +1328,7 @@ pub(super) mod tests {
1251 );1328 );
1252 editor.undo(&mut engine).unwrap();1329 editor.undo(&mut engine).unwrap();
1253 let update = access1330 let update = access
1254 .update(&editor, None, viewport, "Test", None, None)1331 .update_with(&editor, None, viewport, "Test", None, None, None)
1255 .unwrap();1332 .unwrap();
1256 assert_eq!(1333 assert_eq!(
1257 apply(&mut tree, update)1334 apply(&mut tree, update)
...@@ -1317,7 +1394,7 @@ pub(super) mod tests {...@@ -1317,7 +1394,7 @@ pub(super) mod tests {
1317 .unwrap();1394 .unwrap();
1318 let caret = editor.caret(0.0).unwrap();1395 let caret = editor.caret(0.0).unwrap();
1319 let update = access1396 let update = access
1320 .update(&editor, None, viewport, "Test", None, None)1397 .update_with(&editor, None, viewport, "Test", None, None, None)
1321 .unwrap();1398 .unwrap();
1322 let field = apply(&mut tree, update).focus().unwrap();1399 let field = apply(&mut tree, update).focus().unwrap();
1323 assert_eq!(1400 assert_eq!(
...@@ -1394,7 +1471,7 @@ pub(super) mod tests {...@@ -1394,7 +1471,7 @@ pub(super) mod tests {
1394 editor.move_selection(&mut engine, movement, false).unwrap();1471 editor.move_selection(&mut engine, movement, false).unwrap();
1395 let caret = editor.caret(0.0).unwrap();1472 let caret = editor.caret(0.0).unwrap();
1396 let update = access1473 let update = access
1397 .update(&editor, None, viewport, "Test", None, None)1474 .update_with(&editor, None, viewport, "Test", None, None, None)
1398 .unwrap();1475 .unwrap();
1399 let field = apply(&mut tree, update).focus().unwrap();1476 let field = apply(&mut tree, update).focus().unwrap();
1400 let selection = field.text_selection().unwrap();1477 let selection = field.text_selection().unwrap();
...@@ -1451,13 +1528,13 @@ pub(super) mod tests {...@@ -1451,13 +1528,13 @@ pub(super) mod tests {
1451 .then_some((editor.active_outline().id, [13.5, -7.25]));1528 .then_some((editor.active_outline().id, [13.5, -7.25]));
1452 for repeat in 0..2 {1529 for repeat in 0..2 {
1453 let update = cached1530 let update = cached
1454 .update(editor, None, viewport, "Test", preview, None)1531 .update_with(editor, None, viewport, "Test", preview, None, None)
1455 .unwrap();1532 .unwrap();
1456 let actual = apply(&mut tree, update);1533 let actual = apply(&mut tree, update);
1457 let mut rebuilt = Accessibility::default();1534 let mut rebuilt = Accessibility::default();
1458 let expected = Tree::new(1535 let expected = Tree::new(
1459 rebuilt1536 rebuilt
1460 .update(editor, None, viewport, "Test", preview, None)1537 .update_with(editor, None, viewport, "Test", preview, None, None)
1461 .unwrap(),1538 .unwrap(),
1462 true,1539 true,
1463 );1540 );
...@@ -1575,7 +1652,7 @@ pub(super) mod tests {...@@ -1575,7 +1652,7 @@ pub(super) mod tests {
1575 scale: 2.0,1652 scale: 2.0,
1576 };1653 };
1577 let update = access1654 let update = access
1578 .update(&editor, None, viewport, "Test", None, None)1655 .update_with(&editor, None, viewport, "Test", None, None, None)
1579 .unwrap();1656 .unwrap();
1580 apply(&mut tree, update);1657 apply(&mut tree, update);
1581 let ids = |update: &TreeUpdate| update.nodes.iter().map(|(id, _)| *id).collect::<Vec<_>>();1658 let ids = |update: &TreeUpdate| update.nodes.iter().map(|(id, _)| *id).collect::<Vec<_>>();
...@@ -1583,7 +1660,7 @@ pub(super) mod tests {...@@ -1583,7 +1660,7 @@ pub(super) mod tests {
1583 viewport.origin[1] += scroll;1660 viewport.origin[1] += scroll;
1584 viewport.scale *= zoom;1661 viewport.scale *= zoom;
1585 let update = access1662 let update = access
1586 .update(&editor, None, viewport, "Test", None, None)1663 .update_with(&editor, None, viewport, "Test", None, None, None)
1587 .unwrap();1664 .unwrap();
1588 assert_eq!(ids(&update), [ROOT, PAGE]);1665 assert_eq!(ids(&update), [ROOT, PAGE]);
1589 apply(&mut tree, update);1666 apply(&mut tree, update);
...@@ -1599,13 +1676,13 @@ pub(super) mod tests {...@@ -1599,13 +1676,13 @@ pub(super) mod tests {
1599 )1676 )
1600 .unwrap();1677 .unwrap();
1601 let update = access1678 let update = access
1602 .update(&editor, None, viewport, "Test", None, None)1679 .update_with(&editor, None, viewport, "Test", None, None, None)
1603 .unwrap();1680 .unwrap();
1604 assert_eq!(ids(&update), [field, ROOT, PAGE]);1681 assert_eq!(ids(&update), [field, ROOT, PAGE]);
1605 apply(&mut tree, update);1682 apply(&mut tree, update);
1606 editor.insert(&mut engine, "x").unwrap();1683 editor.insert(&mut engine, "x").unwrap();
1607 let update = access1684 let update = access
1608 .update(&editor, None, viewport, "Test", None, None)1685 .update_with(&editor, None, viewport, "Test", None, None, None)
1609 .unwrap();1686 .unwrap();
1610 let paragraph = &access.fields[0].paragraphs[150];1687 let paragraph = &access.fields[0].paragraphs[150];
1611 assert_eq!(1688 assert_eq!(
...@@ -1616,7 +1693,7 @@ pub(super) mod tests {...@@ -1616,7 +1693,7 @@ pub(super) mod tests {
1616 let state = apply(&mut tree, update);1693 let state = apply(&mut tree, update);
1617 let expected = Tree::new(1694 let expected = Tree::new(
1618 Accessibility::default()1695 Accessibility::default()
1619 .update(&editor, None, viewport, "Test", None, None)1696 .update_with(&editor, None, viewport, "Test", None, None, None)
1620 .unwrap(),1697 .unwrap(),
1621 true,1698 true,
1622 );1699 );
...@@ -1641,7 +1718,7 @@ pub(super) mod tests {...@@ -1641,7 +1718,7 @@ pub(super) mod tests {
1641 origin: [48.0; 2],1718 origin: [48.0; 2],
1642 };1719 };
1643 let update = access1720 let update = access
1644 .update(&editor, None, viewport, "Test", None, None)1721 .update_with(&editor, None, viewport, "Test", None, None, None)
1645 .unwrap();1722 .unwrap();
1646 let fields: Vec<_> = update1723 let fields: Vec<_> = update
1647 .nodes1724 .nodes
...@@ -1667,13 +1744,14 @@ pub(super) mod tests {...@@ -1667,13 +1744,14 @@ pub(super) mod tests {
1667 let source_selection = access.selection(second, selection).unwrap();1744 let source_selection = access.selection(second, selection).unwrap();
1668 editor.select(source_selection).unwrap();1745 editor.select(source_selection).unwrap();
1669 let preview = access1746 let preview = access
1670 .update(1747 .update_with(
1671 &editor,1748 &editor,
1672 None,1749 None,
1673 viewport,1750 viewport,
1674 "Test",1751 "Test",
1675 Some((second, [400.0, 50.0])),1752 Some((second, [400.0, 50.0])),
1676 None,1753 None,
1754 None,
1677 )1755 )
1678 .unwrap();1756 .unwrap();
1679 let preview_field = apply(&mut tree, preview).focus().unwrap();1757 let preview_field = apply(&mut tree, preview).focus().unwrap();
...@@ -1683,7 +1761,7 @@ pub(super) mod tests {...@@ -1683,7 +1761,7 @@ pub(super) mod tests {
1683 assert_eq!(editor.active_outline().origin(), [300.0, 40.0]);1761 assert_eq!(editor.active_outline().origin(), [300.0, 40.0]);
1684 editor.move_outline(second, [400.0, 50.0]).unwrap();1762 editor.move_outline(second, [400.0, 50.0]).unwrap();
1685 let update = access1763 let update = access
1686 .update(&editor, None, viewport, "Test", None, None)1764 .update_with(&editor, None, viewport, "Test", None, None, None)
1687 .unwrap();1765 .unwrap();
1688 apply(&mut tree, update);1766 apply(&mut tree, update);
1689 assert_eq!(access.outline_for_node(fields[1].0), Some(second));1767 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
...@@ -1692,7 +1770,7 @@ pub(super) mod tests {...@@ -1692,7 +1770,7 @@ pub(super) mod tests {
1692 editor.undo(&mut engine).unwrap();1770 editor.undo(&mut engine).unwrap();
1693 editor.undo(&mut engine).unwrap();1771 editor.undo(&mut engine).unwrap();
1694 let update = access1772 let update = access
1695 .update(&editor, None, viewport, "Test", None, None)1773 .update_with(&editor, None, viewport, "Test", None, None, None)
1696 .unwrap();1774 .unwrap();
1697 assert_eq!(1775 assert_eq!(
1698 nodes(apply(&mut tree, update))1776 nodes(apply(&mut tree, update))
...@@ -1735,7 +1813,7 @@ pub(super) mod tests {...@@ -1735,7 +1813,7 @@ pub(super) mod tests {
1735 scale: 1.0,1813 scale: 1.0,
1736 };1814 };
1737 Accessibility::default()1815 Accessibility::default()
1738 .update(&editor, None, viewport, "Test", None, None)1816 .update_with(&editor, None, viewport, "Test", None, None, None)
1739 .unwrap();1817 .unwrap();
1740 }1818 }
1741 assert!(wrapped);1819 assert!(wrapped);
crates/canvas/src/interaction/ink.rs+3
...@@ -82,6 +82,9 @@ impl PageView {...@@ -82,6 +82,9 @@ impl PageView {
82 /// Picks what presses on the page do, as the Draw tab's tools do; the lasso's selection82 /// Picks what presses on the page do, as the Draw tab's tools do; the lasso's selection
83 /// goes.83 /// goes.
84 pub fn set_tool(&mut self, tool: Tool) -> Response {84 pub fn set_tool(&mut self, tool: Tool) -> Response {
85 if tool != Tool::Select {
86 self.column = None;
87 }
85 self.ink.tool = tool;88 self.ink.tool = tool;
86 self.leave_ink();89 self.leave_ink();
87 Response::redraw()90 Response::redraw()
crates/canvas/src/interaction/mod.rs+430-95
...@@ -248,6 +248,7 @@ pub struct Place {...@@ -248,6 +248,7 @@ pub struct Place {
248/// keyboard state between events.248/// keyboard state between events.
249pub struct PageView {249pub struct PageView {
250 pub editor: CanvasEditor,250 pub editor: CanvasEditor,
251 pub(crate) column: Option<crate::reading::Column>,
251 pub engine: TextEngine,252 pub engine: TextEngine,
252 /// The page's noneditable content and where it sits in the view.253 /// The page's noneditable content and where it sits in the view.
253 pub scene: Option<(PageScene, [f32; 2])>,254 pub scene: Option<(PageScene, [f32; 2])>,
...@@ -325,6 +326,7 @@ impl PageView {...@@ -325,6 +326,7 @@ impl PageView {
325 ) -> Self {326 ) -> Self {
326 let mut view = Self {327 let mut view = Self {
327 editor,328 editor,
329 column: None,
328 engine,330 engine,
329 scene,331 scene,
330 viewport: Viewport {332 viewport: Viewport {
...@@ -367,6 +369,7 @@ impl PageView {...@@ -367,6 +369,7 @@ impl PageView {
367 editor.markdown = self.editor.markdown.take();369 editor.markdown = self.editor.markdown.take();
368 let left = std::mem::replace(&mut self.editor, editor);370 let left = std::mem::replace(&mut self.editor, editor);
369 self.scene = scene;371 self.scene = scene;
372 self.column = None;
370 self.drag = None;373 self.drag = None;
371 self.space = false;374 self.space = false;
372 self.object_focus = None;375 self.object_focus = None;
...@@ -380,7 +383,9 @@ impl PageView {...@@ -380,7 +383,9 @@ impl PageView {
380 /// keeping the scroll, the caret and selection, and the pictures already drawn. Marked383 /// keeping the scroll, the caret and selection, and the pictures already drawn. Marked
381 /// text must be committed or cancelled first.384 /// text must be committed or cancelled first.
382 pub fn refresh(&mut self, page: onestore::page::Page) -> Result<Response> {385 pub fn refresh(&mut self, page: onestore::page::Page) -> Result<Response> {
386 let width = self.column.take().map(|column| column.width);
383 if !self.editor.refresh(page, &mut self.engine)? {387 if !self.editor.refresh(page, &mut self.engine)? {
388 self.set_reading(width);
384 return Ok(Response::default());389 return Ok(Response::default());
385 }390 }
386 if let Some((scene, _)) = &mut self.scene {391 if let Some((scene, _)) = &mut self.scene {
...@@ -389,12 +394,15 @@ impl PageView {...@@ -389,12 +394,15 @@ impl PageView {
389 self.drag = None;394 self.drag = None;
390 self.object_focus = None;395 self.object_focus = None;
391 self.refresh_ink();396 self.refresh_ink();
397 self.set_reading(width);
392 self.moved()398 self.moved()
393 }399 }
394400
395 /// Lays the page out again where it stands, as after fonts arrived.401 /// Lays the page out again where it stands, as after fonts arrived.
396 pub fn relayout(&mut self) -> Result<Response> {402 pub fn relayout(&mut self) -> Result<Response> {
403 let width = self.column.take().map(|column| column.width);
397 self.editor.relayout(&mut self.engine)?;404 self.editor.relayout(&mut self.engine)?;
405 self.set_reading(width);
398 self.moved()406 self.moved()
399 }407 }
400408
...@@ -425,10 +433,12 @@ impl PageView {...@@ -425,10 +433,12 @@ impl PageView {
425 /// Takes up `parked`, the editor the page `open` just showed was left with, history and433 /// Takes up `parked`, the editor the page `open` just showed was left with, history and
426 /// all, in place of the one it opened with.434 /// all, in place of the one it opened with.
427 pub fn resume(&mut self, parked: CanvasEditor) -> Result<()> {435 pub fn resume(&mut self, parked: CanvasEditor) -> Result<()> {
436 let width = self.column.take().map(|column| column.width);
428 self.editor.resume(parked, &mut self.engine)?;437 self.editor.resume(parked, &mut self.engine)?;
429 if let Some((scene, _)) = &mut self.scene {438 if let Some((scene, _)) = &mut self.scene {
430 scene.refresh(&mut self.editor, &mut self.engine)?;439 scene.refresh(&mut self.editor, &mut self.engine)?;
431 }440 }
441 self.set_reading(width);
432 Ok(())442 Ok(())
433 }443 }
434444
...@@ -593,6 +603,7 @@ impl PageView {...@@ -593,6 +603,7 @@ impl PageView {
593 point,603 point,
594 self.pixel(),604 self.pixel(),
595 touched,605 touched,
606 self.column.as_ref(),
596 )607 )
597 }608 }
598609
...@@ -602,6 +613,20 @@ impl PageView {...@@ -602,6 +613,20 @@ impl PageView {
602 self.hit_test(self.viewport.document_point(position))613 self.hit_test(self.viewport.document_point(position))
603 }614 }
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
605 /// The Outlook task icon under the pointer, in view device pixels, for the host to name.630 /// The Outlook task icon under the pointer, in view device pixels, for the host to name.
606 pub fn task_under_pointer(&self) -> Option<[f32; 4]> {631 pub fn task_under_pointer(&self) -> Option<[f32; 4]> {
607 if !self.pointer_inside || self.drag.is_some() {632 if !self.pointer_inside || self.drag.is_some() {
...@@ -611,12 +636,17 @@ impl PageView {...@@ -611,12 +636,17 @@ impl PageView {
611 let view = |v: f32, axis: usize| v * self.viewport.scale + self.viewport.origin[axis];636 let view = |v: f32, axis: usize| v * self.viewport.scale + self.viewport.origin[axis];
612 self.editor.visible_outlines().find_map(|outline| {637 self.editor.visible_outlines().find_map(|outline| {
613 let offset = match &self.scene {638 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 }
615 _ => [0.0; 2],644 _ => [0.0; 2],
616 };645 };
617 let shaped = outline.shaped();646 let (geometry, origin) = crate::reading::geometry(outline, self.column.as_ref());
618 let left = offset[0] + outline.origin()[0] + shaped.tag_column_offset();647 let shaped = geometry.shaped;
619 let top = offset[1] + outline.origin()[1];648 let left = offset[0] + origin[0] + shaped.tag_column_offset();
649 let top = offset[1] + origin[1];
620 shaped.tags().find_map(|(_, origin, tag)| {650 shaped.tags().find_map(|(_, origin, tag)| {
621 let x0 = left + origin[0];651 let x0 = left + origin[0];
622 let y0 = top + origin[1];652 let y0 = top + origin[1];
...@@ -665,6 +695,11 @@ impl PageView {...@@ -665,6 +695,11 @@ impl PageView {
665695
666 /// Keeps the view in bounds and restarts the caret blink after a change.696 /// Keeps the view in bounds and restarts the caret blink after a change.
667 fn changed(&mut self) -> Result<Response> {697 fn changed(&mut self) -> Result<Response> {
698 self.reconcile_reading();
699 self.finish_change()
700 }
701
702 fn finish_change(&mut self) -> Result<Response> {
668 if !self.host_viewport {703 if !self.host_viewport {
669 self.scroll().clamp(&mut self.viewport);704 self.scroll().clamp(&mut self.viewport);
670 }705 }
...@@ -686,11 +721,14 @@ impl PageView {...@@ -686,11 +721,14 @@ impl PageView {
686721
687 /// The caret's rectangle in device pixels, for placing the platform's input method.722 /// The caret's rectangle in device pixels, for placing the platform's input method.
688 pub fn caret_area(&self) -> Result<[f32; 4]> {723 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)?;
690 let origin = self728 let origin = self
691 .outline_preview()729 .outline_preview()
692 .map(|(_, origin)| origin)730 .map(|(_, origin)| origin)
693 .unwrap_or_else(|| self.editor.active_outline().origin());731 .unwrap_or(displayed);
694 rect.x0 += f64::from(origin[0]);732 rect.x0 += f64::from(origin[0]);
695 rect.y0 += f64::from(origin[1]);733 rect.y0 += f64::from(origin[1]);
696 rect.x1 += f64::from(origin[0]);734 rect.x1 += f64::from(origin[0]);
...@@ -714,17 +752,18 @@ impl PageView {...@@ -714,17 +752,18 @@ impl PageView {
714 let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from);752 let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from);
715 parley::BoundingBox { x0, y0, x1, y1 }753 parley::BoundingBox { x0, y0, x1, y1 }
716 } else {754 } else {
717 let mut rect = self.editor.caret(1.0)?;755 let outline = self.editor.active_outline();
718 let origin = self.editor.active_outline().origin();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)?;
719 rect.x0 += f64::from(origin[0]);760 rect.x0 += f64::from(origin[0]);
720 rect.x1 += f64::from(origin[0]);761 rect.x1 += f64::from(origin[0]);
721 rect.y0 += f64::from(origin[1]);762 rect.y0 += f64::from(origin[1]);
722 rect.y1 += f64::from(origin[1]);763 rect.y1 += f64::from(origin[1]);
723 rect764 rect
724 };765 };
725 let outline = self766 let outline = (self.column.is_none() && self.object_focus.is_none())
726 .object_focus
727 .is_none()
728 .then(|| self.editor.active_outline().bounds());767 .then(|| self.editor.active_outline().bounds());
729 self.reveal(rect, outline);768 self.reveal(rect, outline);
730 Ok(())769 Ok(())
...@@ -781,17 +820,10 @@ impl PageView {...@@ -781,17 +820,10 @@ impl PageView {
781 && !self.host_viewport820 && !self.host_viewport
782 && !self.viewport.size.contains(&0)821 && !self.viewport.size.contains(&0)
783 {822 {
784 let [x, y] = outline.origin().map(f64::from);823 let rect = self
785 let rect = outline824 .range_rects(outline.id, selection)?
786 .range_rects(selection)?
787 .into_iter()825 .into_iter()
788 .reduce(|a, b| a.union(b))826 .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 });
795 if let Some(rect) = rect {827 if let Some(rect) = rect {
796 self.reveal(rect, None);828 self.reveal(rect, None);
797 return self.moved();829 return self.moved();
...@@ -804,11 +836,15 @@ impl PageView {...@@ -804,11 +836,15 @@ impl PageView {
804 /// it keeps its place on screen.836 /// it keeps its place on screen.
805 fn move_page(&mut self, up: bool, extend: bool) -> Result<()> {837 fn move_page(&mut self, up: bool, extend: bool) -> Result<()> {
806 let movement = if up { Movement::Up } else { Movement::Down };838 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();
807 let start = self.caret_area()?[1];844 let start = self.caret_area()?[1];
808 let mut caret = self.caret_area()?;845 let mut caret = self.caret_area()?;
809 while (caret[1] - start).abs() < self.viewport.size[1] as f32 {846 while (caret[1] - start).abs() < self.viewport.size[1] as f32 {
810 self.editor847 self.move_selection_prepared(movement, extend)?;
811 .move_selection(&mut self.engine, movement, extend)?;
812 let moved = self.caret_area()?;848 let moved = self.caret_area()?;
813 if moved == caret {849 if moved == caret {
814 break;850 break;
...@@ -821,10 +857,11 @@ impl PageView {...@@ -821,10 +857,11 @@ impl PageView {
821857
822 /// Reveals the caret or focused object after an edit, then reports the change.858 /// Reveals the caret or focused object after an edit, then reports the change.
823 fn edited(&mut self) -> Result<Response> {859 fn edited(&mut self) -> Result<Response> {
860 self.reconcile_reading();
824 if !self.host_viewport {861 if !self.host_viewport {
825 self.reveal_focus()?;862 self.reveal_focus()?;
826 }863 }
827 self.changed()864 self.finish_change()
828 }865 }
829866
830 /// The page's zoom, where 1 is OneNote's 100%: 96 pixels per inch.867 /// The page's zoom, where 1 is OneNote's 100%: 96 pixels per inch.
...@@ -876,14 +913,28 @@ impl PageView {...@@ -876,14 +913,28 @@ impl PageView {
876 .visible_outlines()913 .visible_outlines()
877 .chain(self.editor.caret_outline())914 .chain(self.editor.caret_outline())
878 .map(|outline| {915 .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 };
880 let offset = self927 let offset = self
881 .scene928 .scene
882 .as_ref()929 .as_ref()
883 .filter(|_| self.editor.has_page_outline(outline.id))930 .filter(|_| self.column.is_some() || self.editor.has_page_outline(outline.id))
884 .map(|(_, offset)| *offset)931 .map(|(_, offset)| *offset)
885 .unwrap_or([0.0; 2]);932 .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 };
887 [938 [
888 left + offset[0],939 left + offset[0],
889 top + offset[1],940 top + offset[1],
...@@ -894,7 +945,7 @@ impl PageView {...@@ -894,7 +945,7 @@ impl PageView {
894 let pad = PAD * self.display_scale;945 let pad = PAD * self.display_scale;
895 let fixed = self.scene.iter().flat_map(|(scene, offset)| {946 let fixed = self.scene.iter().flat_map(|(scene, offset)| {
896 scene947 scene
897 .content_bounds(&self.editor)948 .content_bounds(&self.editor, self.column.as_ref())
898 .map(move |(rect, padded)| {949 .map(move |(rect, padded)| {
899 (950 (
900 crate::translated(rect, *offset),951 crate::translated(rect, *offset),
...@@ -907,7 +958,9 @@ impl PageView {...@@ -907,7 +958,9 @@ impl PageView {
907 editable.map(|rect| (rect, pad)).chain(fixed),958 editable.map(|rect| (rect, pad)).chain(fixed),
908 self.editor.rtl(),959 self.editor.rtl(),
909 );960 );
910 if let Some(reach) = self.reach {961 if self.column.is_none()
962 && let Some(reach) = self.reach
963 {
911 for axis in 0..2 {964 for axis in 0..2 {
912 scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale);965 scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale);
913 scroll.max[axis] = scroll.max[axis]966 scroll.max[axis] = scroll.max[axis]
...@@ -949,6 +1002,158 @@ impl PageView {...@@ -949,6 +1002,158 @@ impl PageView {
949 update_pictures(scene, *offset, editor, viewport, paper, waker)1002 update_pictures(scene, *offset, editor, viewport, paper, waker)
950 }1003 }
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
952 /// Everything to draw this frame.1157 /// Everything to draw this frame.
953 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {1158 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {
954 let preview = match &self.drag {1159 let preview = match &self.drag {
...@@ -973,11 +1178,13 @@ impl PageView {...@@ -973,11 +1178,13 @@ impl PageView {
973 if !self.pointer_inside || self.space {1178 if !self.pointer_inside || self.space {
974 return None;1179 return None;
975 }1180 }
976 match page_hit_test(1181 match page_hit(
977 &self.editor,1182 &self.editor,
978 self.scene.as_ref(),1183 self.scene.as_ref(),
979 self.viewport.document_point(self.pointer),1184 self.viewport.document_point(self.pointer),
980 self.pixel(),1185 self.pixel(),
1186 None,
1187 self.column.as_ref(),
981 ) {1188 ) {
982 Some(1189 Some(
983 Hit::Text { id, .. } | Hit::Handle { id, .. } | Hit::Resize { id, .. },1190 Hit::Text { id, .. } | Hit::Handle { id, .. } | Hit::Resize { id, .. },
...@@ -1000,6 +1207,7 @@ impl PageView {...@@ -1000,6 +1207,7 @@ impl PageView {
1000 primitives.extend(page_primitives(1207 primitives.extend(page_primitives(
1001 &self.editor,1208 &self.editor,
1002 self.scene.as_ref(),1209 self.scene.as_ref(),
1210 self.column.as_ref(),
1003 preview,1211 preview,
1004 self.object_focus,1212 self.object_focus,
1005 Paint {1213 Paint {
...@@ -1089,9 +1297,7 @@ impl PageView {...@@ -1089,9 +1297,7 @@ impl PageView {
1089 (Some(Drag::Column { .. }), _) | (None, Some(Hit::Column { .. })) => Cursor::ColResize,1297 (Some(Drag::Column { .. }), _) | (None, Some(Hit::Column { .. })) => Cursor::ColResize,
1090 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,1298 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
1091 (None, Some(Hit::Date(_))) => Cursor::Pointer,1299 (None, Some(Hit::Date(_))) => Cursor::Pointer,
1092 (None, Some(Hit::Text { id, point }))1300 (None, Some(Hit::Text { id, point })) if self.link_under(id, point).is_some() => {
1093 if self.editor.link_under(id, point).is_some() =>
1094 {
1095 Cursor::Pointer1301 Cursor::Pointer
1096 }1302 }
1097 (1303 (
...@@ -1170,10 +1376,7 @@ impl PageView {...@@ -1170,10 +1376,7 @@ impl PageView {
1170 Some(Drag::Text { anchor, unit }) => {1376 Some(Drag::Text { anchor, unit }) => {
1171 let (anchor, unit) = (*anchor, *unit);1377 let (anchor, unit) = (*anchor, *unit);
1172 let point = self.viewport.document_point(self.pointer);1378 let point = self.viewport.document_point(self.pointer);
1173 let origin = self.editor.active_outline().origin();1379 let target = self.selection_at(self.editor.active_outline().id, point, unit)?;
1174 let target =
1175 self.editor
1176 .selection_at(point[0] - origin[0], point[1] - origin[1], unit)?;
1177 let pair = |selection: Selection| {1380 let pair = |selection: Selection| {
1178 [0, 1].map(|end| (selection.positions[end], selection.affinities[end]))1381 [0, 1].map(|end| (selection.positions[end], selection.affinities[end]))
1179 };1382 };
...@@ -1357,7 +1560,7 @@ impl PageView {...@@ -1357,7 +1560,7 @@ impl PageView {
1357 Some(Hit::Text { id, point })1560 Some(Hit::Text { id, point })
1358 if unit == SelectionUnit::Grapheme1561 if unit == SelectionUnit::Grapheme
1359 && !self.modifiers.shift1562 && !self.modifiers.shift
1360 && let Some(address) = self.editor.link_under(id, point) =>1563 && let Some(address) = self.link_under(id, point) =>
1361 {1564 {
1362 self.drag = None;1565 self.drag = None;
1363 return Ok(Response {1566 return Ok(Response {
...@@ -1365,15 +1568,21 @@ impl PageView {...@@ -1365,15 +1568,21 @@ impl PageView {
1365 ..Response::default()1568 ..Response::default()
1366 });1569 });
1367 }1570 }
1368 Some(Hit::Text { id, point }) => {1571 Some(Hit::Text { id, point: local }) => {
1572 let reading = self.column.is_some();
1369 let extend = self.modifiers.shift1573 let extend = self.modifiers.shift
1370 && self.object_focus.is_none()1574 && self.object_focus.is_none()
1371 && id == self.editor.active_outline().id;1575 && id == self.editor.active_outline().id;
1372 self.set_object_focus(None);1576 self.set_object_focus(None);
1373 self.editor.focus_outline(id)?;1577 self.editor.focus_outline(id)?;
1374 let previous = self.editor.selection();1578 let previous = self.editor.selection();
1375 self.editor.select_below(&mut self.engine, id, point)?;1579 self.reconcile_reading();
1376 let selection = self.editor.selection_at(point[0], point[1], unit)?;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 };
1377 let selection = if extend {1586 let selection = if extend {
1378 Selection {1587 Selection {
1379 positions: [previous.positions[0], selection.positions[1]],1588 positions: [previous.positions[0], selection.positions[1]],
...@@ -1402,6 +1611,29 @@ impl PageView {...@@ -1402,6 +1611,29 @@ impl PageView {
14021611
1403 /// A fresh click places text 7 px above the pointer, on the grid.1612 /// A fresh click places text 7 px above the pointer, on the grid.
1404 fn place_caret(&mut self, point: [f32; 2]) -> Result<()> {1613 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 }
1405 let position = [point[0], point[1] - 7.0 * self.pixel()];1637 let position = [point[0], point[1] - 7.0 * self.pixel()];
1406 let position = self.grid(position);1638 let position = self.grid(position);
1407 self.editor1639 self.editor
...@@ -1625,8 +1857,8 @@ impl PageView {...@@ -1625,8 +1857,8 @@ impl PageView {
1625 /// `None` off text, pictures and files.1857 /// `None` off text, pictures and files.
1626 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {1858 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {
1627 let point = self.viewport.document_point(self.pointer);1859 let point = self.viewport.document_point(self.pointer);
1628 let (id, point) = match self.hit_test(point) {1860 let id = match self.hit_test(point) {
1629 Some(Hit::Text { id, point }) => (id, point),1861 Some(Hit::Text { id, .. }) => id,
1630 Some(Hit::File(id)) | Some(Hit::Image { id, .. }) => {1862 Some(Hit::File(id)) | Some(Hit::Image { id, .. }) => {
1631 let focus = if self.editor.attachment(id).is_some() {1863 let focus = if self.editor.attachment(id).is_some() {
1632 ObjectFocus::File(id)1864 ObjectFocus::File(id)
...@@ -1645,9 +1877,8 @@ impl PageView {...@@ -1645,9 +1877,8 @@ impl PageView {
1645 let outline = self.editor.active_outline().id;1877 let outline = self.editor.active_outline().id;
1646 let [anchor, focus] = self.editor.selection().positions;1878 let [anchor, focus] = self.editor.selection().positions;
1647 self.editor.focus_outline(id)?;1879 self.editor.focus_outline(id)?;
1648 let at = self1880 self.reconcile_reading();
1649 .editor1881 let at = self.selection_at(id, point, SelectionUnit::Grapheme)?;
1650 .selection_at(point[0], point[1], SelectionUnit::Grapheme)?;
1651 let inside = outline == id1882 let inside = outline == id
1652 && anchor.min(focus) <= at.positions[0]1883 && anchor.min(focus) <= at.positions[0]
1653 && at.positions[0] <= anchor.max(focus);1884 && at.positions[0] <= anchor.max(focus);
...@@ -1727,8 +1958,8 @@ impl PageView {...@@ -1727,8 +1958,8 @@ impl PageView {
1727 let (id, word, correction) = {1958 let (id, word, correction) = {
1728 let mut outlines: Vec<&TextOutline> = self.editor.outlines().iter().collect();1959 let mut outlines: Vec<&TextOutline> = self.editor.outlines().iter().collect();
1729 outlines.sort_by(|a, b| {1960 outlines.sort_by(|a, b| {
1730 let [ax, ay] = a.origin();1961 let [ax, ay] = crate::reading::geometry(a, self.column.as_ref()).1;
1731 let [bx, by] = b.origin();1962 let [bx, by] = crate::reading::geometry(b, self.column.as_ref()).1;
1732 ay.total_cmp(&by).then(ax.total_cmp(&bx))1963 ay.total_cmp(&by).then(ax.total_cmp(&bx))
1733 });1964 });
1734 let active = self.editor.active_outline().id;1965 let active = self.editor.active_outline().id;
...@@ -1962,14 +2193,16 @@ impl PageView {...@@ -1962,14 +2193,16 @@ impl PageView {
1962 /// Puts the caret where a drop at `position` lands, as a click there would.2193 /// Puts the caret where a drop at `position` lands, as a click there would.
1963 fn drop_caret(&mut self, position: [f32; 2]) -> Result<()> {2194 fn drop_caret(&mut self, position: [f32; 2]) -> Result<()> {
1964 let point = self.viewport.document_point(position);2195 let point = self.viewport.document_point(position);
2196 let reading = self.column.is_some();
1965 self.set_object_focus(None);2197 self.set_object_focus(None);
1966 match self.hit_test(point) {2198 match self.hit_test(point) {
1967 Some(Hit::Text { id, point }) => {2199 Some(Hit::Text { id, point: local }) => {
1968 self.editor.focus_outline(id)?;2200 self.editor.focus_outline(id)?;
1969 self.editor.select_below(&mut self.engine, id, point)?;2201 self.reconcile_reading();
1970 let at = self2202 if !reading {
1971 .editor2203 self.editor.select_below(&mut self.engine, id, local)?;
1972 .selection_at(point[0], point[1], SelectionUnit::Grapheme)?;2204 }
2205 let at = self.selection_at(id, point, SelectionUnit::Grapheme)?;
1973 self.editor.select(at)?;2206 self.editor.select(at)?;
1974 }2207 }
1975 None => self.place_caret(point)?,2208 None => self.place_caret(point)?,
...@@ -2118,8 +2351,7 @@ impl PageView {...@@ -2118,8 +2351,7 @@ impl PageView {
2118 | (_, Key::Named(NamedKey::Tab)) => return Ok(Response::default()),2351 | (_, Key::Named(NamedKey::Tab)) => return Ok(Response::default()),
2119 // Enter leaves the caret at the selection's end, as Right does.2352 // Enter leaves the caret at the selection's end, as Right does.
2120 (_, Key::Named(NamedKey::Enter)) => {2353 (_, Key::Named(NamedKey::Enter)) => {
2121 self.editor2354 self.move_selection(Movement::Right, false)?;
2122 .move_selection(&mut self.engine, Movement::Right, false)?;
2123 return self.edited();2355 return self.edited();
2124 }2356 }
2125 _ => {}2357 _ => {}
...@@ -2199,8 +2431,7 @@ impl PageView {...@@ -2199,8 +2431,7 @@ impl PageView {
2199 }2431 }
2200 let chord = Command::from_key(key, self.modifiers);2432 let chord = Command::from_key(key, self.modifiers);
2201 if let Some(Command::Move(movement)) = chord {2433 if let Some(Command::Move(movement)) = chord {
2202 self.editor2434 self.move_selection(movement, shift)?;
2203 .move_selection(&mut self.engine, movement, shift)?;
2204 } else if let Some(Command::ScrollPage { up }) = chord {2435 } else if let Some(Command::ScrollPage { up }) = chord {
2205 // AppKit keeps ten points of the last page in view.2436 // AppKit keeps ten points of the last page in view.
2206 let page = (self.viewport.size[1] as f32 - 10.0 * self.display_scale).max(0.0);2437 let page = (self.viewport.size[1] as f32 - 10.0 * self.display_scale).max(0.0);
...@@ -2306,13 +2537,14 @@ pub enum Hit {...@@ -2306,13 +2537,14 @@ pub enum Hit {
2306}2537}
23072538
2308/// What a document point lands on; `pixel` is document points per device pixel.2539/// 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(
2310 editor: &CanvasEditor,2542 editor: &CanvasEditor,
2311 scene: Option<&(PageScene, [f32; 2])>,2543 scene: Option<&(PageScene, [f32; 2])>,
2312 point: [f32; 2],2544 point: [f32; 2],
2313 pixel: f32,2545 pixel: f32,
2314) -> Option<Hit> {2546) -> Option<Hit> {
2315 page_hit(editor, scene, point, pixel, None)2547 page_hit(editor, scene, point, pixel, None, None)
2316}2548}
23172549
2318/// OneNote 2010's play button: a blue disc with a white triangle, in `rect`.2550/// OneNote 2010's play button: a blue disc with a white triangle, in `rect`.
...@@ -2342,7 +2574,57 @@ fn page_hit(...@@ -2342,7 +2574,57 @@ fn page_hit(
2342 point: [f32; 2],2574 point: [f32; 2],
2343 pixel: f32,2575 pixel: f32,
2344 touched: Option<onestore::ExGuid>,2576 touched: Option<onestore::ExGuid>,
2577 column: Option<&crate::reading::Column>,
2345) -> Option<Hit> {2578) -> 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 }
2346 /// Grips and text resolve front to back before any outline's padding, as a native2628 /// Grips and text resolve front to back before any outline's padding, as a native
2347 /// width handle stays reachable under the next outline's left padding; the typing room2629 /// width handle stays reachable under the next outline's left padding; the typing room
2348 /// below an outline comes last.2630 /// below an outline comes last.
...@@ -2548,6 +2830,7 @@ pub(crate) fn rule_primitives(...@@ -2548,6 +2830,7 @@ pub(crate) fn rule_primitives(
2548fn page_primitives<'a>(2830fn page_primitives<'a>(
2549 editor: &'a CanvasEditor,2831 editor: &'a CanvasEditor,
2550 scene: Option<&'a (PageScene, [f32; 2])>,2832 scene: Option<&'a (PageScene, [f32; 2])>,
2833 column: Option<&'a crate::reading::Column>,
2551 preview: Option<PointerFeedback<'a>>,2834 preview: Option<PointerFeedback<'a>>,
2552 object_focus: Option<ObjectFocus>,2835 object_focus: Option<ObjectFocus>,
2553 paint: Paint,2836 paint: Paint,
...@@ -2563,6 +2846,11 @@ fn page_primitives<'a>(...@@ -2563,6 +2846,11 @@ fn page_primitives<'a>(
2563 None => false,2846 None => false,
2564 };2847 };
2565 let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| {2848 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 };
2566 // An emptied page outline leaves the editor but keeps its paint slot for undo.2854 // An emptied page outline leaves the editor but keeps its paint slot for undo.
2567 let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else {2855 let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else {
2568 return Ok(());2856 return Ok(());
...@@ -2571,7 +2859,8 @@ fn page_primitives<'a>(...@@ -2571,7 +2859,8 @@ fn page_primitives<'a>(
2571 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,2859 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,
2572 _ => outline,2860 _ => outline,
2573 };2861 };
2574 let [x, y] = outline.origin();2862 let (geometry, displayed) = crate::reading::geometry(outline, column);
2863 let [x, y] = displayed;
2575 let origin = match preview {2864 let origin = match preview {
2576 Some(PointerFeedback::Move(id, [dx, dy]))2865 Some(PointerFeedback::Move(id, [dx, dy]))
2577 if id == outline.id || whole == Some(Whole::Page) && !outline.title =>2866 if id == outline.id || whole == Some(Whole::Page) && !outline.title =>
...@@ -2581,11 +2870,12 @@ fn page_primitives<'a>(...@@ -2581,11 +2870,12 @@ fn page_primitives<'a>(
2581 _ => [x, y],2870 _ => [x, y],
2582 };2871 };
2583 // OneNote frames the title whether or not it is focused or hovered.2872 // OneNote frames the title whether or not it is focused or hovered.
2584 if outline.title2873 if column.is_none()
2585 || selected(outline)2874 && (outline.title
2586 || (paint.chrome && object_focus.is_none() && outline.id == active)2875 || selected(outline)
2587 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)2876 || (paint.chrome && object_focus.is_none() && outline.id == active)
2588 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)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))
2589 {2879 {
2590 append_outline_chrome(2880 append_outline_chrome(
2591 outline,2881 outline,
...@@ -2596,13 +2886,27 @@ fn page_primitives<'a>(...@@ -2596,13 +2886,27 @@ fn page_primitives<'a>(
2596 primitives,2886 primitives,
2597 );2887 );
2598 }2888 }
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 }
2599 append_outline(2903 append_outline(
2600 (object_focus.is_none()2904 (object_focus.is_none()
2601 && outline.id == active2905 && outline.id == active
2602 && !matches!(preview, Some(PointerFeedback::Resize(_))))2906 && !matches!(preview, Some(PointerFeedback::Resize(_))))
2603 .then_some(editor),2907 .then_some(editor),
2604 selected(outline),2908 selected(outline),
2605 outline,2909 geometry,
2606 [origin[0] + offset[0], origin[1] + offset[1]],2910 [origin[0] + offset[0], origin[1] + offset[1]],
2607 paint,2911 paint,
2608 primitives,2912 primitives,
...@@ -2633,6 +2937,7 @@ fn page_primitives<'a>(...@@ -2633,6 +2937,7 @@ fn page_primitives<'a>(
2633 &mut primitives,2937 &mut primitives,
2634 *origin,2938 *origin,
2635 Some(editor),2939 Some(editor),
2940 column,
2636 moving,2941 moving,
2637 paint.colors.paper,2942 paint.colors.paper,
2638 &draw_outline,2943 &draw_outline,
...@@ -2650,8 +2955,16 @@ fn page_primitives<'a>(...@@ -2650,8 +2955,16 @@ fn page_primitives<'a>(
2650 append_outline(2955 append_outline(
2651 Some(editor),2956 Some(editor),
2652 false,2957 false,
2653 outline,2958 crate::reading::geometry(outline, column).0,
2654 outline.origin(),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 },
2655 paint,2968 paint,
2656 &mut primitives,2969 &mut primitives,
2657 )?;2970 )?;
...@@ -2926,14 +3239,7 @@ fn append_outline_chrome(...@@ -2926,14 +3239,7 @@ fn append_outline_chrome(
2926 let [left, top, right, bottom] = crate::translated(bounds, [dx, dy]);3239 let [left, top, right, bottom] = crate::translated(bounds, [dx, dy]);
2927 let body_top = body_top + dy;3240 let body_top = body_top + dy;
2928 if outline.title {3241 if outline.title {
2929 primitives.push(Primitive::RoundedRect {3242 append_title_frame([left, top, right, bottom], pixel, paper, primitives);
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 });
2937 return;3243 return;
2938 }3244 }
2939 primitives.push(Primitive::RoundedRect {3245 primitives.push(Primitive::RoundedRect {
...@@ -2991,16 +3297,33 @@ fn append_outline_chrome(...@@ -2991,16 +3297,33 @@ fn append_outline_chrome(
2991 }3297 }
2992}3298}
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
2994/// `editor` paints its selection and caret in the focused outline; `selected` highlights all3316/// `editor` paints its selection and caret in the focused outline; `selected` highlights all
2995/// the text of another.3317/// the text of another.
2996fn append_outline<'a>(3318fn append_outline<'a>(
2997 editor: Option<&'a CanvasEditor>,3319 editor: Option<&'a CanvasEditor>,
2998 selected: bool,3320 selected: bool,
2999 outline: &'a TextOutline,3321 geometry: crate::editor::OutlineGeometry<'a>,
3000 origin: [f32; 2],3322 origin: [f32; 2],
3001 paint: Paint,3323 paint: Paint,
3002 primitives: &mut Vec<Primitive<'a>>,3324 primitives: &mut Vec<Primitive<'a>>,
3003) -> Result<()> {3325) -> Result<()> {
3326 let outline = geometry.source;
3004 let [x, y] = origin;3327 let [x, y] = origin;
3005 let Paint {3328 let Paint {
3006 caret,3329 caret,
...@@ -3015,14 +3338,14 @@ fn append_outline<'a>(...@@ -3015,14 +3338,14 @@ fn append_outline<'a>(
3015 ..3338 ..
3016 } = paint;3339 } = paint;
3017 let rows = [visible[0] - y, visible[1] - y];3340 let rows = [visible[0] - y, visible[1] - y];
3018 outline3341 geometry
3019 .shaped()3342 .shaped
3020 .append_table_primitives(primitives, origin, colors.paper);3343 .append_table_primitives(primitives, origin, colors.paper);
3021 outline3344 geometry
3022 .shaped()3345 .shaped
3023 .append_block_tag_primitives(primitives, origin, paint.tag_art);3346 .append_block_tag_primitives(primitives, origin, paint.tag_art);
3024 outline3347 geometry
3025 .shaped()3348 .shaped
3026 .append_background_primitives(primitives, origin, rows, colors.paper);3349 .append_background_primitives(primitives, origin, rows, colors.paper);
3027 // OneNote's search highlight: yellow, deepened to gold under light text on dark paper.3350 // OneNote's search highlight: yellow, deepened to gold under light text on dark paper.
3028 let [red, green, blue, _] = colors.paper.color;3351 let [red, green, blue, _] = colors.paper.color;
...@@ -3032,7 +3355,7 @@ fn append_outline<'a>(...@@ -3032,7 +3355,7 @@ fn append_outline<'a>(
3032 draw::srgb(0xff, 0xe6, 0x00)3355 draw::srgb(0xff, 0xe6, 0x00)
3033 };3356 };
3034 for (_, selection) in found.iter().filter(|(id, _)| *id == outline.id) {3357 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)? {
3036 primitives.push(Primitive::Rect {3359 primitives.push(Primitive::Rect {
3037 rect: [3360 rect: [
3038 rect.x0 as f32 + x,3361 rect.x0 as f32 + x,
...@@ -3045,13 +3368,13 @@ fn append_outline<'a>(...@@ -3045,13 +3368,13 @@ fn append_outline<'a>(
3045 }3368 }
3046 }3369 }
3047 let mut highlight = match editor {3370 let mut highlight = match editor {
3048 Some(editor) => editor.selection_rects()?,3371 Some(editor) => geometry.range_rects(editor.selection())?,
3049 None if selected => outline.range_rects(outline.whole())?,3372 None if selected => geometry.range_rects(outline.whole())?,
3050 None => Vec::new(),3373 None => Vec::new(),
3051 };3374 };
3052 // OneNote highlights the note playing as it highlights a selection.3375 // OneNote highlights the note playing as it highlights a selection.
3053 if let Some((_, selection)) = played.filter(|(id, _)| *id == outline.id) {3376 if let Some((_, selection)) = played.filter(|(id, _)| *id == outline.id) {
3054 highlight.extend(outline.range_rects(*selection)?);3377 highlight.extend(geometry.range_rects(*selection)?);
3055 }3378 }
3056 for rect in highlight {3379 for rect in highlight {
3057 primitives.push(Primitive::Rect {3380 primitives.push(Primitive::Rect {
...@@ -3064,8 +3387,8 @@ fn append_outline<'a>(...@@ -3064,8 +3387,8 @@ fn append_outline<'a>(
3064 color: colors.selection,3387 color: colors.selection,
3065 });3388 });
3066 }3389 }
3067 for (index, paragraph) in outline.shaped().visible(rows) {3390 for (index, paragraph) in geometry.shaped.visible(rows) {
3068 outline.shaped().append_paragraph_primitives(3391 geometry.shaped.append_paragraph_primitives(
3069 index,3392 index,
3070 paragraph,3393 paragraph,
3071 origin,3394 origin,
...@@ -3081,7 +3404,7 @@ fn append_outline<'a>(...@@ -3081,7 +3404,7 @@ fn append_outline<'a>(
3081 .map(|(_, at)| at);3404 .map(|(_, at)| at);
3082 append_marks(3405 append_marks(
3083 spelling,3406 spelling,
3084 outline,3407 geometry,
3085 typing,3408 typing,
3086 origin,3409 origin,
3087 rows,3410 rows,
...@@ -3091,7 +3414,17 @@ fn append_outline<'a>(...@@ -3091,7 +3414,17 @@ fn append_outline<'a>(
3091 )?;3414 )?;
3092 }3415 }
3093 if let Some(editor) = editor {3416 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 {
3095 primitives.push(Primitive::Rect {3428 primitives.push(Primitive::Rect {
3096 rect: [3429 rect: [
3097 rect.x0 as f32 + x,3430 rect.x0 as f32 + x,
...@@ -3105,7 +3438,8 @@ fn append_outline<'a>(...@@ -3105,7 +3438,8 @@ fn append_outline<'a>(
3105 let [anchor, focus] = editor.selection().positions;3438 let [anchor, focus] = editor.selection().positions;
3106 if caret > 0.0 && anchor == focus {3439 if caret > 0.0 && anchor == focus {
3107 // AppKit centres the caret on the insertion point.3440 // 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)?;
3109 let half = edit::CARET_WIDTH / 2.0 * pixel;3443 let half = edit::CARET_WIDTH / 2.0 * pixel;
3110 let middle = rect.x0 as f32 + x;3444 let middle = rect.x0 as f32 + x;
3111 primitives.push(Primitive::RoundedRect {3445 primitives.push(Primitive::RoundedRect {
...@@ -3131,7 +3465,7 @@ fn append_outline<'a>(...@@ -3131,7 +3465,7 @@ fn append_outline<'a>(
3131#[allow(clippy::too_many_arguments)]3465#[allow(clippy::too_many_arguments)]
3132fn append_marks(3466fn append_marks(
3133 spelling: &crate::spelling::Spelling,3467 spelling: &crate::spelling::Spelling,
3134 outline: &TextOutline,3468 geometry: crate::editor::OutlineGeometry<'_>,
3135 typing: Option<crate::document::TextPosition>,3469 typing: Option<crate::document::TextPosition>,
3136 origin: [f32; 2],3470 origin: [f32; 2],
3137 rows: [f32; 2],3471 rows: [f32; 2],
...@@ -3139,9 +3473,10 @@ fn append_marks(...@@ -3139,9 +3473,10 @@ fn append_marks(
3139 pixel: f32,3473 pixel: f32,
3140 primitives: &mut Vec<Primitive<'_>>,3474 primitives: &mut Vec<Primitive<'_>>,
3141) -> Result<()> {3475) -> Result<()> {
3476 let outline = geometry.source;
3142 let color = draw::srgb(0xff, 0x00, 0x00);3477 let color = draw::srgb(0xff, 0x00, 0x00);
3143 let dot = (pixel * scale).round().max(1.0);3478 let dot = (pixel * scale).round().max(1.0);
3144 let shown = outline.layouts().filter(|(_, paragraph)| {3479 let shown = geometry.layouts().filter(|(_, paragraph)| {
3145 paragraph.origin[1] + paragraph.text.height() >= rows[0] && paragraph.origin[1] <= rows[1]3480 paragraph.origin[1] + paragraph.text.height() >= rows[0] && paragraph.origin[1] <= rows[1]
3146 });3481 });
3147 for (index, _) in shown {3482 for (index, _) in shown {
...@@ -3154,7 +3489,7 @@ fn append_marks(...@@ -3154,7 +3489,7 @@ fn append_marks(
3154 if typing.is_some_and(|at| at.paragraph == index && at.offset == end) {3489 if typing.is_some_and(|at| at.paragraph == index && at.offset == end) {
3155 continue;3490 continue;
3156 }3491 }
3157 for [left, right, baseline] in outline.underlines(index, start..end)? {3492 for [left, right, baseline] in geometry.underlines(index, start..end)? {
3158 let right = ((origin[0] + right) * scale + device_x).round();3493 let right = ((origin[0] + right) * scale + device_x).round();
3159 let mut x = ((origin[0] + left) * scale + device_x).round();3494 let mut x = ((origin[0] + left) * scale + device_x).round();
3160 let top = ((origin[1] + baseline) * scale + device_y).round() + dot;3495 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(...@@ -60,7 +60,7 @@ fn editing_cost(
60 let mut access = accessibility::Accessibility::default();60 let mut access = accessibility::Accessibility::default();
61 let update = timed(case, "ax_first", 0, || {61 let update = timed(case, "ax_first", 0, || {
62 access62 access
63 .update(editor, scene, viewport, "Profile", None, None)63 .update_with(editor, scene, viewport, "Profile", None, None, None)
64 .unwrap()64 .unwrap()
65 });65 });
66 eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len());66 eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len());
...@@ -107,6 +107,7 @@ fn editing_cost(...@@ -107,6 +107,7 @@ fn editing_cost(
107 scene,107 scene,
108 None,108 None,
109 None,109 None,
110 None,
110 super::tests::paint(true, viewport.scale, 1.0),111 super::tests::paint(true, viewport.scale, 1.0),
111 )112 )
112 .unwrap();113 .unwrap();
...@@ -117,7 +118,7 @@ fn editing_cost(...@@ -117,7 +118,7 @@ fn editing_cost(
117 timed(case, "ax_insert", sample, || {118 timed(case, "ax_insert", sample, || {
118 black_box(119 black_box(
119 access120 access
120 .update(editor, scene, viewport, "Profile", None, None)121 .update_with(editor, scene, viewport, "Profile", None, None, None)
121 .unwrap(),122 .unwrap(),
122 );123 );
123 });124 });
...@@ -125,7 +126,7 @@ fn editing_cost(...@@ -125,7 +126,7 @@ fn editing_cost(
125 timed(case, "ax_scroll", sample, || {126 timed(case, "ax_scroll", sample, || {
126 black_box(127 black_box(
127 access128 access
128 .update(editor, scene, viewport, "Profile", None, None)129 .update_with(editor, scene, viewport, "Profile", None, None, None)
129 .unwrap(),130 .unwrap(),
130 );131 );
131 });132 });
...@@ -136,7 +137,7 @@ fn editing_cost(...@@ -136,7 +137,7 @@ fn editing_cost(
136 timed(case, "ax_undo", sample, || {137 timed(case, "ax_undo", sample, || {
137 black_box(138 black_box(
138 access139 access
139 .update(editor, scene, viewport, "Profile", None, None)140 .update_with(editor, scene, viewport, "Profile", None, None, None)
140 .unwrap(),141 .unwrap(),
141 );142 );
142 });143 });
...@@ -188,6 +189,7 @@ fn canvas_pipeline_cost() {...@@ -188,6 +189,7 @@ fn canvas_pipeline_cost() {
188 Some(&scene),189 Some(&scene),
189 None,190 None,
190 None,191 None,
192 None,
191 super::tests::paint(false, 96.0 / 72.0, 1.0),193 super::tests::paint(false, 96.0 / 72.0, 1.0),
192 )194 )
193 .unwrap(),195 .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()...@@ -90,7 +90,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow()
90 .unwrap();90 .unwrap();
91 }91 }
92 let update = access92 let update = access
93 .update(&editor, Some(&scene), viewport, "Header", None, None)93 .update_with(&editor, Some(&scene), viewport, "Header", None, None, None)
94 .unwrap();94 .unwrap();
95 let buttons: Vec<_> =95 let buttons: Vec<_> =
96 accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update))96 accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update))
...@@ -177,7 +177,8 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {...@@ -177,7 +177,8 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
177 matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id)177 matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id)
178 );178 );
179 }179 }
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();
181 let borders = primitives182 let borders = primitives
182 .iter()183 .iter()
183 .filter_map(|p| match p {184 .filter_map(|p| match p {
...@@ -189,7 +190,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {...@@ -189,7 +190,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
189 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])));190 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])));
190 let mut access = accessibility::Accessibility::default();191 let mut access = accessibility::Accessibility::default();
191 let update = access192 let update = access
192 .update(193 .update_with(
193 &editor,194 &editor,
194 None,195 None,
195 Viewport {196 Viewport {
...@@ -200,6 +201,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {...@@ -200,6 +201,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
200 "Header",201 "Header",
201 None,202 None,
202 None,203 None,
204 None,
203 )205 )
204 .unwrap();206 .unwrap();
205 assert!(update.nodes.iter().any(|(_, node)| node.role()207 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() {...@@ -232,7 +234,7 @@ fn title_frame_shows_unfocused_and_its_pen_scales_only_when_zoomed_in() {
232 for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] {234 for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] {
233 let scale = zoom * 96.0 / 72.0;235 let scale = zoom * 96.0 / 72.0;
234 let primitives =236 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();
236 let dashed = primitives238 let dashed = primitives
237 .iter()239 .iter()
238 .filter_map(|primitive| match primitive {240 .filter_map(|primitive| match primitive {
...@@ -265,7 +267,8 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {...@@ -265,7 +267,8 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
265 .move_selection(&mut engine, Movement::Down, false)267 .move_selection(&mut engine, Movement::Down, false)
266 .unwrap();268 .unwrap();
267 }269 }
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();
269 assert_eq!(272 assert_eq!(
270 primitives273 primitives
271 .iter()274 .iter()
...@@ -279,7 +282,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {...@@ -279,7 +282,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
279 );282 );
280 let mut access = accessibility::Accessibility::default();283 let mut access = accessibility::Accessibility::default();
281 let update = access284 let update = access
282 .update(285 .update_with(
283 &editor,286 &editor,
284 None,287 None,
285 Viewport {288 Viewport {
...@@ -290,6 +293,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {...@@ -290,6 +293,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
290 "Test",293 "Test",
291 None,294 None,
292 None,295 None,
296 None,
293 )297 )
294 .unwrap();298 .unwrap();
295 let fields = update299 let fields = update
...@@ -334,21 +338,28 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {...@@ -334,21 +338,28 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
334 origin: [48.0; 2],338 origin: [48.0; 2],
335 };339 };
336 let initial = access340 let initial = access
337 .update(&editor, None, viewport, "Page", None, None)341 .update_with(&editor, None, viewport, "Page", None, None, None)
338 .unwrap();342 .unwrap();
339 let id = editor.active_outline().id;343 let id = editor.active_outline().id;
340 assert_eq!(access.outline_for_node(initial.focus), Some(id));344 assert_eq!(access.outline_for_node(initial.focus), Some(id));
341 let rectangles = |editor: &CanvasEditor| {345 let rectangles = |editor: &CanvasEditor| {
342 page_primitives(editor, None, None, None, paint(true, viewport.scale, 1.0))346 page_primitives(
343 .unwrap()347 editor,
344 .into_iter()348 None,
345 .filter_map(|p| match p {349 None,
346 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {350 None,
347 Some((rect, color))351 None,
348 }352 paint(true, viewport.scale, 1.0),
349 _ => None,353 )
350 })354 .unwrap()
351 .collect::<Vec<_>>()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<_>>()
352 };363 };
353 assert_eq!(rectangles(&editor).len(), 1);364 assert_eq!(rectangles(&editor).len(), 1);
354 assert_eq!(365 assert_eq!(
...@@ -363,12 +374,12 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {...@@ -363,12 +374,12 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
363 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret)374 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret)
364 );375 );
365 let preedit = access376 let preedit = access
366 .update(&editor, None, viewport, "Page", None, None)377 .update_with(&editor, None, viewport, "Page", None, None, None)
367 .unwrap();378 .unwrap();
368 assert_eq!(preedit.focus, initial.focus);379 assert_eq!(preedit.focus, initial.focus);
369 editor.commit_text(&mut engine, "日本".into()).unwrap();380 editor.commit_text(&mut engine, "日本".into()).unwrap();
370 let committed = access381 let committed = access
371 .update(&editor, None, viewport, "Page", None, None)382 .update_with(&editor, None, viewport, "Page", None, None, None)
372 .unwrap();383 .unwrap();
373 assert_eq!(committed.focus, initial.focus);384 assert_eq!(committed.focus, initial.focus);
374 assert!(rectangles(&editor).len() > 1);385 assert!(rectangles(&editor).len() > 1);
...@@ -376,7 +387,7 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {...@@ -376,7 +387,7 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
376 assert!(editor.outlines().is_empty());387 assert!(editor.outlines().is_empty());
377 assert_eq!(rectangles(&editor).len(), 1);388 assert_eq!(rectangles(&editor).len(), 1);
378 let empty = access389 let empty = access
379 .update(&editor, None, viewport, "Page", None, None)390 .update_with(&editor, None, viewport, "Page", None, None, None)
380 .unwrap();391 .unwrap();
381 assert_eq!(empty.focus, committed.focus);392 assert_eq!(empty.focus, committed.focus);
382 assert_eq!(access.outline_for_node(committed.focus), Some(id));393 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() {...@@ -386,14 +397,14 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
386 assert!(editor.outlines().is_empty());397 assert!(editor.outlines().is_empty());
387 assert_eq!(rectangles(&editor).len(), 1);398 assert_eq!(rectangles(&editor).len(), 1);
388 let retired = access399 let retired = access
389 .update(&editor, None, viewport, "Page", None, None)400 .update_with(&editor, None, viewport, "Page", None, None, None)
390 .unwrap();401 .unwrap();
391 assert_eq!(retired.focus, committed.focus);402 assert_eq!(retired.focus, committed.focus);
392 assert_eq!(access.outline_for_node(retired.focus), Some(id));403 assert_eq!(access.outline_for_node(retired.focus), Some(id));
393 editor.undo(&mut engine).unwrap();404 editor.undo(&mut engine).unwrap();
394 assert!(rectangles(&editor).len() > 1);405 assert!(rectangles(&editor).len() > 1);
395 let restored = access406 let restored = access
396 .update(&editor, None, viewport, "Page", None, None)407 .update_with(&editor, None, viewport, "Page", None, None, None)
397 .unwrap();408 .unwrap();
398 assert_eq!(restored.focus, committed.focus);409 assert_eq!(restored.focus, committed.focus);
399 editor.redo(&mut engine).unwrap();410 editor.redo(&mut engine).unwrap();
...@@ -463,6 +474,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {...@@ -463,6 +474,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
463 Some(&scene),474 Some(&scene),
464 None,475 None,
465 None,476 None,
477 None,
466 paint(true, viewport.scale, 1.0),478 paint(true, viewport.scale, 1.0),
467 )479 )
468 .unwrap()480 .unwrap()
...@@ -493,7 +505,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {...@@ -493,7 +505,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
493 )505 )
494 .unwrap();506 .unwrap();
495 let before = access507 let before = access
496 .update(&editor, None, viewport, "Test", None, None)508 .update_with(&editor, None, viewport, "Test", None, None, None)
497 .unwrap();509 .unwrap();
498 let before = accessibility::tests::apply(&mut tree, before)510 let before = accessibility::tests::apply(&mut tree, before)
499 .focus()511 .focus()
...@@ -503,7 +515,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {...@@ -503,7 +515,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
503 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();515 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();
504 {516 {
505 let update = access517 let update = access
506 .update(&editor, None, viewport, "Test", None, None)518 .update_with(&editor, None, viewport, "Test", None, None, None)
507 .unwrap();519 .unwrap();
508 assert_eq!(520 assert_eq!(
509 accessibility::tests::apply(&mut tree, update)521 accessibility::tests::apply(&mut tree, update)
...@@ -518,6 +530,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {...@@ -518,6 +530,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
518 Some(&scene),530 Some(&scene),
519 None,531 None,
520 None,532 None,
533 None,
521 paint(true, viewport.scale, 1.0),534 paint(true, viewport.scale, 1.0),
522 )535 )
523 .unwrap();536 .unwrap();
...@@ -532,7 +545,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {...@@ -532,7 +545,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() {
532 assert_eq!(editor.active_outline().document(), &original);545 assert_eq!(editor.active_outline().document(), &original);
533 assert_eq!(tags(&editor), original_tags);546 assert_eq!(tags(&editor), original_tags);
534 let update = access547 let update = access
535 .update(&editor, None, viewport, "Test", None, None)548 .update_with(&editor, None, viewport, "Test", None, None, None)
536 .unwrap();549 .unwrap();
537 assert_eq!(550 assert_eq!(
538 accessibility::tests::apply(&mut tree, update)551 accessibility::tests::apply(&mut tree, update)
...@@ -622,7 +635,7 @@ fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {...@@ -622,7 +635,7 @@ fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
622 let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap();635 let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap();
623 let second = editor.outlines()[1].id;636 let second = editor.outlines()[1].id;
624 let borders = |feedback| {637 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))
626 .unwrap()639 .unwrap()
627 .into_iter()640 .into_iter()
628 .filter_map(|p| match p {641 .filter_map(|p| match p {
...@@ -900,7 +913,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {...@@ -900,7 +913,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
900 append_outline(913 append_outline(
901 Some(&editor),914 Some(&editor),
902 false,915 false,
903 editor.active_outline(),916 crate::reading::geometry(editor.active_outline(), None).0,
904 [24.0, 48.0],917 [24.0, 48.0],
905 Paint {918 Paint {
906 caret: 0.0,919 caret: 0.0,
...@@ -978,7 +991,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {...@@ -978,7 +991,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
978 append_outline(991 append_outline(
979 Some(&editor),992 Some(&editor),
980 false,993 false,
981 editor.active_outline(),994 crate::reading::geometry(editor.active_outline(), None).0,
982 [24.0, 48.0],995 [24.0, 48.0],
983 Paint {996 Paint {
984 caret: 0.0,997 caret: 0.0,
...@@ -1047,6 +1060,7 @@ fn resize_preview_paints_reflowed_text_without_changing_the_editor() {...@@ -1047,6 +1060,7 @@ fn resize_preview_paints_reflowed_text_without_changing_the_editor() {
1047 let primitives = page_primitives(1060 let primitives = page_primitives(
1048 &editor,1061 &editor,
1049 None,1062 None,
1063 None,
1050 Some(PointerFeedback::Resize(&resized)),1064 Some(PointerFeedback::Resize(&resized)),
1051 None,1065 None,
1052 paint(false, 96.0 / 72.0, 1.0),1066 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...@@ -1141,7 +1155,7 @@ fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and
1141 None1155 None
1142 );1156 );
1143 let primitives =1157 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();
1145 assert!(1159 assert!(
1146 matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])1160 matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])
1147 );1161 );
...@@ -1266,15 +1280,21 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {...@@ -1266,15 +1280,21 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
1266 .11280 .1
1267 .text1281 .text
1268 .id();1282 .id();
1269 let texts: Vec<_> =1283 let texts: Vec<_> = page_primitives(
1270 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0))1284 &editor,
1271 .unwrap()1285 Some(&scene),
1272 .into_iter()1286 None,
1273 .filter_map(|p| match p {1287 None,
1274 Primitive::Text { text, .. } => Some(painted_layout(text).id()),1288 None,
1275 _ => None,1289 paint(false, 1.0, 1.0),
1276 })1290 )
1277 .collect();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();
1278 assert_eq!(texts.len(), 2);1298 assert_eq!(texts.len(), 2);
1279 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);1299 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);
1280 let expected = if readonly_on_top {1300 let expected = if readonly_on_top {
...@@ -1318,15 +1338,21 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {...@@ -1318,15 +1338,21 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
1318 grab: [10.0, -8.0]1338 grab: [10.0, -8.0]
1319 })1339 })
1320 );1340 );
1321 let texts: Vec<_> =1341 let texts: Vec<_> = page_primitives(
1322 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0))1342 &editor,
1323 .unwrap()1343 Some(&scene),
1324 .into_iter()1344 None,
1325 .filter_map(|p| match p {1345 None,
1326 Primitive::Text { text, .. } => Some(painted_layout(text).id()),1346 None,
1327 _ => None,1347 paint(false, 1.0, 1.0),
1328 })1348 )
1329 .collect();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();
1330 assert_eq!(1356 assert_eq!(
1331 *texts.last().unwrap(),1357 *texts.last().unwrap(),
1332 editor1358 editor
...@@ -1480,7 +1506,15 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {...@@ -1480,7 +1506,15 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1480 };1506 };
1481 for scale in [1.0, 2.0] {1507 for scale in [1.0, 2.0] {
1482 let text = rectangles(1508 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(),
1484 );1518 );
1485 assert!(text.iter().any(|(_, color)| *color == COLORS.caret));1519 assert!(text.iter().any(|(_, color)| *color == COLORS.caret));
1486 let selected = rectangles(1520 let selected = rectangles(
...@@ -1488,6 +1522,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {...@@ -1488,6 +1522,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1488 &editor,1522 &editor,
1489 Some(&scene),1523 Some(&scene),
1490 None,1524 None,
1525 None,
1491 Some(ObjectFocus::ReadOnly(0)),1526 Some(ObjectFocus::ReadOnly(0)),
1492 paint(true, scale, 1.0),1527 paint(true, scale, 1.0),
1493 )1528 )
...@@ -1501,6 +1536,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {...@@ -1501,6 +1536,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1501 &editor,1536 &editor,
1502 Some(&scene),1537 Some(&scene),
1503 None,1538 None,
1539 None,
1504 Some(ObjectFocus::ReadOnly(0)),1540 Some(ObjectFocus::ReadOnly(0)),
1505 paint(false, scale, 1.0)1541 paint(false, scale, 1.0)
1506 )1542 )
...@@ -1514,7 +1550,15 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {...@@ -1514,7 +1550,15 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1514 }1550 }
1515 editor.select_all().unwrap();1551 editor.select_all().unwrap();
1516 let text = rectangles(1552 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(),
1518 );1562 );
1519 assert!(text.iter().any(|(_, color)| *color == COLORS.selection));1563 assert!(text.iter().any(|(_, color)| *color == COLORS.selection));
1520 let selected = rectangles(1564 let selected = rectangles(
...@@ -1522,6 +1566,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {...@@ -1522,6 +1566,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1522 &editor,1566 &editor,
1523 Some(&scene),1567 Some(&scene),
1524 None,1568 None,
1569 None,
1525 Some(ObjectFocus::ReadOnly(0)),1570 Some(ObjectFocus::ReadOnly(0)),
1526 paint(true, 1.0, 1.0),1571 paint(true, 1.0, 1.0),
1527 )1572 )
...@@ -3639,3 +3684,589 @@ fn copied(response: Response) -> Option<String> {...@@ -3639,3 +3684,589 @@ fn copied(response: Response) -> Option<String> {
3639 _ => None,3684 _ => None,
3640 }3685 }
3641}3686}
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 @@...@@ -9,7 +9,7 @@
9//! it. Text rewraps to the column, pictures shrink to it, and everything else keeps its size.9//! it. Text rewraps to the column, pictures shrink to it, and everything else keeps its size.
1010
11use crate::{11use crate::{
12 editor::{CanvasEditor, EditorError, TextOutline, page::Content},12 editor::{CanvasEditor, EditorError},
13 layout::TextEngine,13 layout::TextEngine,
14};14};
15use onestore::{15use onestore::{
...@@ -18,6 +18,184 @@ use onestore::{...@@ -18,6 +18,184 @@ use onestore::{
18};18};
19use std::{collections::BTreeMap, ops::Range};19use 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
21/// Points between blocks in the column: OneNote's grid row.199/// Points between blocks in the column: OneNote's grid row.
22const GAP: f32 = 18.0;200const GAP: f32 = 18.0;
23/// Strokes this near one another are one drawing, as handwriting is many strokes.201/// 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...@@ -29,4 +29,5 @@ objc2-foundation = { version = "0.2.2", default-features = false, features = ["s
2929
30[dev-dependencies]30[dev-dependencies]
31relay = { path = "../relay" }31relay = { path = "../relay" }
32rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] }
32tempfile = "3"33tempfile = "3"
crates/mobile/include/snowbound.h+5-2
...@@ -100,8 +100,11 @@ bool sb_view_frame_pending(const View *view);...@@ -100,8 +100,11 @@ bool sb_view_frame_pending(const View *view);
100void sb_view_set_dark(View *view, bool dark);100void sb_view_set_dark(View *view, bool dark);
101bool sb_view_read_only(const View *view);101bool sb_view_read_only(const View *view);
102char *sb_view_reading(const View *view, float width);102char *sb_view_reading(const View *view, float width);
103bool sb_view_set_reading(View *view, float width);103// Preserves the nearest source caret's host offset from the page-point anchor at scale 1.
104bool sb_view_reload(View *view, bool discard);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]);
105void sb_view_focus(View *view, bool focused);108void sb_view_focus(View *view, bool focused);
106void sb_view_content(View *view, float bounds[4]);109void sb_view_content(View *view, float bounds[4]);
107bool sb_view_block(const View *view, float x, float y, float rect[4]);110bool 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::{...@@ -33,7 +33,7 @@ use onestore::{
33 op::{Edit, Op, PageOp},33 op::{Edit, Op, PageOp},
34 page::{Page, ink::ShapeKind},34 page::{Page, ink::ShapeKind},
35};35};
36use parley::{Affinity, BoundingBox};36use parley::Affinity;
37use std::{37use std::{
38 error::Error,38 error::Error,
39 ffi::{CStr, CString, c_char, c_void},39 ffi::{CStr, CString, c_char, c_void},
...@@ -228,14 +228,6 @@ impl Canvas {...@@ -228,14 +228,6 @@ impl Canvas {
228 self.page.viewport.origin = corner.map(|value| -value * self.scale * zoom);228 self.page.viewport.origin = corner.map(|value| -value * self.scale * zoom);
229 }229 }
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
239 fn target(&self, point: [f32; 2]) -> Target {231 fn target(&self, point: [f32; 2]) -> Target {
240 let device = self.device(point);232 let device = self.device(point);
241 let [x, y] = self.page.viewport.document_point(device);233 let [x, y] = self.page.viewport.document_point(device);
...@@ -254,10 +246,13 @@ impl Canvas {...@@ -254,10 +246,13 @@ impl Canvas {
254 {246 {
255 Target::Grip247 Target::Grip
256 }248 }
257 Some(Hit::Text { id, point })249 Some(Hit::Text { id, .. })
258 if !self250 if self
259 .outline(id)251 .page
260 .is_some_and(|outline| outline.contains_extension(point)) =>252 .text_block([x, y])
253 .is_some_and(|[x0, y0, x1, y1]| {
254 (x0..=x1).contains(&x) && (y0..=y1).contains(&y)
255 }) =>
261 {256 {
262 if id == self.page.editor.active_outline().id {257 if id == self.page.editor.active_outline().id {
263 Target::ActiveText258 Target::ActiveText
...@@ -301,25 +296,11 @@ impl Canvas {...@@ -301,25 +296,11 @@ impl Canvas {
301 Ok(changed)296 Ok(changed)
302 }297 }
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.
305 fn block(&self, point: [f32; 2]) -> Option<[f32; 4]> {300 fn block(&self, point: [f32; 2]) -> Option<[f32; 4]> {
306 let [x, y] = self301 self.page
307 .page302 .text_block(self.page.viewport.document_point(self.device(point)))
308 .viewport303 .map(|rect| rect.map(|value| value * POINT))
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))
323 }304 }
324305
325 fn active(&self) -> &TextOutline {306 fn active(&self) -> &TextOutline {
...@@ -562,14 +543,6 @@ impl Canvas {...@@ -562,14 +543,6 @@ impl Canvas {
562 .map(|rect| self.document_rect(rect))543 .map(|rect| self.document_rect(rect))
563 }544 }
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
573 /// Page rectangle `[x0, y0, x1, y1]` in the view's points as `[x, y, width, height]`.546 /// Page rectangle `[x0, y0, x1, y1]` in the view's points as `[x, y, width, height]`.
574 fn document_rect(&self, [x0, y0, x1, y1]: [f32; 4]) -> [f32; 4] {547 fn document_rect(&self, [x0, y0, x1, y1]: [f32; 4]) -> [f32; 4] {
575 let viewport = self.page.viewport;548 let viewport = self.page.viewport;
...@@ -583,30 +556,33 @@ impl Canvas {...@@ -583,30 +556,33 @@ impl Canvas {
583556
584 fn caret_rect(&self, offset: u32) -> Result<[f32; 4]> {557 fn caret_rect(&self, offset: u32) -> Result<[f32; 4]> {
585 let position = self.active().utf16_position(offset)?;558 let position = self.active().utf16_position(offset)?;
586 let caret = self559 let rect = self
587 .page560 .page
588 .editor561 .caret_at(self.active().id, position, Affinity::Downstream, 1.0)?;
589 .caret_at(position, Affinity::Downstream, 1.0)?;562 Ok(self.document_rect([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32)))
590 Ok(self.view_rect(caret))
591 }563 }
592564
593 /// The selection rectangles of a range, one or more per line.
594 fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> {565 fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> {
595 let rects = self.active().range_rects(Selection {566 let rects = self.page.range_rects(
596 positions: self.positions(range)?,567 self.active().id,
597 affinities: [Affinity::Downstream, Affinity::Upstream],568 Selection {
598 })?;569 positions: self.positions(range)?,
599 Ok(rects.into_iter().map(|rect| self.view_rect(rect)).collect())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())
600 }579 }
601580
602 fn closest(&self, point: [f32; 2]) -> Result<u32> {581 fn closest(&self, point: [f32; 2]) -> Result<u32> {
603 let point = self.page.viewport.document_point(self.device(point));582 let point = self.page.viewport.document_point(self.device(point));
604 let origin = self.active().origin();583 let selection = self
605 let selection = self.page.editor.selection_at(584 .page
606 point[0] - origin[0],585 .selection_at(self.active().id, point, SelectionUnit::Grapheme)?;
607 point[1] - origin[1],
608 SelectionUnit::Grapheme,
609 )?;
610 Ok(self.active().utf16_offset(selection.positions[1])?)586 Ok(self.active().utf16_offset(selection.positions[1])?)
611 }587 }
612}588}
...@@ -803,6 +779,44 @@ impl Presentation {...@@ -803,6 +779,44 @@ impl Presentation {
803 }779 }
804}780}
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
806pub struct View {820pub struct View {
807 /// Taken apart on drop, which returns its text engine to `ENGINES`.821 /// Taken apart on drop, which returns its text engine to `ENGINES`.
808 canvas: ManuallyDrop<Canvas>,822 canvas: ManuallyDrop<Canvas>,
...@@ -811,8 +825,9 @@ pub struct View {...@@ -811,8 +825,9 @@ pub struct View {
811 section: Arc<Shared>,825 section: Arc<Shared>,
812 /// A conflict page, which shows what is stored and takes no edits.826 /// A conflict page, which shows what is stored and takes no edits.
813 read_only: bool,827 read_only: bool,
814 /// The column, in page points, a reading view reflows the page into; it takes no edits.828 reload_pending: bool,
815 reading: Option<f32>,829 /// Original applies stay counted across an explicit discard.
830 local_edits: Arc<Mutex<LocalEdits>>,
816}831}
817832
818impl View {833impl View {
...@@ -829,11 +844,17 @@ impl View {...@@ -829,11 +844,17 @@ impl View {
829 // SAFETY: the caller supplies the platform window's lifetime contract.844 // SAFETY: the caller supplies the platform window's lifetime contract.
830 let presentation = unsafe { Presentation::new(window, pixels) }?;845 let presentation = unsafe { Presentation::new(window, pixels) }?;
831 let (mut page, read_only) = section.shared.page(space)?;846 let (mut page, read_only) = section.shared.page(space)?;
847 let local_edits = Arc::new(Mutex::new(LocalEdits::default()));
832 // The page wears its theme: style objects it gives otherwise are restyled first.848 // The page wears its theme: style objects it gives otherwise are restyled first.
833 let sheet = section.sheet(page.identity);849 let sheet = section.sheet(page.identity);
834 let restyle = onestore::op::restyle(&page, &sheet)?;850 let restyle = onestore::op::restyle(&page, &sheet)?;
835 if !read_only && !restyle.is_empty() {851 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 })?;
837 page = section.shared.page(space)?.0;858 page = section.shared.page(space)?.0;
838 }859 }
839 let frame = Arc::<Frame>::default();860 let frame = Arc::<Frame>::default();
...@@ -852,7 +873,8 @@ impl View {...@@ -852,7 +873,8 @@ impl View {
852 frame,873 frame,
853 section: Arc::clone(&section.shared),874 section: Arc::clone(&section.shared),
854 read_only,875 read_only,
855 reading: None,876 reload_pending: false,
877 local_edits,
856 })878 })
857 }879 }
858880
...@@ -901,10 +923,9 @@ impl View {...@@ -901,10 +923,9 @@ impl View {
901 /// conflict page returns to what is stored instead.923 /// conflict page returns to what is stored instead.
902 fn stored(&mut self, result: Result<bool>) -> bool {924 fn stored(&mut self, result: Result<bool>) -> bool {
903 let changed = report(result).unwrap_or(false);925 let changed = report(result).unwrap_or(false);
904 match report(self.canvas.edit()) {926 self.canvas.page.reconcile_reading();
905 Some(Some(_)) if self.read_only || self.reading.is_some() => {927 let submitted = match report(self.canvas.edit()) {
906 let _ = report(self.reload());928 Some(Some(_)) if self.read_only => report(self.reload(true)).is_some(),
907 }
908 Some(Some(edit)) => {929 Some(Some(edit)) => {
909 // Pictures that came or went, by insertion or undo, are drawn or dropped.930 // Pictures that came or went, by insertion or undo, are drawn or dropped.
910 if edit.ops.iter().any(|op| {931 if edit.ops.iter().any(|op| {
...@@ -922,53 +943,128 @@ impl View {...@@ -922,53 +943,128 @@ impl View {
922 let _ = report(refreshed.map_err(Into::into));943 let _ = report(refreshed.map_err(Into::into));
923 }944 }
924 }945 }
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 }
926 }1035 }
927 _ => {}
928 }1036 }
929 changed1037 result
930 }1038 }
9311039
932 /// Shows the page as stored, in place: the caret, selection and drawn pictures stay.1040 /// Defers automatic replacement until marking ends and original local replies succeed.
933 /// Waits for marked text to end.1041 fn reload(&mut self, discard: bool) -> Result<bool> {
934 fn reload(&mut self) -> Result<bool> {1042 self.reload_pending = true;
935 if self.canvas.page.editor.marked_range().is_some() {1043 if self.canvas.page.editor.marked_range().is_some()
1044 || (!discard && !sb_view_reload_ready(self))
1045 {
936 return Ok(false);1046 return Ok(false);
937 }1047 }
938 let (page, read_only) = self.section.page(self.canvas.space)?;1048 // Reset before reading so an outstanding reply's failure cannot be cleared afterward.
939 self.read_only = read_only;1049 let failed = discard
940 if let Some(column) = self.reading {1050 && self
941 return self.show(page, Some(column));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;
942 }1066 }
943 Ok(moved(self.canvas.page.refresh(page)?))1067 result
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())
972 }1068 }
973}1069}
9741070
...@@ -1105,49 +1201,58 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) {...@@ -1105,49 +1201,58 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) {
1105 view.canvas.set_dark(dark);1201 view.canvas.set_dark(dark);
1106}1202}
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.
1109#[unsafe(no_mangle)]1205#[unsafe(no_mangle)]
1110pub extern "C" fn sb_view_read_only(view: &View) -> bool {1206pub extern "C" fn sb_view_read_only(view: &View) -> bool {
1111 view.read_only || view.reading.is_some()1207 view.read_only
1112}1208}
11131209
1114/// Whether the page has a reading view `width` points wide, as JSON: `offered`, and the1210/// Reading eligibility and active projection as JSON: `offered`, `shown`, and `verdict`.
1115/// `verdict` saying how it reflows or why not.
1116#[unsafe(no_mangle)]1211#[unsafe(no_mangle)]
1117pub extern "C" fn sb_view_reading(view: &View, width: f32) -> *mut c_char {1212pub 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, _)| {1213 let (offered, shown) = view.canvas.page.reading_status(width / POINT);
1119 let mut engine = pooled_engine();1214 let json = serde_json::json!({ "offered": offered, "shown": shown, "verdict": if offered { "Reflows" } else { "Refused(Unsupported)" } });
1120 let read = canvas::reading::read(&page, width / POINT, &mut engine);1215 owned(json.to_string())
1121 pool_engine(engine);1216}
1122 Ok(read?.verdict)1217
1123 });1218/// Shows editable text reflowed into a column `width` points wide, or with 0 as laid
1124 report(result).map_or(std::ptr::null_mut(), |verdict| {1219/// out; whether it now shows reflowed. Moves the page-point viewport `anchor` so its
1125 let json =1220/// nearest source caret keeps its relative host offset at scale 1; unchanged without text.
1126 serde_json::json!({ "offered": verdict.offered(), "verdict": format!("{verdict:?}") });1221#[unsafe(no_mangle)]
1127 owned(json.to_string())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))
1128 })1227 })
1129}1228}
11301229
1131/// Shows the page reflowed into a column `width` points wide, read-only, or with 0 as laid1230/// Whether an existing deferred reload can read without replacing unconfirmed local edits.
1132/// out; whether it now shows reflowed.
1133#[unsafe(no_mangle)]1231#[unsafe(no_mangle)]
1134pub extern "C" fn sb_view_set_reading(view: &mut View, width: f32) -> bool {1232pub extern "C" fn sb_view_reload_ready(view: &View) -> bool {
1135 let column = (width > 0.0).then_some(width / POINT);1233 view.reload_pending
1136 let shown = view1234 && view.canvas.page.editor.marked_range().is_none()
1137 .section1235 && view
1138 .page(view.canvas.space)1236 .local_edits
1139 .and_then(|(page, _)| view.show(page, column));1237 .lock()
1140 report(shown).unwrap_or(false)1238 .is_ok_and(|edits| edits.pending == 0 && !edits.failed)
1141}1239}
11421240
1143/// Shows the page as stored after a change made elsewhere, keeping the caret; with1241/// Shows the page as stored after a change made elsewhere, keeping the caret; with
1144/// `discard`, after the section refused an edit, drops what the page took since.1242/// `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.
1145#[unsafe(no_mangle)]1244#[unsafe(no_mangle)]
1146pub extern "C" fn sb_view_reload(view: &mut View, discard: bool) -> bool {1245pub extern "C" fn sb_view_reload(
1147 if discard {1246 view: &mut View,
1148 let _ = view.canvas.page.editor.take_ops();1247 discard: bool,
1149 }1248 anchor: Option<&mut [f32; 2]>,
1150 report(view.reload()).unwrap_or(false)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 })
1151}1256}
11521257
1153/// Whether the view has input focus: the focused outline shows its frame and grips only then.1258/// 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) {...@@ -1212,7 +1317,11 @@ pub extern "C" fn sb_view_pressure(view: &mut View, pressure: f32) {
12121317
1213#[unsafe(no_mangle)]1318#[unsafe(no_mangle)]
1214pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool {1319pub 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)
1216}1325}
12171326
1218#[unsafe(no_mangle)]1327#[unsafe(no_mangle)]
...@@ -1350,7 +1459,8 @@ pub extern "C" fn sb_view_title(view: &View) -> *mut c_char {...@@ -1350,7 +1459,8 @@ pub extern "C" fn sb_view_title(view: &View) -> *mut c_char {
1350/// Puts the caret at the end of the page title; false on a page without one.1459/// Puts the caret at the end of the page title; false on a page without one.
1351#[unsafe(no_mangle)]1460#[unsafe(no_mangle)]
1352pub extern "C" fn sb_view_focus_title(view: &mut View) -> bool {1461pub 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)
1354}1464}
13551465
1356/// Selects the first place `query` occurs on the page, ignoring case.1466/// 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 {...@@ -1359,7 +1469,8 @@ pub extern "C" fn sb_view_focus_title(view: &mut View) -> bool {
1359/// `query` is NUL-terminated UTF-8.1469/// `query` is NUL-terminated UTF-8.
1360#[unsafe(no_mangle)]1470#[unsafe(no_mangle)]
1361pub unsafe extern "C" fn sb_view_find(view: &mut View, query: *const c_char) -> bool {1471pub 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)
1363}1474}
13641475
1365/// Selects paragraph `id` of the page; false where the page has none.1476/// 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_...@@ -1372,7 +1483,7 @@ pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_
1372 .parse()1483 .parse()
1373 .map_err(Into::into)1484 .map_err(Into::into)
1374 .and_then(|id| view.canvas.select_paragraph(id));1485 .and_then(|id| view.canvas.select_paragraph(id));
1375 report(result).unwrap_or(false)1486 view.stored(result)
1376}1487}
13771488
1378/// Copy, or Cut with `cut`: the selected text for the clipboard, a page selection's1489/// 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]) {...@@ -1664,20 +1775,24 @@ pub extern "C" fn sb_selection(view: &View, range: &mut [u32; 2]) {
16641775
1665#[unsafe(no_mangle)]1776#[unsafe(no_mangle)]
1666pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool {1777pub 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
1668}1782}
16691783
1670/// Widens the selection as OneNote's Ctrl+A does, one enclosing unit per call.1784/// Widens the selection as OneNote's Ctrl+A does, one enclosing unit per call.
1671#[unsafe(no_mangle)]1785#[unsafe(no_mangle)]
1672pub extern "C" fn sb_select_more(view: &mut View) -> bool {1786pub extern "C" fn sb_select_more(view: &mut View) -> bool {
1673 report(1787 let result = view
1674 view.canvas1788 .canvas
1675 .page1789 .page
1676 .editor1790 .editor
1677 .widen_selection()1791 .widen_selection()
1678 .map_err(Into::into),1792 .map_err(Into::into);
1679 )1793 let success = result.is_ok();
1680 .is_some()1794 view.stored(result.map(|()| false));
1795 success
1681}1796}
16821797
1683#[unsafe(no_mangle)]1798#[unsafe(no_mangle)]
crates/mobile/src/library.rs+13-1
...@@ -810,8 +810,20 @@ impl Shared {...@@ -810,8 +810,20 @@ impl Shared {
810 }810 }
811811
812 pub fn apply(&self, edit: Edit) -> Result<()> {812 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<()> {
814 self.status.store(Status::Saving as u8, Ordering::Relaxed);821 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 }
815 Ok(())827 Ok(())
816 }828 }
817829
crates/mobile/src/recording.rs+1-1
...@@ -117,7 +117,7 @@ pub unsafe extern "C" fn sb_view_start_recording(...@@ -117,7 +117,7 @@ pub unsafe extern "C" fn sb_view_start_recording(
117 date: *const c_char,117 date: *const c_char,
118 time: *const c_char,118 time: *const c_char,
119) -> bool {119) -> bool {
120 if view.read_only || view.reading.is_some() {120 if view.read_only {
121 return false;121 return false;
122 }122 }
123 let started = view123 let started = view
crates/mobile/src/tests.rs+978-1
...@@ -1748,7 +1748,8 @@ fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() {...@@ -1748,7 +1748,8 @@ fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() {
1748 frame: Arc::default(),1748 frame: Arc::default(),
1749 section: Arc::clone(&section.shared),1749 section: Arc::clone(&section.shared),
1750 read_only,1750 read_only,
1751 reading: None,1751 reload_pending: false,
1752 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
1752 };1753 };
1753 assert!(sb_view_focus_title(&mut view));1754 assert!(sb_view_focus_title(&mut view));
1754 assert!(unsafe { sb_insert(&mut view, c" retained after surface loss".as_ptr()) });1755 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() {...@@ -2364,3 +2365,979 @@ fn hosted_credentials_reject_unknown_versions_without_echoing_secret_input() {
2364 }2365 }
2365 assert!(!Path::new(cache.to_str().unwrap()).exists());2366 assert!(!Path::new(cache.to_str().unwrap()).exists());
2366}2367}
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 {...@@ -2471,6 +2471,16 @@ impl Section {
2471 /// Applies an edit without waiting: it becomes durable on the section thread, which2471 /// Applies an edit without waiting: it becomes durable on the section thread, which
2472 /// reports a refusal as `Event::Rejected`. Edits apply in the order they arrive.2472 /// reports a refusal as `Event::Rejected`. Edits apply in the order they arrive.
2473 pub fn apply(&self, author: &str, edit: Edit) -> Result<()> {2473 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<()> {
2474 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));2484 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));
2475 let root = self.replica.root;2485 let root = self.replica.root;
2476 let spaces = crate::queue::spaces(&edit, root);2486 let spaces = crate::queue::spaces(&edit, root);
...@@ -2478,15 +2488,20 @@ impl Section {...@@ -2478,15 +2488,20 @@ impl Section {
2478 author,2488 author,
2479 edit,2489 edit,
2480 Box::new(move |result| {2490 Box::new(move |result| {
2481 let event = match result {2491 let event = match &result {
2482 Ok(_) => return,2492 Ok(_) => None,
2483 Err(Error::Rejected(error)) => Event::Rejected {2493 Err(Error::Rejected(error)) => Some(Event::Rejected {
2484 spaces,2494 spaces,
2485 error: error.to_string(),2495 error: error.to_string(),
2486 },2496 }),
2487 Err(error) => Event::Failed(error.to_string()),2497 Err(error) => Some(Event::Failed(error.to_string())),
2488 };2498 };
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 {
2490 notify();2505 notify();
2491 }2506 }
2492 }),2507 }),
crates/notebook/src/session/tests.rs+46
...@@ -624,3 +624,49 @@ fn initially_offline_workers_keep_the_original_queue_until_explicitly_woken() {...@@ -624,3 +624,49 @@ fn initially_offline_workers_keep_the_original_queue_until_explicitly_woken() {
624 assert!(Replica::open(&cache).unwrap().pending().unwrap().is_empty());624 assert!(Replica::open(&cache).unwrap().pending().unwrap().is_empty());
625 }625 }
626}626}
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(...@@ -4957,14 +4957,7 @@ fn page_tree(
4957 scale: view.viewport.scale * ratio,4957 scale: view.viewport.scale * ratio,
4958 origin: [0, 1].map(|axis| (view.viewport.origin[axis] + corner[axis] * scale) * ratio),4958 origin: [0, 1].map(|axis| (view.viewport.origin[axis] + corner[axis] * scale) * ratio),
4959 };4959 };
4960 let update = access.update(4960 let update = access.update(view, viewport, "Page")?;
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 )?;
4968 Ok(accesskit::TreeUpdate {4961 Ok(accesskit::TreeUpdate {
4969 tree_id: PAGE_TREE,4962 tree_id: PAGE_TREE,
4970 ..update4963 ..update