diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index 86571a0..56ad08e 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -209,7 +209,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { coordinator?.applyHybridAttributes(to: textView, cause: .focusChange) } textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView, interactionKind in - coordinator?.recordUserEditingInteraction(interactionKind) + coordinator?.recordUserEditingInteraction(interactionKind, in: textView) } textView.isRichText = false textView.isEditable = true @@ -274,6 +274,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { fileprivate var viewportStabilityEvents: [ViewportStabilityEvent] = [] private var lastUserInteractionKind: EditorInteractionKind = .programmatic private var pendingSelectionInteractionKind: EditorInteractionKind? + private var pendingMouseVisibleOrigin: NSPoint? init(_ parent: NativeMarkdownTextView) { self.parent = parent @@ -320,6 +321,11 @@ private struct NativeMarkdownTextView: NSViewRepresentable { if interactionKind != .programmatic { hasUserActivatedEditing = true } + defer { + if interactionKind == .mouse { + pendingMouseVisibleOrigin = nil + } + } applyHybridAttributes(to: textView, cause: .selectionChange(interactionKind)) parent.selection = newSelection } @@ -345,7 +351,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable { ? ViewportPresentationAnchor.capture( in: textView, selectedRange: selectedRange, - lineIndex: currentLineIndex + lineIndex: currentLineIndex, + visibleOrigin: visibleOriginOverride(for: cause) ) : nil let start = Date() @@ -420,8 +427,13 @@ private struct NativeMarkdownTextView: NSViewRepresentable { applyHybridAttributes(to: textView, cause: .editingActivation(interactionKind)) } - func recordUserEditingInteraction(_ interactionKind: EditorInteractionKind) { + func recordUserEditingInteraction(_ interactionKind: EditorInteractionKind, in textView: NSTextView) { lastUserInteractionKind = interactionKind + if interactionKind == .mouse { + pendingMouseVisibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin + } else { + pendingMouseVisibleOrigin = nil + } } func setSelection( @@ -452,6 +464,11 @@ private struct NativeMarkdownTextView: NSViewRepresentable { return !visibleRect.contains(point) } + private func visibleOriginOverride(for cause: PresentationUpdateCause) -> NSPoint? { + guard case .selectionChange(.mouse) = cause else { return nil } + return pendingMouseVisibleOrigin + } + func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { @@ -783,7 +800,8 @@ private struct ViewportPresentationAnchor { static func capture( in textView: NSTextView, selectedRange: NSRange, - lineIndex: DocumentLineIndex + lineIndex: DocumentLineIndex, + visibleOrigin visibleOriginOverride: NSPoint? = nil ) -> ViewportPresentationAnchor? { guard let scrollView = textView.enclosingScrollView, textView.string.utf16.count > 0 @@ -796,7 +814,7 @@ private struct ViewportPresentationAnchor { ) guard let point = textView.presentationAnchorPoint(at: location) else { return nil } - let visibleOrigin = scrollView.contentView.bounds.origin + let visibleOrigin = visibleOriginOverride ?? scrollView.contentView.bounds.origin return ViewportPresentationAnchor( characterLocation: location, visibleOrigin: visibleOrigin, @@ -1130,7 +1148,7 @@ public final class HybridMarkdownLiveEditorHarness { coordinator?.applyHybridAttributes(to: textView, cause: .focusChange) } self.textView.onUserEditingInteraction = { [weak coordinator] textView, interactionKind in - coordinator?.recordUserEditingInteraction(interactionKind) + coordinator?.recordUserEditingInteraction(interactionKind, in: textView) } self.textView.string = source self.textView.delegate = coordinator @@ -1192,11 +1210,19 @@ public final class HybridMarkdownLiveEditorHarness { setSelection(range, interactionKind: .mouse) } + public func simulateMouseSelectionAfterNativeScroll(_ range: NSRange, nativeScrollY: CGFloat) { + coordinator.recordUserEditingInteraction(.mouse, in: textView) + scrollViewportWithoutSync(toY: nativeScrollY) + textView.setSelectedRange(range) + coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView)) + syncState() + } + private func setSelection(_ range: NSRange, interactionKind: EditorInteractionKind) { if interactionKind == .programmatic { coordinator.activateEditingPresentation(in: textView, interactionKind: interactionKind) } else { - coordinator.recordUserEditingInteraction(interactionKind) + coordinator.recordUserEditingInteraction(interactionKind, in: textView) } coordinator.setSelection(range, in: textView, interactionKind: interactionKind) coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView)) @@ -1220,11 +1246,15 @@ public final class HybridMarkdownLiveEditorHarness { } public func scrollViewport(toY y: CGFloat) { + scrollViewportWithoutSync(toY: y) + syncState() + } + + private func scrollViewportWithoutSync(toY y: CGFloat) { let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height) let target = NSPoint(x: 0, y: max(0, min(y, maxY))) scrollView.contentView.scroll(to: target) scrollView.reflectScrolledClipView(scrollView.contentView) - syncState() } public func viewportOrigin() -> CGPoint { diff --git a/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift b/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift index 282f9a1..6f54c6a 100644 --- a/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift +++ b/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift @@ -27,6 +27,22 @@ final class HybridMarkdownLiveEditorHarnessTests: XCTestCase { XCTAssertEqual(harness.viewportOrigin().y, 0, accuracy: 0.001) } + func testNativeMouseScrollDuringTopClickIsRestored() { + let source = (["# Heading", "Opening paragraph"] + (1...80).map { "Line \($0)" }) + .joined(separator: "\n") + let harness = HybridMarkdownLiveEditorHarness(source: source) + harness.simulateLaunchFirstResponder() + harness.scrollViewport(toY: 0) + + let paragraphLocation = (source as NSString).range(of: "Opening").location + harness.simulateMouseSelectionAfterNativeScroll( + NSRange(location: paragraphLocation, length: 0), + nativeScrollY: 48 + ) + + XCTAssertEqual(harness.viewportOrigin().y, 0, accuracy: 0.001) + } + func testLiveParagraphGeometryReturnsAfterClickAndFocusAway() throws { let source = """ # Heading