From b9b91291d174212f9d4e801a9b0dbd87a0ed954a Mon Sep 17 00:00:00 2001 From: Zokii0 Date: Tue, 13 Oct 2026 19:50:34 +0200 Subject: [PATCH] Fix capitalization and delete swipe behavior --- .../keyboard/KeyboardActionListenerImpl.kt | 61 ++++++++++++++++++- .../keyboard/keyboard/PointerTracker.kt | 31 +++++++++- .../keyboard/latin/RichInputConnection.kt | 11 ++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index acfcef461..91ece805c 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -527,11 +527,68 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp deleteSwipeStepCount = 0 } + // Vertical movement is delegated to the editor, so the selection always ends exactly one + // visual (wrapped) line above/below, like the arrow keys do. If the editor can't do that, + // we fall back to the approximated line boundaries below. + val movedByEditor = stepsY != 0 && moveDeleteSelectionByEditorLines(stepsY) + val remainingStepsY = if (movedByEditor) 0 else stepsY + if (movedByEditor && stepsX == 0) return + if (settings.current.mDeleteSwipeWordByWord) { - onMoveDeletePointerWords2D(stepsX, stepsY) + onMoveDeletePointerWords2D(stepsX, remainingStepsY) } else { - onMoveDeletePointerChars2D(stepsX, stepsY) + onMoveDeletePointerChars2D(stepsX, remainingStepsY) + } + } + + /** + * Moves the left end of the delete-swipe selection by whole editor lines (negative = up, positive = down), + * using Shift+DPAD_UP/DOWN on a reversed selection (anchor = right end, extent = left end). + * Afterwards the horizontal state is re-based on the new left end. + * Returns false (and leaves the selection as it was) if the editor does not behave as expected. + */ + private fun moveDeleteSelectionByEditorLines(stepsY: Int): Boolean { + val initialEnd = deleteSwipeInitialEnd + val currentStart = connection.expectedSelectionStart + if (initialEnd < 0 || currentStart < 0 || currentStart > initialEnd) return false + + // make the moving end the extent: anchor at the right end, extent at the left end + if (!connection.setSelection(initialEnd, currentStart)) return false + + val keyCode = if (stepsY < 0) KeyEvent.KEYCODE_DPAD_UP else KeyEvent.KEYCODE_DPAD_DOWN + repeat(abs(stepsY)) { + val eventTime = android.os.SystemClock.uptimeMillis() + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0)) + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0, KeyEvent.META_SHIFT_ON)) + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, keyCode, 0, KeyEvent.META_SHIFT_ON)) + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0)) + } + + val selection = connection.getEditorSelection() + if (selection == null || selection.first != initialEnd) { + // editor doesn't support extracted text or reversed selections: restore and use the fallback + connection.setSelection(currentStart, initialEnd) + return false } + + // moving down must not go past the original cursor position + val newStart = selection.second.coerceIn(0, initialEnd) + connection.setSelection(newStart, initialEnd) // normal order again, also updates expected selection + + val textBefore = connection.getTextBeforeCursor(2000, 0)?.toString() ?: "" + deleteSwipeWordBoundaries = getWordBoundariesBackwards(textBefore, newStart) + deleteSwipeLineBoundaries = listOf(newStart) + deleteSwipeWordIndex = 0 + deleteSwipeLineIndex = 0 + deleteSwipeCharOffset = 0 + + if (newStart != currentStart) { + deleteSwipeStepCount++ + if (deleteSwipeStepCount % 2 != 0) { + performHapticFeedback(HapticEvent.GESTURE_MOVE) + } + } + return true } private fun onMoveDeletePointerChars2D(stepsX: Int, stepsY: Int) { diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt index 9e94c681b..f1783b577 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt @@ -100,6 +100,11 @@ class PointerTracker private constructor( // true if keyboard layout has been changed. private var mKeyboardLayoutHasBeenChanged = false + + // True as long as every keyboard change since the flag above was last reset only dropped the one-shot + // (automatic or manual) shift of the same alphabet layout. Such a change must not make us + // re-detect the key on release, see detectAndSendKey. + private var mOnlyShiftStateHasChanged = true private var keyboardChangeOccupiedHeightDifference = 0 // true if this pointer is no longer triggering any action because it has been canceled. @@ -160,6 +165,7 @@ class PointerTracker private constructor( sListener.onPressKey(key.code, repeatCount, getActivePointerTrackerCount() == 1, HapticEvent.KEY_PRESS) val keyboardLayoutHasBeenChanged = mKeyboardLayoutHasBeenChanged mKeyboardLayoutHasBeenChanged = false + mOnlyShiftStateHasChanged = true getTimerProxy().startTypingStateTimer(key) return keyboardLayoutHasBeenChanged } @@ -243,6 +249,9 @@ class PointerTracker private constructor( // since y is measured from top of view, this change needs to be considered in some places keyboardChangeOccupiedHeightDifference = keyboard.mOccupiedHeight - oldKeyboard.mOccupiedHeight } + val onlyShiftStateChanged = oldKeyboard != null && isOnlyShiftStateChange(oldKeyboard, keyboard) + mOnlyShiftStateHasChanged = if (mKeyboardLayoutHasBeenChanged) mOnlyShiftStateHasChanged && onlyShiftStateChanged + else onlyShiftStateChanged mKeyDetector = keyDetector mKeyboard = keyboard // Mark that keyboard layout has been changed. @@ -253,6 +262,22 @@ class PointerTracker private constructor( mBogusMoveEventDetector.setKeyboardGeometry(keyWidth, keyHeight) } + /** + * True if [newKb] is the same alphabet keyboard as [oldKb], but the one-shot shift (automatic or manual) + * has been dropped. This happens when a second finger touches a key while the first one is still down. + * The opposite direction (e.g. chording with the shift key) is not matched, as re-detecting the key + * is intended there. + */ + private fun isOnlyShiftStateChange(oldKb: Keyboard, newKb: Keyboard): Boolean { + val oldId = oldKb.mId + val newId = newKb.mId + return newId.mElementId == KeyboardId.ELEMENT_ALPHABET + && (oldId.mElementId == KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED + || oldId.mElementId == KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED) + && oldId.mSubtype == newId.mSubtype + && oldId.mHeight == newId.mHeight + } + override fun isInDraggingFinger(): Boolean = mIsInDraggingFinger val key: Key? get() = mCurrentKey @@ -529,6 +554,7 @@ class PointerTracker private constructor( sInKeySwipe = true } mKeyboardLayoutHasBeenChanged = false + mOnlyShiftStateHasChanged = true mIsTrackingForActionDisabled = false resetKeySelectionByDraggingFinger() if (key != null) { @@ -1248,7 +1274,10 @@ class PointerTracker private constructor( return } - val targetKey = if (mKeyboardLayoutHasBeenChanged && !key.isModifier()) { + // Don't re-detect the key if the layout only changed its shift state after this pointer went down. + // Otherwise typing fast (second key touched before the first one is released) resets the automatic + // shift, and the first key would then be re-detected on the unshifted layout and typed as lower case. + val targetKey = if (mKeyboardLayoutHasBeenChanged && !mOnlyShiftStateHasChanged && !key.isModifier()) { mKeyDetector.detectHitKey(x, y) ?: key } else { key diff --git a/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt b/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt index 5d98df1cc..cdcaa27d4 100644 --- a/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt +++ b/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt @@ -265,6 +265,17 @@ class RichInputConnection(private val mParent: InputMethodService) : PrivateComm return true } + /** + * Synchronously asks the editor for its current selection as (anchor, extent) in absolute offsets. + * For a reversed selection, extent < anchor. Returns null if the editor does not provide extracted text. + */ + fun getEditorSelection(): Pair? { + if (!isConnected()) return null + val et = mIC?.getExtractedText(ExtractedTextRequest(), 0) ?: return null + if (et.selectionStart < 0 || et.selectionEnd < 0) return null + return Pair(et.selectionStart + et.startOffset, et.selectionEnd + et.startOffset) + } + private fun reloadCursorPosition() { if (!isConnected()) return