diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index c9876964e..93cbb1439 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -525,6 +525,16 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp private var deleteSwipeLineIndex = 0 private var deleteSwipeCharOffset = 0 private var deleteSwipeStepCount = 0 + // Vertical movement: when the editor can move by visual lines, deleteSwipeLineBoundaries holds the left end + // of the selection after 0, 1, 2... upward editor steps (index 0 = where the swipe started). + private var deleteSwipeApproxLineBoundaries = emptyList() + private var deleteSwipeUseEditorLines = false + private var deleteSwipeEditorTopReached = false + // Left end of the selection as this swipe last set it. In editor line mode this is used instead of + // the selection reported back by the editor, which can briefly show the temporary carets used for probing. + private var deleteSwipeShownStart = -1 + // Editor (app) in which probing was too slow: don't probe there again until the keyboard is restarted + private var slowEditorLinesPackage: String? = null override fun onMoveDeletePointer(steps: Int) { onMoveDeletePointer(steps, 0) @@ -537,19 +547,34 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } inputLogic.finishInput() val currentEnd = connection.expectedSelectionEnd - if (deleteSwipeInitialEnd == -1 || deleteSwipeInitialEnd != currentEnd) { + // In editor line mode our own temporary selections must not look like an external selection change + if (deleteSwipeInitialEnd == -1 || (!deleteSwipeUseEditorLines && deleteSwipeInitialEnd != currentEnd)) { deleteSwipeInitialEnd = currentEnd val textBefore = connection.getTextBeforeCursor(2000, 0)?.toString() ?: "" deleteSwipeInitialText = textBefore val baseStart = connection.expectedSelectionStart deleteSwipeWordBoundaries = getWordBoundariesBackwards(textBefore, baseStart) - deleteSwipeLineBoundaries = getLineBoundariesBackwards(textBefore, baseStart) + deleteSwipeApproxLineBoundaries = getLineBoundariesBackwards(textBefore, baseStart) + // Only multi-line fields have visual lines the editor can move over; elsewhere keep the approximation. + // This is opt-in (experimental): it sends arrow key events to the editor. + val editorPackage = keyboardSwitcher.keyboard?.mId?.mEditorInfo?.packageName + deleteSwipeUseEditorLines = settings.current.mDeleteSwipeExactLines + && keyboardSwitcher.keyboard?.mId?.isMultiLine == true + && baseStart >= 0 + && (editorPackage == null || editorPackage != slowEditorLinesPackage) + deleteSwipeShownStart = baseStart + deleteSwipeEditorTopReached = false + deleteSwipeLineBoundaries = if (deleteSwipeUseEditorLines) listOf(baseStart) else deleteSwipeApproxLineBoundaries deleteSwipeWordIndex = 0 deleteSwipeLineIndex = 0 deleteSwipeCharOffset = 0 deleteSwipeStepCount = 0 } + if (stepsY < 0 && deleteSwipeUseEditorLines && !deleteSwipeEditorTopReached) { + extendDeleteSwipeLinesByEditor(deleteSwipeLineIndex - stepsY) + } + if (settings.current.mDeleteSwipeWordByWord) { onMoveDeletePointerWords2D(stepsX, stepsY) } else { @@ -557,6 +582,52 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } } + /** + * Makes sure [deleteSwipeLineBoundaries] has an entry for [targetIndex] by asking the editor to move the caret + * one visual line up (same as pressing the up arrow key), starting at the last known position. + * Moving down later just goes back to the already known entries, so up and down always match exactly. + * If the editor can't do this at all, the approximated boundaries are used for the rest of the swipe. + */ + private fun extendDeleteSwipeLinesByEditor(targetIndex: Int) { + if (deleteSwipeLineBoundaries.size > targetIndex) return + val boundaries = deleteSwipeLineBoundaries.toMutableList() + val startTime = SystemClock.elapsedRealtime() + val result = try { + extendLineBoundaries(boundaries, targetIndex) { position -> editorPositionOneLineUp(position) } + } finally { + // restore what the user currently sees; our own value is used, the editor's report may be in between + connection.setSelection(deleteSwipeShownStart, deleteSwipeInitialEnd) + } + // the requests are synchronous: if the editor is slow, don't ask again in this app + val slow = SystemClock.elapsedRealtime() - startTime > MAX_EDITOR_LINE_PROBE_MILLIS * (boundaries.size - deleteSwipeLineBoundaries.size).coerceAtLeast(1) + when { + result == LineExtendResult.FAILED && boundaries.size <= 1 -> { + // editor doesn't support it (e.g. no extracted text): use the approximation as before + deleteSwipeUseEditorLines = false + deleteSwipeLineBoundaries = deleteSwipeApproxLineBoundaries + } + else -> { + deleteSwipeLineBoundaries = boundaries + if (result != LineExtendResult.REACHED) deleteSwipeEditorTopReached = true + } + } + if (slow) { + slowEditorLinesPackage = keyboardSwitcher.keyboard?.mId?.mEditorInfo?.packageName + deleteSwipeEditorTopReached = true + } + } + + /** Position of the caret after moving it one visual line up from [position] in the editor, or null if unknown. */ + private fun editorPositionOneLineUp(position: Int): Int? { + if (!connection.setSelection(position, position)) return null + val eventTime = SystemClock.uptimeMillis() + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP, 0)) + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_UP, 0)) + val selection = connection.getEditorSelection() ?: return null + // still a selection (or not a caret at all): the editor didn't treat this as a plain arrow key + return if (selection.first == selection.second) selection.first else null + } + private fun onMoveDeletePointerChars2D(stepsX: Int, stepsY: Int) { val oldLineIndex = deleteSwipeLineIndex val oldCharOffset = deleteSwipeCharOffset @@ -584,6 +655,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp if (deleteSwipeStepCount % 2 != 0) { performHapticFeedback(HapticEvent.GESTURE_MOVE) } + deleteSwipeShownStart = finalStart connection.setSelection(finalStart, deleteSwipeInitialEnd) } } @@ -653,7 +725,8 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp if (deleteSwipeStepCount % 2 != 0) { performHapticFeedback(HapticEvent.GESTURE_MOVE) } - connection.setSelection(finalStart.coerceAtMost(deleteSwipeInitialEnd), deleteSwipeInitialEnd) + deleteSwipeShownStart = finalStart.coerceAtMost(deleteSwipeInitialEnd) + connection.setSelection(deleteSwipeShownStart, deleteSwipeInitialEnd) } } @@ -681,15 +754,26 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp keyboardSwitcher.onGifUpWithDeletePointerActive() return } + // in editor line mode the reported selection may lag behind, so use what this swipe set itself + val editorLineMode = deleteSwipeUseEditorLines + val shownStart = deleteSwipeShownStart + val shownEnd = deleteSwipeInitialEnd deleteSwipeInitialText = "" deleteSwipeInitialEnd = -1 deleteSwipeWordBoundaries = emptyList() deleteSwipeLineBoundaries = emptyList() + deleteSwipeApproxLineBoundaries = emptyList() + deleteSwipeUseEditorLines = false + deleteSwipeEditorTopReached = false + deleteSwipeShownStart = -1 deleteSwipeWordIndex = 0 deleteSwipeLineIndex = 0 deleteSwipeCharOffset = 0 deleteSwipeStepCount = 0 - if (!connection.hasSelection()) return + if (editorLineMode) { + if (shownStart !in 0 until shownEnd) return // nothing selected + connection.setSelection(shownStart, shownEnd) + } else if (!connection.hasSelection()) return inputLogic.finishInput() onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) keyboardSwitcher.requestUpdatingShiftState( @@ -705,6 +789,10 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp deleteSwipeInitialEnd = -1 deleteSwipeWordBoundaries = emptyList() deleteSwipeLineBoundaries = emptyList() + deleteSwipeApproxLineBoundaries = emptyList() + deleteSwipeUseEditorLines = false + deleteSwipeEditorTopReached = false + deleteSwipeShownStart = -1 deleteSwipeWordIndex = 0 deleteSwipeLineIndex = 0 deleteSwipeCharOffset = 0 @@ -1047,6 +1135,29 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp private fun Int.isMetaLock() = this == KeyCode.CTRL_LOCK || this == KeyCode.ALT_LOCK || this == KeyCode.FN_LOCK || this == KeyCode.META_LOCK + internal enum class LineExtendResult { REACHED, TOP, FAILED } + /** Probing the editor for a line takes longer than this (ms, per line): stop asking in this app. */ + private const val MAX_EDITOR_LINE_PROBE_MILLIS = 60L + + /** + * Appends to [boundaries] (index 0 = start position of the swipe) until it has an entry for [targetIndex]. + * [oneLineUp] returns the position one visual line above the given position (same column), or null if + * the editor can't tell. Returns TOP if there is no further line above, FAILED if the editor can't tell. + */ + internal fun extendLineBoundaries( + boundaries: MutableList, targetIndex: Int, oneLineUp: (Int) -> Int? + ): LineExtendResult { + while (boundaries.size <= targetIndex) { + val last = boundaries.last() + // at the very start of the text an up key can't move the caret, and the editor might use it to move focus + if (last <= 0) return LineExtendResult.TOP + val up = oneLineUp(last) ?: return LineExtendResult.FAILED + if (up >= last) return LineExtendResult.TOP + boundaries.add(up.coerceAtLeast(0)) + } + return LineExtendResult.REACHED + } + internal fun getLineBoundariesBackwards(text: String, endOffset: Int): List { val boundaries = mutableListOf() boundaries.add(endOffset) diff --git a/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt b/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt index 256ba7e68..095c5697e 100644 --- a/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt +++ b/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt @@ -270,6 +270,19 @@ class RichInputConnection(private val mParent: InputMethodService) : PrivateComm return true } + /** + * Synchronously asks the editor for its current selection as (anchor, extent) in absolute offsets. + * Returns null if the editor does not provide extracted text. + */ + fun getEditorSelection(): Pair? { + if (!isConnected()) return null + // only the selection is needed, so don't ask for the whole text of large fields + val request = ExtractedTextRequest().apply { hintMaxChars = 10240 } + val et = mIC?.getExtractedText(request, 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 diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 9e6800e47..8805f9b81 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -137,6 +137,7 @@ object Defaults { const val PREF_VERTICAL_SWIPE_THRESHOLD = 24 const val PREF_DELETE_SWIPE = true const val PREF_DELETE_SWIPE_WORD_BY_WORD = false + const val PREF_DELETE_SWIPE_EXACT_LINES = false const val PREF_AUTOSPACE_AFTER_PUNCTUATION = false const val PREF_AUTOSPACE_AFTER_EMOJI = false const val PREF_AUTOSPACE_AFTER_SUGGESTION = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt index b98d91a79..452446d23 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt @@ -395,6 +395,7 @@ class Settings private constructor() : SharedPreferences.OnSharedPreferenceChang const val PREF_VERTICAL_SWIPE_THRESHOLD = "vertical_swipe_threshold" const val PREF_DELETE_SWIPE = "delete_swipe" const val PREF_DELETE_SWIPE_WORD_BY_WORD = "delete_swipe_word_by_word" + const val PREF_DELETE_SWIPE_EXACT_LINES = "delete_swipe_exact_lines" const val PREF_FULL_KEYBOARD_SWIPE = "full_keyboard_swipe" const val PREF_FULL_SWIPE_SWITCH_LANGUAGE = "full_swipe_switch_language" const val PREF_AUTOSPACE_AFTER_PUNCTUATION = "autospace_after_punctuation" diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt index b10f387ac..96744b92b 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt @@ -108,6 +108,7 @@ open class SettingsValues( val mClipboardSkipSensitive: Boolean val mDeleteSwipeEnabled: Boolean val mDeleteSwipeWordByWord: Boolean + val mDeleteSwipeExactLines: Boolean val mAutospaceAfterPunctuation: Boolean val mAutospaceAfterEmoji: Boolean val mAutospaceAfterSuggestion: Boolean @@ -450,6 +451,7 @@ open class SettingsValues( mClipboardSkipSensitive = prefs.getBoolean(Settings.PREF_CLIPBOARD_SKIP_SENSITIVE, Defaults.PREF_CLIPBOARD_SKIP_SENSITIVE) mDeleteSwipeEnabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE) mDeleteSwipeWordByWord = prefs.getBoolean(Settings.PREF_DELETE_SWIPE_WORD_BY_WORD, Defaults.PREF_DELETE_SWIPE_WORD_BY_WORD) + mDeleteSwipeExactLines = prefs.getBoolean(Settings.PREF_DELETE_SWIPE_EXACT_LINES, Defaults.PREF_DELETE_SWIPE_EXACT_LINES) mAutospaceAfterPunctuation = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, Defaults.PREF_AUTOSPACE_AFTER_PUNCTUATION) mAutospaceAfterEmoji = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_EMOJI, Defaults.PREF_AUTOSPACE_AFTER_EMOJI) mAutospaceAfterSuggestion = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_SUGGESTION, Defaults.PREF_AUTOSPACE_AFTER_SUGGESTION) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt index a1cdae9d6..5bbccc75e 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -105,6 +105,7 @@ fun GestureTypingScreen( add(Settings.PREF_TOOLBAR_SWIPE_DOWN_GESTURE) add(Settings.PREF_VERTICAL_SWIPE_THRESHOLD) add(Settings.PREF_DELETE_SWIPE) + add(Settings.PREF_DELETE_SWIPE_EXACT_LINES) add(R.string.settings_category_touchpad) add(Settings.PREF_TOUCHPAD_SENSITIVITY) @@ -356,6 +357,14 @@ fun createGestureTypingSettings(context: Context) = listOf( enabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE) ) }, + Setting(context, Settings.PREF_DELETE_SWIPE_EXACT_LINES, R.string.delete_swipe_exact_lines, R.string.delete_swipe_exact_lines_summary) { + val prefs = LocalContext.current.prefs() + SwitchPreference( + it, + Defaults.PREF_DELETE_SWIPE_EXACT_LINES, + enabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE) + ) + }, ) @Preview diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66a397983..4cab3e6db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -350,6 +350,10 @@ Delete word by word Select and delete whole words when swiping left from the delete key + + Exact lines when swiping up (experimental) + + Ask the text field for the real line above when swiping up from the delete key. Needs a multi-line field, may not work in every app. Otherwise lines are estimated Backup and restore @@ -1405,6 +1409,10 @@ New dictionary: Version View on GitHub + + Sponsor project + + Support the development of LeanType Sponsor this project This project is completely community-driven and relies on donations to keep development alive. If you find this keyboard useful, please consider sponsoring! diff --git a/app/src/test/java/helium314/keyboard/keyboard/WordDeleteSwipeTest.kt b/app/src/test/java/helium314/keyboard/keyboard/WordDeleteSwipeTest.kt index 6a7053527..333e4eea1 100644 --- a/app/src/test/java/helium314/keyboard/keyboard/WordDeleteSwipeTest.kt +++ b/app/src/test/java/helium314/keyboard/keyboard/WordDeleteSwipeTest.kt @@ -160,4 +160,63 @@ class WordDeleteSwipeTest { KeyboardActionListenerImpl.getWordBoundariesForwards(text, endOffset, startOffset) ) } + + // fake editor with fixed-width visual lines of 10 chars: up keeps the column, first line goes to offset 0 + private fun fakeLineUp(position: Int): Int? = if (position < 10) 0 else position - 10 + + @Test + fun `line boundaries are extended one visual line at a time`() { + val boundaries = mutableListOf(47) // swipe started at line 4, column 7 + val result = KeyboardActionListenerImpl.extendLineBoundaries(boundaries, 2) { fakeLineUp(it) } + assertEquals(KeyboardActionListenerImpl.LineExtendResult.REACHED, result) + assertEquals(listOf(47, 37, 27), boundaries) + } + + @Test + fun `line boundaries are not touched when the target already exists`() { + val boundaries = mutableListOf(47, 37) + var calls = 0 + val result = KeyboardActionListenerImpl.extendLineBoundaries(boundaries, 1) { calls++; fakeLineUp(it) } + assertEquals(KeyboardActionListenerImpl.LineExtendResult.REACHED, result) + assertEquals(listOf(47, 37), boundaries) + assertEquals(0, calls) + } + + @Test + fun `line boundaries stop at the top of the text`() { + val boundaries = mutableListOf(14) // line 1, column 4 + val result = KeyboardActionListenerImpl.extendLineBoundaries(boundaries, 5) { fakeLineUp(it) } + assertEquals(KeyboardActionListenerImpl.LineExtendResult.TOP, result) + // first step keeps the column, second goes to the very start, then there is nothing above + assertEquals(listOf(14, 4, 0), boundaries) + } + + @Test + fun `line boundaries report failure when the editor cannot move by lines`() { + val boundaries = mutableListOf(47) + val result = KeyboardActionListenerImpl.extendLineBoundaries(boundaries, 1) { null } + assertEquals(KeyboardActionListenerImpl.LineExtendResult.FAILED, result) + assertEquals(listOf(47), boundaries) + } + + @Test + fun `line boundaries keep entries found before a failure`() { + val boundaries = mutableListOf(47) + var calls = 0 + val result = KeyboardActionListenerImpl.extendLineBoundaries(boundaries, 3) { + if (calls++ == 0) fakeLineUp(it) else null + } + assertEquals(KeyboardActionListenerImpl.LineExtendResult.FAILED, result) + assertEquals(listOf(47, 37), boundaries) + } + + @Test + fun `editor is not asked for a line above the start of the text`() { + val boundaries = mutableListOf(0) + var calls = 0 + val result = KeyboardActionListenerImpl.extendLineBoundaries(boundaries, 3) { calls++; fakeLineUp(it) } + assertEquals(KeyboardActionListenerImpl.LineExtendResult.TOP, result) + assertEquals(listOf(0), boundaries) + assertEquals(0, calls) + } }