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