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