Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>()
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)
Expand All @@ -537,26 +547,87 @@ 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 {
onMoveDeletePointerChars2D(stepsX, stepsY)
}
}

/**
* 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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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<Int>, 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<Int> {
val boundaries = mutableListOf<Int>()
boundaries.add(endOffset)
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int, Int>? {
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,10 @@
<string name="delete_swipe_word_by_word">Delete word by word</string>
<!-- Description for "delete_swipe_word_by_word" option. -->
<string name="delete_swipe_word_by_word_summary">Select and delete whole words when swiping left from the delete key</string>
<!-- Preferences item for exact line selection when swiping up from the delete key (experimental) -->
<string name="delete_swipe_exact_lines">Exact lines when swiping up (experimental)</string>
<!-- Description for "delete_swipe_exact_lines" option. -->
<string name="delete_swipe_exact_lines_summary">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</string>
<!-- Preferences item and dialog title for backup and restore -->
<string name="backup_restore_title">Backup and restore</string>
<!-- Message for backup and restore dialog -->
Expand Down Expand Up @@ -1405,6 +1409,10 @@ New dictionary:
<string name="version">Version</string>
<!-- github repo link for about screen -->
<string name="about_github_link">View on GitHub</string>
<!-- sponsor link for about screen -->
<string name="about_sponsor_link">Sponsor project</string>
<!-- description for sponsor link -->
<string name="about_sponsor_link_description">Support the development of LeanType</string>
<!-- sponsor dialog -->
<string name="sponsor_dialog_title">Sponsor this project</string>
<string name="sponsor_dialog_message">This project is completely community-driven and relies on donations to keep development alive. If you find this keyboard useful, please consider sponsoring!</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading