diff --git a/damus/Shared/Components/Text/TextViewWrapper.swift b/damus/Shared/Components/Text/TextViewWrapper.swift index ad5f11145..11d7541e7 100644 --- a/damus/Shared/Components/Text/TextViewWrapper.swift +++ b/damus/Shared/Components/Text/TextViewWrapper.swift @@ -49,7 +49,16 @@ struct TextViewWrapper: UIViewRepresentable { uiView.linkTextAttributes = linkAttributes } + /// Pushes the SwiftUI-side attributed text into the text view and restores the + /// caret position, which UIKit resets whenever `attributedText` is re-assigned. + /// + /// Every text/selection change made here is programmatic, so the coordinator's + /// selection tracking is suppressed for the duration of this update: the delegate + /// callbacks fired by these changes must not be recorded as user-driven caret moves. func updateUIView(_ uiView: UITextView, context: Context) { + context.coordinator.isApplyingProgrammaticChange = true + defer { context.coordinator.isApplyingProgrammaticChange = false } + // Save the current selection BEFORE making any changes // This is critical because setting attributedText causes UITextView to reset the cursor position let savedRange = uiView.selectedRange @@ -108,6 +117,11 @@ struct TextViewWrapper: UIViewRepresentable { let initialTextSuffix: String? var initialTextSuffixWasAdded: Bool = false var convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)? = nil + /// True while `updateUIView` applies programmatic text/selection changes. + /// + /// `textViewDidChangeSelection` checks this flag so that delegate callbacks + /// fired by those changes are not recorded as user-driven caret moves. + var isApplyingProgrammaticChange: Bool = false static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; ", "#"] init(attributedText: Binding, @@ -149,6 +163,20 @@ struct TextViewWrapper: UIViewRepresentable { updateCursorPosition(textView.selectedRange.location) } + /// Keeps the tracked cursor position in sync when the user moves the caret + /// without editing the text (e.g. by tapping mid-text). + /// + /// Without this, the position recorded at the last text change goes stale, + /// and the next view update (e.g. one triggered by an iOS keyboard switch) + /// would re-apply it, jumping the caret to the end of the text (issue #3545). + /// + /// Programmatic selection changes are ignored while `isApplyingProgrammaticChange` + /// is set, as are range selections, which a single index cannot represent. + func textViewDidChangeSelection(_ textView: UITextView) { + guard !isApplyingProgrammaticChange, textView.selectedRange.length == 0 else { return } + updateCursorPosition(textView.selectedRange.location) + } + private func processFocusedWordForMention(textView: UITextView) { var val: (String?, NSRange?) = (nil, nil) diff --git a/damusTests/PostViewTests.swift b/damusTests/PostViewTests.swift index 5e5691504..57c258a42 100644 --- a/damusTests/PostViewTests.swift +++ b/damusTests/PostViewTests.swift @@ -319,6 +319,42 @@ final class PostViewTests: XCTestCase { XCTAssertTrue(shouldChange, "shouldChangeTextIn should return true for regular text") } + /// Tests that moving the caret without editing text updates the tracked cursor + /// position, so a later view update does not restore a stale position (issue #3545) + func testSelectionChangeUpdatesTrackedCursorPosition() { + let content = NSMutableAttributedString(string: "Hello world") + let bindingContent: Binding = Binding(get: { content }, set: { _ in }) + + var trackedCursorPosition: Int? = nil + let coordinator = TextViewWrapper.Coordinator( + attributedText: bindingContent, + getFocusWordForMention: nil, + updateCursorPosition: { trackedCursorPosition = $0 }, + initialTextSuffix: nil, + convertMentionRef: nil + ) + + let textView = UITextView() + textView.attributedText = content + + // Simulate the user tapping to place the caret mid-text + textView.selectedRange = NSRange(location: 5, length: 0) + coordinator.textViewDidChangeSelection(textView) + XCTAssertEqual(trackedCursorPosition, 5, "A user-driven caret move should update the tracked cursor position") + + // Range selections cannot be represented by a single index, so they are not tracked + trackedCursorPosition = nil + textView.selectedRange = NSRange(location: 2, length: 3) + coordinator.textViewDidChangeSelection(textView) + XCTAssertNil(trackedCursorPosition, "Range selections should not update the tracked cursor position") + + // Programmatic selection changes made by updateUIView should not be recorded + coordinator.isApplyingProgrammaticChange = true + textView.selectedRange = NSRange(location: 8, length: 0) + coordinator.textViewDidChangeSelection(textView) + XCTAssertNil(trackedCursorPosition, "Programmatic selection changes should not update the tracked cursor position") + } + /// Tests that client tags are added to events when provided. func testToEventAddsClientTagWhenProvided() { let post = NostrPost(content: "gm")