Skip to content

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

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

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

@LeanBitLab

Copy link
Copy Markdown
Owner

It still causing these issues

  1. Main Thread Blocking & IPC Overhead: Firing setSelection, KEYCODE_DPAD_UP, and synchronous getExtractedText over Binder during a 60-120fps touch drag causes severe frame drops and UI stutter (up to 60ms+ stalls per step).
  2. Third-Party App Side Effects: In many real-world apps (chat lists, forms, WebViews), sending DPAD_UP causes focus to escape the text field entirely, which immediately dismisses the keyboard. It also pollutes undo/redo histories and behaves asynchronously in browser engines.
  3. Selection Desync: Bypassing connection.hasSelection() and maintaining a parallel shadow selection (deleteSwipeShownStart) creates race conditions between the host editor and the IME, risking deleting the wrong text on finger lift.
  4. Stray Changes: The PR also includes unrelated sponsorship strings in strings.xml.

@Zokii0

Zokii0 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

In that case i'll close this PR, but tell me do you want me to try with requestTextBoundsInfo (as mentioned in this PR comment as alternative method)? It should fix all of your issues mentioned for this previous method, as that requestTextBoundsInfo won't using DPAD or any combinations of different functions. Drawbacks of using that method are that it only works from Android 14, and only if editor(s) implements it (i just googled and Chromium based browser should support it). Because of these drawbacks i didn't consider that method, and that's why i said for this previous method that is my last one to try, but if you want, we can try that requestTextBoundsInfo (and it would be seriously my last one, i asked 2 different Claude chats on 2 different accounts, and both couldn't come up with any new methods or ideas for this implementation of exact vertical cursor deleting)

@LeanBitLab

Copy link
Copy Markdown
Owner

Let me research it further, there will be always an way

@Zokii0

Zokii0 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Hello, i just tested your latest update, but it's not ideal solution:

Screen_Recording_20261022_103902_NotallyX.mp4
Screen_Recording_20261022_105921_NotallyX.mp4

There's fix for it, but even with that fix i still find your previous 2D 40 characters solution better than this, so here's what i was told:

"Hi, I tested 4.2.9 and found a regression in the vertical delete swipe: when the text has no sentence punctuation (. ! ? … : ;), the first swipe up selects everything up to the start of the paragraph (video attached, the text is one long paragraph of random letters).

It looks like findSegmentBreaksBackwards only breaks on punctuation, so without any it returns no breaks and the whole paragraph becomes one “line”. The previous version broke at whitespace about 40 characters back, which handled this case.

A possible fix is in the attached leantype-segment-breaks-fix.md: only look for punctuation in the last ~80 characters, and if none is found, fall back to the old behaviour (whitespace about 40 chars back, hard break if there is none). It includes the full function and three unit tests. I haven’t compiled it, I only checked the logic separately.

Even with this fix, the vertical swipe stays an estimate. It doesn’t know the width of the field, the font, or where the editor actually wraps lines, so a swipe up moves by a sentence or about 40 characters, not by a real visual line. Only the editor knows its real line breaks.

requestTextBoundsInfo (Android 14+) would fix that part. The editor tells the keyboard where its lines break, asynchronously, so there are no key events, no focus loss, no blocking calls and no shadow selection. Where the editor doesn’t support it, or on older Android, it would fall back to your current estimate. I already have an implementation, opt-in and off by default, but it is not compiled or tested on a device yet.

Would you like me to send you the files, or open a draft PR? Or would you rather skip it, that’s fine too."

leantype-segment-breaks-fix.md

So if you want, i can later make PR with requestTextBoundsInfo which will have your previous method as fallback (here's example in real scenario where i just now tried to delete part of this message, to not be as that i only tried on random letters:

Screen_Recording_20261022_111011_Brave.mp4

)

@LeanBitLab

Copy link
Copy Markdown
Owner

yup it kinda broken now, the more we tried to make it complicated the less stable it become or for normal operations

@LeanBitLab

Copy link
Copy Markdown
Owner

maybe we should better simplify it

@Zokii0

Zokii0 commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

I still think simple solution would be to implement requestTextBoundsInfo with fallback being your previous method:

"I still think a more stable direction is requestTextBoundsInfo, with the old 2D 40-character estimate as the fallback. That would also mean reverting findSegmentBreaksBackwards to the previous whitespace-based estimate (the one before 4.2.9), since the sentence-punctuation logic is what breaks on text without punctuation. If you prefer to keep the new logic, the fix I attached earlier would work too, but reverting is the simpler option.

How requestTextBoundsInfo would work: on the first vertical step the keyboard asks the editor once, asynchronously, for the character bounds of the lines above the cursor, turns them into line starts and uses them instead of the estimate. The selection is still set with the same plain setSelection as now. No key events, no synchronous calls, no extra selection state. If the editor doesn’t answer (older Android, unsupported editor, timeout), the behaviour is exactly the old estimate, so a failure can’t make it worse than before. It would be opt-in.

Limits: Android 14+ only and it depends on the editor supporting it. I haven’t tested it on a device yet, so I’d open it as a draft for you to look at, not as something ready to merge."

@Zokii0

Zokii0 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

I'll make PR once i be home, but in case you can't wait that long:

leantype-textboundsinfo-4.2.9.zip

leantype-textboundsinfo-report.md

It includes both bringing back your previous stable (and good) method and adding requestTextBoundsInfo (which is optional and turned off by default)

Edit: i uploaded new files with only change being that option in setting is only visible for Android 14+ users, but of course your previous method stays for those who are under Android 14

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