From e96281aa5114bfbe9034916516ee60d78514d77b Mon Sep 17 00:00:00 2001 From: Zokii0 Date: Thu, 22 Oct 2026 21:24:44 +0200 Subject: [PATCH 1/2] Delete swipe: exact visual lines via requestTextBoundsInfo (Android 14+), opt-in --- .../keyboard/KeyboardActionListenerImpl.kt | 225 +++++++++++++----- .../java/helium314/keyboard/latin/LatinIME.kt | 6 + .../keyboard/latin/RichInputConnection.kt | 33 +++ .../keyboard/latin/settings/Defaults.kt | 1 + .../keyboard/latin/settings/Settings.kt | 1 + .../keyboard/latin/settings/SettingsValues.kt | 2 + .../keyboard/latin/utils/VisualLines.kt | 64 +++++ .../settings/screens/GestureTypingScreen.kt | 11 + app/src/main/res/values/strings.xml | 4 + .../keyboard/keyboard/WordDeleteSwipeTest.kt | 23 -- .../keyboard/latin/utils/VisualLinesTest.kt | 62 +++++ 11 files changed, 348 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/helium314/keyboard/latin/utils/VisualLines.kt create mode 100644 app/src/test/java/helium314/keyboard/latin/utils/VisualLinesTest.kt diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index a38c58725..3dc1b4018 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -1,11 +1,16 @@ // SPDX-License-Identifier: GPL-3.0-only package helium314.keyboard.keyboard +import android.graphics.RectF +import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.SystemClock import android.text.InputType import android.util.SparseArray import android.view.KeyEvent import android.view.inputmethod.InputMethodSubtype +import android.view.inputmethod.TextBoundsInfo import androidx.core.util.forEach import helium314.keyboard.event.Event import helium314.keyboard.event.HangulEventDecoder @@ -31,6 +36,7 @@ import helium314.keyboard.latin.inputlogic.InputLogic import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.SubtypeSettings +import helium314.keyboard.latin.utils.VisualLines import kotlin.math.abs import kotlin.math.min @@ -526,11 +532,24 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp private var deleteSwipeCharOffset = 0 private var deleteSwipeStepCount = 0 + // Exact visual lines (Android 14+, opt-in): the editor is asked once per swipe, asynchronously, where its lines + // break. Until the answer arrives vertical movement is held back (and applied afterwards); if there is no answer + // the estimated boundaries above are used, exactly as without this setting. + private val deleteSwipeHandler = Handler(Looper.getMainLooper()) + private var deleteSwipeGeneration = 0 + private var deleteSwipeExactRequested = false + private var deleteSwipeExactPending = false + private var deleteSwipePendingStepsY = 0 + private var deleteSwipeTimeout: Runnable? = null + // Editor (app) that didn't answer or doesn't support it: don't ask again there until the keyboard is restarted + private var noTextBoundsPackage: String? = null + override fun onMoveDeletePointer(steps: Int) { onMoveDeletePointer(steps, 0) } override fun onMoveDeletePointer(stepsX: Int, stepsY: Int) { + var stepsY = stepsY if (keyboardSwitcher.isGifPickerShowing) { keyboardSwitcher.onGifMoveDeletePointer(stepsX, stepsY) return @@ -548,8 +567,20 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp deleteSwipeLineIndex = 0 deleteSwipeCharOffset = 0 deleteSwipeStepCount = 0 + resetDeleteSwipeExactLines() + } + + if (stepsY != 0 && !deleteSwipeExactRequested) requestExactLines() + if (deleteSwipeExactPending && stepsY != 0) { + // wait for the editor's answer, vertical movement is applied then + deleteSwipePendingStepsY += stepsY + stepsY = 0 } + if (stepsX == 0 && stepsY == 0) return + moveDeletePointer(stepsX, stepsY) + } + private fun moveDeletePointer(stepsX: Int, stepsY: Int) { if (settings.current.mDeleteSwipeWordByWord) { onMoveDeletePointerWords2D(stepsX, stepsY) } else { @@ -557,6 +588,101 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } } + private fun resetDeleteSwipeExactLines() { + deleteSwipeGeneration++ + deleteSwipeExactRequested = false + deleteSwipeExactPending = false + deleteSwipePendingStepsY = 0 + deleteSwipeTimeout?.let { deleteSwipeHandler.removeCallbacks(it) } + deleteSwipeTimeout = null + } + + /** + * Once per swipe, on the first vertical movement: asks the editor where its visual lines break + * (`requestTextBoundsInfo`, Android 14+), for the lines just above the cursor. No key events and no + * selection changes are involved, and nothing blocks: the answer is handled in [onExactLinesResult]. + */ + private fun requestExactLines() { + deleteSwipeExactRequested = true + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return + if (!settings.current.mDeleteSwipeExactLines) return + val keyboardId = keyboardSwitcher.keyboard?.mId ?: return + if (!keyboardId.isMultiLine) return + val editorPackage = keyboardId.mEditorInfo.packageName + if (editorPackage != null && editorPackage == noTextBoundsPackage) return + // where the cursor line is, as reported by the editor (needs cursor updates, see InputLogic.startInput) + val anchor = connection.lastCursorAnchorInfo ?: return + val markerTop = anchor.insertionMarkerTop + val markerBottom = anchor.insertionMarkerBottom + val lineHeight = markerBottom - markerTop + if (markerTop.isNaN() || markerBottom.isNaN() || lineHeight <= 0f) return + + val generation = deleteSwipeGeneration + val baseStart = deleteSwipeStartForExactLines() + val bounds = RectF(-HUGE_HORIZONTAL, markerTop - EXACT_LINES_LOOKUP * lineHeight, HUGE_HORIZONTAL, markerBottom) + deleteSwipeExactPending = true + val sent = connection.requestTextBoundsInfo(bounds) { info -> + if (generation == deleteSwipeGeneration && deleteSwipeExactPending) + onExactLinesResult(info, baseStart, editorPackage) + } + if (!sent) { + deleteSwipeExactPending = false + return + } + val timeout = Runnable { + if (generation == deleteSwipeGeneration && deleteSwipeExactPending) onExactLinesResult(null, baseStart, editorPackage) + } + deleteSwipeTimeout = timeout + deleteSwipeHandler.postDelayed(timeout, EXACT_LINES_TIMEOUT_MILLIS) + } + + private fun deleteSwipeStartForExactLines() = deleteSwipeLineBoundaries.firstOrNull() ?: deleteSwipeInitialEnd + + private fun onExactLinesResult(info: TextBoundsInfo?, baseStart: Int, editorPackage: String?) { + deleteSwipeExactPending = false + deleteSwipeTimeout?.let { deleteSwipeHandler.removeCallbacks(it) } + deleteSwipeTimeout = null + // vertical movement was held back while waiting, so the line index is still 0 and swapping is safe + if (info == null) { + noTextBoundsPackage = editorPackage + } else if (deleteSwipeLineIndex == 0 && deleteSwipeLineBoundaries.firstOrNull() == baseStart) { + val exact = linesFromTextBoundsInfo(info, baseStart) + if (exact != null && exact.size > 1) deleteSwipeLineBoundaries = exact + } + val pending = deleteSwipePendingStepsY + deleteSwipePendingStepsY = 0 + if (pending != 0) moveDeletePointer(0, pending) + } + + /** Line boundaries (same layout as [getLineBoundariesBackwards]) from the editor's character geometry, or null. */ + private fun linesFromTextBoundsInfo(info: TextBoundsInfo, baseStart: Int): List? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return null + if (info.end <= info.start || info.start > baseStart) return null + // only characters before the swipe's start matter, and only the last ones if there are very many + val end = minOf(info.end, baseStart) + val start = maxOf(info.start, end - MAX_EXACT_LINES_CHARS) + val count = end - start + if (count <= 0) return null + val tops = FloatArray(count) + val bottoms = FloatArray(count) + val lineFeed = BooleanArray(count) + val rect = RectF() + for (i in 0 until count) { + info.getCharacterBounds(start + i, rect) + tops[i] = rect.top + bottoms[i] = rect.bottom + lineFeed[i] = info.getCharacterFlags(start + i) and TextBoundsInfo.FLAG_CHARACTER_LINEFEED != 0 + } + val starts = VisualLines.lineStarts(start, tops, bottoms, lineFeed) + if (starts.isEmpty()) return null + val textStartOffset = deleteSwipeInitialEnd - deleteSwipeInitialText.length + val charBefore = (start - 1 - textStartOffset).takeIf { it in deleteSwipeInitialText.indices }?.let { deleteSwipeInitialText[it] } + val firstIsRealLineStart = start == 0 || charBefore == '\n' + return VisualLines.buildBoundaries(baseStart, starts, firstIsRealLineStart, deleteSwipeApproxBoundaries(), textStartOffset.coerceAtLeast(0)) + } + + private fun deleteSwipeApproxBoundaries() = getLineBoundariesBackwards(deleteSwipeInitialText, deleteSwipeLineBoundaries.first()) + private fun onMoveDeletePointerChars2D(stepsX: Int, stepsY: Int) { val oldLineIndex = deleteSwipeLineIndex val oldCharOffset = deleteSwipeCharOffset @@ -689,6 +815,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp deleteSwipeLineIndex = 0 deleteSwipeCharOffset = 0 deleteSwipeStepCount = 0 + resetDeleteSwipeExactLines() if (!connection.hasSelection()) return inputLogic.finishInput() onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) @@ -709,6 +836,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp deleteSwipeLineIndex = 0 deleteSwipeCharOffset = 0 deleteSwipeStepCount = 0 + resetDeleteSwipeExactLines() } private fun onLanguageSlide(steps: Int, isVertical: Boolean = false): Boolean { @@ -1047,53 +1175,14 @@ 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 - private fun findSegmentBreaksBackwards(text: String, start: Int, end: Int): List { - val breaks = mutableListOf() - var subEnd = end - while (subEnd - start > 60) { - var breakPoint = -1 - // Look for sentence terminators (. ! ? …) followed by whitespace - for (i in (subEnd - 15) downTo (start + 15)) { - val c = text[i] - if ((c == '.' || c == '!' || c == '?' || c == '…') && i + 1 < subEnd && Character.isWhitespace(text[i + 1])) { - if (c == '.' && i > start && Character.isDigit(text[i - 1])) { - continue - } - var nextStart = i + 1 - while (nextStart < subEnd && Character.isWhitespace(text[nextStart])) { - nextStart++ - } - if (nextStart < subEnd && nextStart - start >= 15 && subEnd - nextStart >= 15) { - breakPoint = nextStart - break - } - } - } - // If no sentence break found, check for clause punctuation (: ;) - if (breakPoint == -1) { - for (i in (subEnd - 20) downTo (start + 20)) { - val c = text[i] - if ((c == ';' || c == ':') && i + 1 < subEnd && Character.isWhitespace(text[i + 1])) { - var nextStart = i + 1 - while (nextStart < subEnd && Character.isWhitespace(text[nextStart])) { - nextStart++ - } - if (nextStart < subEnd && nextStart - start >= 15 && subEnd - nextStart >= 15) { - breakPoint = nextStart - break - } - } - } - } - if (breakPoint != -1 && breakPoint < subEnd) { - breaks.add(breakPoint) - subEnd = breakPoint - } else { - break - } - } - return breaks - } + /** How many lines above the cursor line the editor is asked about for exact lines (beyond that: estimate). */ + private const val EXACT_LINES_LOOKUP = 40 + /** At most this many characters of the editor's answer are looked at. */ + private const val MAX_EXACT_LINES_CHARS = 4000 + /** No answer from the editor within this time (ms): use the estimate for this swipe and app. */ + private const val EXACT_LINES_TIMEOUT_MILLIS = 150L + /** Covers the full width of the editor, whatever its scroll position. */ + private const val HUGE_HORIZONTAL = 100_000f internal fun getLineBoundariesBackwards(text: String, endOffset: Int): List { val boundaries = mutableListOf() @@ -1106,11 +1195,20 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp if (lastNewline != -1) { val lineStart = lastNewline + 1 if (currentEnd > lineStart) { - val segBreaks = findSegmentBreaksBackwards(text, lineStart, currentEnd) - for (bp in segBreaks) { - val offset = endOffset - (text.length - bp) - if (boundaries.last() != offset) { + val segLength = currentEnd - lineStart + if (segLength > 50) { + var subEnd = currentEnd + while (subEnd - lineStart > 50) { + var breakPoint = (subEnd - 40).coerceAtLeast(lineStart) + while (breakPoint > lineStart && !Character.isWhitespace(text[breakPoint])) { + breakPoint-- + } + if (breakPoint <= lineStart) { + breakPoint = (subEnd - 40).coerceAtLeast(lineStart) + } + val offset = endOffset - (text.length - breakPoint) boundaries.add(offset) + subEnd = breakPoint } } val offset = endOffset - (text.length - lineStart) @@ -1120,20 +1218,25 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } currentEnd = lastNewline } else { - val lineStart = 0 - if (currentEnd > lineStart) { - val segBreaks = findSegmentBreaksBackwards(text, lineStart, currentEnd) - for (bp in segBreaks) { - val offset = endOffset - (text.length - bp) - if (boundaries.last() != offset) { - boundaries.add(offset) + if (currentEnd > 50) { + var subEnd = currentEnd + while (subEnd > 50) { + var breakPoint = (subEnd - 40).coerceAtLeast(0) + while (breakPoint > 0 && !Character.isWhitespace(text[breakPoint])) { + breakPoint-- } - } - val offset = endOffset - text.length - if (boundaries.last() != offset) { + if (breakPoint <= 0) { + breakPoint = (subEnd - 40).coerceAtLeast(0) + } + val offset = endOffset - (text.length - breakPoint) boundaries.add(offset) + subEnd = breakPoint } } + val offset = endOffset - text.length + if (boundaries.last() != offset) { + boundaries.add(offset) + } break } } diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.kt b/app/src/main/java/helium314/keyboard/latin/LatinIME.kt index 3b8ee7bfd..25a377253 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.kt +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.kt @@ -1045,6 +1045,12 @@ class LatinIME : InputMethodService(), keyboardSwitcher.deallocateMemory() } + override fun onUpdateCursorAnchorInfo(cursorAnchorInfo: android.view.inputmethod.CursorAnchorInfo) { + super.onUpdateCursorAnchorInfo(cursorAnchorInfo) + // only used by the delete swipe to know where the cursor line is, see KeyboardActionListenerImpl + inputLogic.connection.lastCursorAnchorInfo = cursorAnchorInfo + } + override fun onUpdateSelection( oldSelStart: Int, oldSelEnd: Int, newSelStart: Int, newSelEnd: Int, diff --git a/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt b/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt index 256ba7e68..f11a17c65 100644 --- a/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt +++ b/app/src/main/java/helium314/keyboard/latin/RichInputConnection.kt @@ -4,9 +4,12 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.graphics.Color +import android.graphics.RectF import android.inputmethodservice.InputMethodService import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.os.SystemClock import android.text.SpannableString import android.text.SpannableStringBuilder @@ -15,12 +18,16 @@ import android.text.TextUtils import android.text.style.BackgroundColorSpan import android.text.style.CharacterStyle import android.view.KeyEvent +import android.view.inputmethod.CursorAnchorInfo import android.view.inputmethod.CompletionInfo import android.view.inputmethod.CorrectionInfo import android.view.inputmethod.ExtractedText import android.view.inputmethod.ExtractedTextRequest import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager +import android.view.inputmethod.TextBoundsInfo +import android.view.inputmethod.TextBoundsInfoResult +import androidx.annotation.RequiresApi import helium314.keyboard.compat.AppQuirksManager import helium314.keyboard.keyboard.KeyboardSwitcher import helium314.keyboard.latin.common.Constants @@ -42,7 +49,9 @@ import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.NgramContextUtils import helium314.keyboard.latin.utils.StatsUtils import helium314.keyboard.latin.utils.TextRange +import java.util.concurrent.Executor import java.util.concurrent.TimeUnit +import java.util.function.Consumer import kotlin.math.max /** @@ -107,6 +116,7 @@ class RichInputConnection(private val mParent: InputMethodService) : PrivateComm } mIsActive = false mIC = null + lastCursorAnchorInfo = null mNestLevel = 0 mPendingInFlightDeletions = 0 } @@ -1438,6 +1448,29 @@ class RichInputConnection(private val mParent: InputMethodService) : PrivateComm fun isCursorPositionKnown(): Boolean = INVALID_CURSOR_POSITION != mExpectedSelStart + /** Latest cursor anchor info the editor sent (needs [requestCursorUpdates] with monitoring), or null. */ + @Volatile var lastCursorAnchorInfo: CursorAnchorInfo? = null + + /** + * Asks the editor for the geometry of the characters inside [bounds] (editor coordinates), without touching + * the selection or sending key events. Android 14+ only; [callback] runs on the main thread with null if the + * editor doesn't support the request or failed. Returns false if the request could not be sent at all. + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun requestTextBoundsInfo(bounds: RectF, callback: (TextBoundsInfo?) -> Unit): Boolean { + val ic = mIC ?: return false + val mainHandler = Handler(Looper.getMainLooper()) + return try { + ic.requestTextBoundsInfo(bounds, Executor { mainHandler.post(it) }, Consumer { result -> + callback(if (result.resultCode == TextBoundsInfoResult.CODE_SUCCESS) result.textBoundsInfo else null) + }) + true + } catch (e: Exception) { + Log.w(TAG, "requestTextBoundsInfo failed", e) + false + } + } + fun requestCursorUpdates(enableMonitor: Boolean, requestImmediateCallback: Boolean): Boolean { mIC = mParent.currentInputConnection 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 91bd5944e..578c6a096 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -140,6 +140,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 37f10490b..d82af15b0 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt @@ -396,6 +396,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 c337b8c76..f61a34fc0 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 @@ -454,6 +455,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/latin/utils/VisualLines.kt b/app/src/main/java/helium314/keyboard/latin/utils/VisualLines.kt new file mode 100644 index 000000000..fd32c8c65 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/VisualLines.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.utils + +/** + * Helpers to turn the character geometry an editor reports (`InputConnection.requestTextBoundsInfo`, Android 14+) + * into the visual line boundaries the delete swipe uses. Kept free of Android classes so it can be unit tested. + */ +object VisualLines { + /** + * Offsets (absolute, ascending) of the first character of every visual line in a range of characters. + * + * [tops] and [bottoms] hold the vertical extent of each character, index 0 being the character at [start]. + * A new line begins after a line feed, or when the vertical centre of a character lies outside the + * vertical extent of the line collected so far. The first entry is always [start], which is only a real + * line start if the range happens to begin at one (see [buildBoundaries]). + */ + fun lineStarts(start: Int, tops: FloatArray, bottoms: FloatArray, lineFeed: BooleanArray): List { + val n = minOf(tops.size, bottoms.size, lineFeed.size) + if (n == 0) return emptyList() + val starts = mutableListOf(start) + var lineTop = tops[0] + var lineBottom = bottoms[0] + for (i in 1 until n) { + val centerY = (tops[i] + bottoms[i]) / 2 + val newLine = lineFeed[i - 1] || centerY > lineBottom || centerY < lineTop + if (newLine) { + starts.add(start + i) + lineTop = tops[i] + lineBottom = bottoms[i] + } else { + lineTop = minOf(lineTop, tops[i]) + lineBottom = maxOf(lineBottom, bottoms[i]) + } + } + return starts + } + + /** + * Boundaries for the delete swipe in the same layout as `getLineBoundariesBackwards`: + * index 0 = [baseStart], then the start of the line the selection begins in, then the lines above it. + * + * [exactStarts] are the line starts from [lineStarts]. If [firstIsRealLineStart] is false the range began in the + * middle of a line, so its first entry is not a real line start and is dropped. Lines older than the exact + * ones are continued from [approxBoundaries] (the estimate), so swiping further up than the editor was asked + * about still works. Entries below [minOffset] are not used (that is as far back as the swipe knows the text). + */ + fun buildBoundaries( + baseStart: Int, + exactStarts: List, + firstIsRealLineStart: Boolean, + approxBoundaries: List, + minOffset: Int, + ): List { + val result = mutableListOf(baseStart) + val usable = if (firstIsRealLineStart) exactStarts else exactStarts.drop(1) + for (s in usable.asReversed()) { + if (s < result.last() && s >= minOffset) result.add(s) + } + for (b in approxBoundaries) { + if (b < result.last() && b >= minOffset) result.add(b) + } + return result + } +} 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..584ecbc2e 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,9 @@ fun GestureTypingScreen( add(Settings.PREF_TOOLBAR_SWIPE_DOWN_GESTURE) add(Settings.PREF_VERTICAL_SWIPE_THRESHOLD) add(Settings.PREF_DELETE_SWIPE) + // only useful where the editor can be asked for its lines (requestTextBoundsInfo, Android 14+) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + add(Settings.PREF_DELETE_SWIPE_EXACT_LINES) add(R.string.settings_category_touchpad) add(Settings.PREF_TOUCHPAD_SENSITIVITY) @@ -356,6 +359,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 a94615445..04a583ae9 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 where its lines break (Android 14 or newer, multi-line fields). If the field does not answer, lines are estimated as before Backup and restore diff --git a/app/src/test/java/helium314/keyboard/keyboard/WordDeleteSwipeTest.kt b/app/src/test/java/helium314/keyboard/keyboard/WordDeleteSwipeTest.kt index 42a4ee35a..6a7053527 100644 --- a/app/src/test/java/helium314/keyboard/keyboard/WordDeleteSwipeTest.kt +++ b/app/src/test/java/helium314/keyboard/keyboard/WordDeleteSwipeTest.kt @@ -3,7 +3,6 @@ package helium314.keyboard.keyboard import org.junit.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue class WordDeleteSwipeTest { @@ -97,28 +96,6 @@ class WordDeleteSwipeTest { assertEquals(listOf(50, 44, 37), boundaries) } - @Test - fun `long line with newline exceeding 50 chars remains intact`() { - val text = "This is a line of text that has more than fifty characters in it without soft wrap\nsecond line" - val boundaries = KeyboardActionListenerImpl.getLineBoundariesBackwards(text, text.length) - val line2Start = text.indexOf("second line") - assertEquals(listOf(text.length, line2Start, 0), boundaries) - assertEquals("second line", text.substring(boundaries[1], boundaries[0])) - assertEquals("This is a line of text that has more than fifty characters in it without soft wrap", text.substring(boundaries[2], boundaries[1]).trim()) - } - - @Test - fun `multi sentence paragraph without newline splits at sentence boundaries`() { - val text = "First sentence of paragraph. Second sentence of paragraph is longer. Third sentence here." - val boundaries = KeyboardActionListenerImpl.getLineBoundariesBackwards(text, text.length) - for (i in 1 until boundaries.size) { - val start = boundaries[i] - assertTrue(start == 0 || Character.isWhitespace(text[start - 1])) - } - assertEquals(text.length, boundaries.first()) - assertEquals(0, boundaries.last()) - } - @Test fun `diagonal swipe word boundaries before line start`() { val initialText = "first line\nsecond line\nthird line" diff --git a/app/src/test/java/helium314/keyboard/latin/utils/VisualLinesTest.kt b/app/src/test/java/helium314/keyboard/latin/utils/VisualLinesTest.kt new file mode 100644 index 000000000..8f67111ea --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/utils/VisualLinesTest.kt @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.utils + +import org.junit.Test +import kotlin.test.assertEquals + +class VisualLinesTest { + // one entry per char, lines of the given lengths, 10px high, no linefeed flags + private fun geometry(vararg lineLengths: Int): Triple { + val tops = mutableListOf() + lineLengths.forEachIndexed { line, len -> repeat(len) { tops.add(line * 10f) } } + val t = tops.toFloatArray() + return Triple(t, FloatArray(t.size) { t[it] + 10f }, BooleanArray(t.size)) + } + + @Test + fun `soft wrapped lines are found by vertical position`() { + val (t, b, f) = geometry(5, 3, 4) + assertEquals(listOf(100, 105, 108), VisualLines.lineStarts(100, t, b, f)) + } + + @Test + fun `line feed starts a new line`() { + val t = floatArrayOf(0f, 0f, 0f, 10f, 10f) + val b = floatArrayOf(10f, 10f, 10f, 20f, 20f) + val f = booleanArrayOf(false, false, true, false, false) + assertEquals(listOf(0, 3), VisualLines.lineStarts(0, t, b, f)) + } + + @Test + fun `line feed flag alone is enough when geometry is identical`() { + val t = FloatArray(4) + val b = FloatArray(4) { 10f } + val f = booleanArrayOf(false, true, false, false) + assertEquals(listOf(0, 2), VisualLines.lineStarts(0, t, b, f)) + } + + @Test + fun `empty input gives no lines`() { + assertEquals(emptyList(), VisualLines.lineStarts(0, floatArrayOf(), floatArrayOf(), booleanArrayOf())) + } + + @Test + fun `boundaries start at base and go upwards`() { + assertEquals(listOf(60, 45, 20, 0), VisualLines.buildBoundaries(60, listOf(0, 20, 45), true, emptyList(), 0)) + } + + @Test + fun `no duplicate when base is already a line start`() { + assertEquals(listOf(45, 20, 0), VisualLines.buildBoundaries(45, listOf(0, 20, 45), true, emptyList(), 0)) + } + + @Test + fun `partial first line is dropped and estimate continues`() { + assertEquals(listOf(60, 45, 25, 0), VisualLines.buildBoundaries(60, listOf(30, 45), false, listOf(60, 45, 25, 0), 0)) + } + + @Test + fun `entries before the known text are not used`() { + assertEquals(listOf(60, 45, 30), VisualLines.buildBoundaries(60, listOf(0, 30, 45), true, emptyList(), 30)) + } +} From 01329ce6a0f8fc5e5f6e7db5b1b452cd72c12a6c Mon Sep 17 00:00:00 2001 From: Zokii0 Date: Thu, 22 Oct 2026 21:54:59 +0200 Subject: [PATCH 2/2] Fix TextBoundsInfo method names (getStartIndex/getEndIndex) --- .../keyboard/keyboard/KeyboardActionListenerImpl.kt | 6 +++--- 1 file changed, 3 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 3dc1b4018..da6341a9e 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -657,10 +657,10 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp /** Line boundaries (same layout as [getLineBoundariesBackwards]) from the editor's character geometry, or null. */ private fun linesFromTextBoundsInfo(info: TextBoundsInfo, baseStart: Int): List? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return null - if (info.end <= info.start || info.start > baseStart) return null + if (info.endIndex <= info.startIndex || info.startIndex > baseStart) return null // only characters before the swipe's start matter, and only the last ones if there are very many - val end = minOf(info.end, baseStart) - val start = maxOf(info.start, end - MAX_EXACT_LINES_CHARS) + val end = minOf(info.endIndex, baseStart) + val start = maxOf(info.startIndex, end - MAX_EXACT_LINES_CHARS) val count = end - start if (count <= 0) return null val tops = FloatArray(count)