diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt index 1854bc7372da..27a2bd97d3f1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt @@ -25,11 +25,13 @@ import android.os.LocaleList import android.os.Parcelable import android.text.InputType import android.util.AttributeSet +import android.view.KeyEvent import android.view.inputmethod.EditorInfo import android.widget.EditText import androidx.annotation.VisibleForTesting import androidx.core.graphics.toColorInt import com.google.android.material.color.MaterialColors +import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.servicelayer.NoteService @@ -56,6 +58,9 @@ class FieldEditText : @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) var clipboard: ClipboardManager? = null + private var lastCutTime: Long = 0 + private var isHandlingCut = false + constructor(context: Context?) : super(context!!) constructor(context: Context?, attr: AttributeSet?) : super(context!!, attr) constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super(context!!, attrs, defStyle) @@ -150,6 +155,77 @@ class FieldEditText : setText(text) } + override fun onKeyDown( + keyCode: Int, + event: KeyEvent?, + ): Boolean { + // Handle Ctrl+X (cut operation) to fix backspace behavior after cut + // Issue #19504: Backspace deletes multiple characters after cut operation + // Core issue: When text is deleted via cut, Android Editor's internal mSelectedText + // and selection cache isn't invalidated. The next key press (e.g., backspace) + // uses the old cached selection causing multi-char deletion. + // + // The REAL fix: After cutting and deleting text, we need to invalidate the Editor's + // internal selection tracking by simulating a cursor movement that forces it to refresh. + if (keyCode == KeyEvent.KEYCODE_X && event?.isCtrlPressed == true) { + val selStart = selectionStart + val selEnd = selectionEnd + + // Only intercept if there's an actual selection + if (selStart != selEnd) { + try { + val start = min(selStart, selEnd) + val end = max(selStart, selEnd) + val editable = editableText + + // Get the text that will be cut + val cutText = editable.subSequence(start, end) + + // Copy to clipboard + val clip = android.content.ClipData.newPlainText("cut", cutText) + clipboard?.setPrimaryClip(clip) + + // Delete the selected text + editable.delete(start, end) + + // CRITICAL: Force Android's Editor to invalidate its selection cache + // by temporarily moving selection away and back + isHandlingCut = true + + // Move selection slightly to force Editor to re-evaluate state + if (start > 0) { + setSelection(start - 1) + setSelection(start) + } else if (editable.length > start) { + setSelection(start + 1) + setSelection(start) + } else { + setSelection(start) + } + + lastCutTime = TimeManager.time.intTimeMS() + isHandlingCut = false + return true + } catch (e: Exception) { + Timber.w(e, "Failed to perform manual cut, falling back to parent") + isHandlingCut = false + return super.onKeyDown(keyCode, event) + } + } + } + + // Immediately after cut, if backspace is pressed, clear the selection that might be lingering + if (keyCode == KeyEvent.KEYCODE_DEL && !isHandlingCut && TimeManager.time.intTimeMS() - lastCutTime < 100) { + // Force clear any lingering selection by explicitly setting cursor + val currentCursor = selectionStart + if (selectionStart != selectionEnd) { + setSelection(currentCursor) + } + } + + return super.onKeyDown(keyCode, event) + } + override fun onSaveInstanceState(): Parcelable { val state = super.onSaveInstanceState() return SavedState(state, ord)