Skip to content

Delete swipe: move by real visual lines (up swipe selects exactly one line above) - #702

Open
Zokii0 wants to merge 3 commits into
LeanBitLab:mainfrom
Zokii0:delete-swipe-visual-lines
Open

Zokii0 wants to merge 3 commits into
LeanBitLab:mainfrom
Zokii0:delete-swipe-visual-lines

Conversation

@Zokii0

@Zokii0 Zokii0 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Related: LeanType issue #467. Based on current main (same source as beta-429-9).

Problem

Swiping up on the delete key selects "lines" using getLineBoundariesBackwards(), which
does not know the real layout of the text field: it splits on \n and cuts longer
segments into chunks of roughly 40-50 characters. These guessed boundaries rarely match the
real soft-wrapped lines, so after an upward step the selection starts at an arbitrary word
somewhere in the previous line.

An earlier attempt (beta-429-7) asked the editor to do the movement with Shift+DPAD_UP on a
reversed selection (anchor = right end, extent = left end). The recording attached to the issue
shows it broken: the selection would not go further than the second line above, and it flickered
in that line when swiping up again. That approach was reverted in beta-429-8.

Likely cause (found by reading the code, not confirmed on a device): an editor reports a reversed
selection to the IME as selStart = anchor, selEnd = extent, so newSelStart > newSelEnd.
InputLogic.onUpdateSelection() passes these values to
resetCachesUponCursorMoveAndReturnSuccess(), which stores them in mExpectedSelStart/End
without normalising. The delete swipe then sees deleteSwipeInitialEnd != expectedSelectionEnd
at the next move event, re-initialises its state from the wrong values and the selection jumps back,
which looks like a stall plus flicker. This change never creates a reversed selection.

Approach

Let the editor find the position one visual line above, but with plain events and without
changing the selection logic:

  1. For an upward step, put a collapsed caret at the current left end of the swipe,
    send a plain DPAD_UP key event (the same events the toolbar arrow keys already send) and read
    the new caret position back with getExtractedText (synchronous).
  2. The result is remembered in deleteSwipeLineBoundaries (index 0 = where the swipe started,
    index n = left end after n upward steps, same column as the arrow key gives).
  3. Moving down does not ask the editor again: it just goes back to the stored entries, so
    up and down always return to exactly the same positions and never go below the original
    start.
  4. The user-visible selection is restored afterwards and the existing code applies the horizontal
    part (deleteSwipeCharOffset / deleteSwipeWordIndex) relative to the entry for the current line,
    so up + left (diagonal) simply adds characters/words left of the exact position above.
  5. All the temporary carets are inside one beginBatchEdit()/endBatchEdit(), so the editor
    reports only the final selection to the IME. The temporary selections are always collapsed or in
    normal order (start <= end), never reversed.

Everything else already in main keeps working unchanged: right-swipe deselection after a vertical
swipe (deleteSwipeSelectionAnchor() is still boundaries[0], the swipe start), the word-mode
forward boundaries, and the thumb-drift filter in PointerTracker.kt for a right swipe after a left swipe
(PointerTracker.kt is not modified).

Changes

  • KeyboardActionListenerImpl.kt
    • New state: deleteSwipeApproxLineBoundaries, deleteSwipeUseEditorLines, deleteSwipeEditorTopReached
      (initialised when a swipe starts, cleared in onUpWithDeletePointerActive() and resetMetaState()).
    • onMoveDeletePointer() extends the boundaries for upward steps before the existing char/word code runs.
    • New extendDeleteSwipeLinesByEditor() and editorPositionOneLineUp().
    • New companion helper extendLineBoundaries(boundaries, targetIndex, oneLineUp) (pure, unit tested) and
      LineExtendResult { REACHED, TOP, FAILED }.
  • RichInputConnection.kt: new getEditorSelection() (synchronous getExtractedText, asks only for a
    window of 10240 chars so large fields are not copied completely).
  • WordDeleteSwipeTest.kt: 5 new tests for extendLineBoundaries with a fake editor (extends one line at a
    time, does not call the editor if the entry exists, stops at the top of the text, reports failure,
    keeps entries found before a failure).

Fallbacks

  • Only used when the keyboard's editor info has the multi-line flag. Single-line fields keep the old
    approximation (an up arrow in a single-line field could move focus).
  • If the editor gives no extracted text, or the caret is not collapsed after the arrow key, the old approximated
    boundaries (getLineBoundariesBackwards) are used for the rest of that swipe.
  • If the editor reports no further line above (top of the text), the selection simply stops there.

Not verified

Not compiled and not run (no Android toolchain available to me). Please test on a device in:
a native EditText, a web textarea (Chrome/Brave), and a field with very long soft-wrapped lines with no spaces
(like the one in the beta-429-7 recording), each in char mode and word-by-word mode:

  • up, up, down, down (must return to the same positions, never below the original cursor);
  • up then right (deselect) and up + left (diagonal);
  • left then right with a slight upward drift (the drift filter must still apply);
  • release deletes exactly the highlighted text;
  • single-line field (old behavior), and the top of the text.

@Zokii0

Zokii0 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

@LeanBitLab Can you test this, or make test apk just for me to test it? You don't have to merge it for next version till it's tested, i want to polish this delete gesture as my last request (it shouldn't be broken like it was back then at version 429-7 , but will test it just in case). This should also solve all those previous issues i found while using your 2D solution. Sorry for troubling you with this same issue and thank you

@LeanBitLab

Copy link
Copy Markdown
Owner

Sending DPAD_UP key events and synchronous getExtractedText() requests during a live drag gesture can cause severe regressions:

  1. Focus Loss: In single-line inputs or when reaching line 0, DPAD_UP causes Android focus traversal to exit the input field entirely (e.g., navigating to URL bars or parent views).
  2. UI Thread Jank / ANR: Calling synchronous IPC (getExtractedText) repeatedly during touch drag on the main thread blocks smooth 60/120fps gesture rendering.
  3. Batch Edit Quirks: Many custom text editors (Chrome, Termux, Discord) ignore or malfunction when KeyEvent is dispatched inside an active batch edit.

@Zokii0

Zokii0 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for these detailed explanation of issues, i'll try update this PR if it's possible to solve all those you mentioned

@Zokii0

Zokii0 commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

PR #702 update: exact visual lines as an opt-in setting, review points addressed

Replaces the previous version of this change. Based on current main (+ the files of PR #702).

What changed since the first version

The review comment listed three problems with sending DPAD_UP and calling getExtractedText()
during a live drag. Changes in response:

  1. Opt-in, off by default. New setting "Exact lines when swiping up (experimental)"
    (delete_swipe_exact_lines, Defaults.PREF_DELETE_SWIPE_EXACT_LINES = false) in the gesture
    settings, next to the other delete swipe options and enabled only when delete swipe is on.
    Without it the behavior is exactly the current one (estimated lines), so nobody gets a regression
    unless they turn it on.
  2. Focus loss. Still only used in multi-line fields (editor info). New guard: when the last known
    position is 0 (start of the text) no key event is sent at all, because an up key that can't move the
    caret is the case where focus traversal can leave the field. Covered by a unit test
    (editor is not asked for a line above the start of the text).
  3. Batch edit. The beginBatchEdit()/endBatchEdit() around the key events is removed, so the arrow
    key events are no longer dispatched inside an active batch edit.
    Without the batch the editor reports the temporary carets to the IME. To keep the swipe from
    treating those as an external selection change, in this mode the swipe keeps its own record of the
    selection it last set (deleteSwipeShownStart) and
    • skips the deleteSwipeInitialEnd != expectedSelectionEnd re-initialisation check,
    • restores the visible selection from its own value after probing,
    • on release uses its own value to decide whether something is selected.
      The old (estimated lines) path keeps its original checks.
  4. Synchronous calls. Not eliminated, only limited: a request is only made when a swipe reaches a
    line that was not known yet (known lines are cached, moving down never asks again), and the time
    of each probe is measured. If it takes longer than 60 ms per line, probing stops for the rest of that
    swipe and is not used again in that app until the keyboard restarts (it falls back to the estimate there).
    This cannot interrupt a single call that is already blocking, so a very slow editor can still
    stall one request.

Everything from the previous version stays: positions found by the editor are cached
(index 0 = swipe start), moving down returns to the same cached positions, the horizontal char/word
offsets and the right-swipe deselect work relative to those positions, and PointerTracker.kt (thumb drift
filter) is not modified.

Files

  • KeyboardActionListenerImpl.kt: opt-in check, no batch edit, own selection record, probe timing,
    guard at position 0 in extendLineBoundaries.
  • RichInputConnection.kt: getEditorSelection() (unchanged from the previous version).
  • Settings.kt, Defaults.kt, SettingsValues.kt, GestureTypingScreen.kt, res/values/strings.xml:
    the new setting (English strings only).
  • WordDeleteSwipeTest.kt: tests for extendLineBoundaries (6 tests, one new for the position-0 guard).

What this does not solve

  • It still depends on the editor treating DPAD_UP as a plain caret move. Some editors (for example a
    terminal emulator) may use the up key for something else; that is why it is opt-in.
  • The synchronous request can still block for as long as one call takes.
  • Alternatives I looked at: CursorAnchorInfo only describes the text being composed, not the line above,
    and TextBoundsInfo/requestTextBoundsInfo needs Android 14+ and an editor that implements it,
    so neither works as a general replacement. It could be a later improvement on Android 14+.

Not verified

Not compiled and not run (no Android toolchain available to me). Please test with the setting on, in a
native EditText, a web textarea (Chrome/Brave) and a field with very long soft-wrapped lines, in char mode
and word-by-word mode: up, up, down, down; up then right; up + left; left then right with slight upward drift;
release deletes exactly the highlighted text; single-line field; top of the text; and with the setting off
(must behave like before).

@Zokii0

Zokii0 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

I think this is my last attempt for this, as Claude couldn't find any better solution (it gave me few, but none of them gives this close fix as this one, all of them have one or another issue), so if this doesn't work, or you dislike this solution, you can close this PR and keep your current 2D solution with 40 characters, it's better than nothing (not saying that your solution is bad, it's good, but thought if we can improve it), i won't trying any new methods, only previous thumb drift fix can stay

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants