input: fix cursor jumping to position 0 after typing first character

When composing a new note, the cursor would jump in front of the first
letter after typing it. This occurred because multiple SwiftUI view
updates (text change, placeholder removal, height change) could cause
the cursor position to be incorrectly restored.

The fix explicitly tracks the cursor position after each text change
by calling updateCursorPosition, ensuring the correct position is
always used regardless of view update timing.

Refactored textViewDidChange to use early return pattern for clarity.

Added UI test to guard against cursor position regressions in the
post composer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changelog-Fixed: Fixed cursor jumping behind first letter when typing a new note
Closes: https://github.com/damus-io/damus/pull/3473
Closes: https://github.com/damus-io/damus/issues/3461
Co-Authored-By: Claude Opus 4.5
Tested-by: William Casarin <jb55@jb55.com>
Signed-off-by: alltheseas
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
alltheseas
2025-12-26 15:04:01 -06:00
committed by Daniel D’Aquino
parent f506f9cfe8
commit 4941b502d5
5 changed files with 63 additions and 5 deletions

View File

@@ -60,9 +60,19 @@ enum AppAccessibilityIdentifiers: String {
// MARK: Post composer
// Prefix: `post_composer`
/// The cancel post button
case post_composer_cancel_button
/// The text view where the user types their note
case post_composer_text_view
// MARK: Post button (FAB)
// Prefix: `post_button`
/// The floating action button to create a new post
case post_button
// MARK: Main interface layout
// Prefix: `main`

View File

@@ -276,7 +276,7 @@ struct PostView: View {
getFocusWordForMention: { word, range in
focusWordAttributes = (word, range)
self.newCursorIndex = nil
},
},
updateCursorPosition: { newCursorIndex in
self.newCursorIndex = newCursorIndex
}
@@ -284,6 +284,7 @@ struct PostView: View {
.environmentObject(tagModel)
.focused($focus)
.textInputAutocapitalization(.sentences)
.accessibilityIdentifier(AppAccessibilityIdentifiers.post_composer_text_view.rawValue)
.onChange(of: post) { p in
post_changed(post: p, media: uploadedMedias)
}

View File

@@ -31,6 +31,7 @@ func PostButton(action: @escaping () -> ()) -> some View {
.foregroundColor(Color.white)
}
})
.accessibilityIdentifier(AppAccessibilityIdentifiers.post_button.rawValue)
.keyboardShortcut("n", modifiers: [.command, .shift])
}

View File

@@ -120,6 +120,7 @@ struct TextViewWrapper: UIViewRepresentable {
}
func textViewDidChange(_ textView: UITextView) {
// Handle one-time initial text suffix insertion (e.g., for replies)
if let initialTextSuffix, !self.initialTextSuffixWasAdded {
self.initialTextSuffixWasAdded = true
var mutable = NSMutableAttributedString(attributedString: textView.attributedText)
@@ -129,11 +130,19 @@ struct TextViewWrapper: UIViewRepresentable {
DispatchQueue.main.async {
self.updateCursorPosition(originalRange.location)
}
processFocusedWordForMention(textView: textView)
return
}
else {
attributedText = NSMutableAttributedString(attributedString: textView.attributedText)
}
attributedText = NSMutableAttributedString(attributedString: textView.attributedText)
processFocusedWordForMention(textView: textView)
// Fix for cursor jumping to position 0 after typing first character.
// When text changes, multiple SwiftUI view updates can occur (text change,
// placeholder removal, height change). The getFocusWordForMention callback
// sets newCursorIndex to nil, forcing reliance on savedRange which may be
// stale. Explicitly tracking cursor position here ensures correct restoration.
updateCursorPosition(textView.selectedRange.location)
}
private func processFocusedWordForMention(textView: UITextView) {

View File

@@ -188,6 +188,43 @@ class damusUITests: XCTestCase {
guard app.buttons[AID.sign_in_confirm_button.rawValue].tapIfExists(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element }
}
/// Tests that typing in the post composer works correctly, specifically that
/// the cursor position is maintained after typing each character.
/// This guards against regressions like https://github.com/damus-io/damus/issues/3461
/// where the cursor would jump to position 0 after typing the first character.
func testPostComposerCursorPosition() throws {
try self.loginIfNotAlready()
// Wait for main interface to load, then tap the post button (FAB)
guard app.buttons[AID.post_button.rawValue].waitForExistence(timeout: 10) else {
throw DamusUITestError.timeout_waiting_for_element
}
app.buttons[AID.post_button.rawValue].tap()
// Wait for the post composer text view to appear
guard app.textViews[AID.post_composer_text_view.rawValue].waitForExistence(timeout: 5) else {
throw DamusUITestError.timeout_waiting_for_element
}
let textView = app.textViews[AID.post_composer_text_view.rawValue]
textView.tap()
// Type a test string character by character
// If the cursor jumps to position 0 after the first character,
// the resulting text would be scrambled (e.g., "olleH" instead of "Hello")
let testString = "Hello"
textView.typeText(testString)
// Verify the text was typed correctly (not scrambled)
let actualText = textView.value as? String ?? ""
XCTAssertEqual(actualText, testString,
"Text should be '\(testString)' but was '\(actualText)'. " +
"This may indicate a cursor position bug.")
// Cancel the post to clean up
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
}
enum DamusUITestError: Error {
case timeout_waiting_for_element
}