Skip to content

fix(ime): atomic per-frame output + force-show on hw-keyboard apps (bug #5) - #71

Merged
willwade merged 3 commits into
mainfrom
fix/ime-output-batching
Oct 15, 2026
Merged

willwade merged 3 commits into
mainfrom
fix/ime-output-batching

Conversation

@willwade

Copy link
Copy Markdown
Contributor

Summary

Fixes #70 (external user bug report #5).

Part 1 — jumbled text in OneNote/Word (fine in AquaMail). The IME applied every engine delta as its own binder call. Synchronous editors apply them in order; OneNote/Word route InputConnection edits through their own async editing pipeline where rapid unbatched deltas can land out of order or partially — garbled text (the Android cousin of Dasher-Windows #17's deletion-forwarding fix).

  • New ImeOutputCoalescer (pure, unit-tested, EditorSync.kt pattern): accumulates each frame's deltas into one Flush(deleteUnits, insert); a delete spanning the flushed/pending boundary is split exactly (UTF-16 units, surrogate pairs included)
  • flushOutputToTarget() applies it once per frame inside beginBatchEdit/endBatchEdit — the editor sees one atomic edit instead of N unrelated calls
  • deleteSurroundingText returning false (WebView/custom editors) now falls back to KEYCODE_DEL key events through the target's own key pipeline, instead of dropping the deletion and silently desyncing the engine buffer from the field
  • Buffer-clear (type 2) stays a no-op

Part 2 — "Dasher wouldn't pop up on Chrome… on the MuMu emulator." Emulators default to an attached hardware keyboard; Chrome never calls showSoftInput in that state (Firefox does). onEvaluateInputViewShown()=true only helps when someone asks. The IME now requestShowSelf()s on every fresh focus of a real text field (TYPE_NULL excluded) — consistent with the existing accessibility decision that our users always want the soft keyboard.

Testing

  • ImeOutputCoalescerTest — 10 new unit tests: commit merging, boundary-spanning deletes, delete-then-commit ordering, surrogate pairs, buffer-clear no-op, flush reset semantics
  • :app:assembleDebug + :app:testDebugUnitTest green locally (against the v0.2.26 submodule pin)
  • Manual: docked-mode typing into OneNote/Word (or another async-editor app) — no jumbling
  • Manual: emulator with hardware keyboard enabled → tap a Chrome text field → keyboard appears

Definition of Done

#5)

User report #5: "When using Dasher in docked mode, the typed letters
get completely jumbled up in applications like OneNote and Word, though
it works perfectly fine inside AquaMail" and "Dasher wouldn't pop up
on Chrome… on the MuMu emulator, I had to switch to Firefox."

Part 1 — jumbled text in OneNote/Word. The IME applied every engine
delta as its own binder call (one commitText per node entry, one
deleteSurroundingText per deletion). Editors with synchronous
InputConnections (AquaMail's EditText) apply those in order; OneNote
and Word route InputConnection edits through their own async editing
pipeline (undo coalescing, autosave) where a burst of unbatched deltas
can land out of order or partially — garbled text. Same class of
problem Dasher-Windows #17 fixed by forwarding deletions through the
target's own input path.

- ImeOutputCoalescer (pure, unit-tested): accumulates the frame's
  deltas into one Flush(deleteUnits, insert); a delete that spans the
  flushed/pending boundary is split exactly (UTF-16 units, surrogate
  pairs included).
- flushOutputToTarget(): applied once per frame after the canvas
  submit, inside beginBatchEdit/endBatchEdit — the editor sees one
  atomic edit, not N unrelated calls.
- Deletion fallback: if deleteSurroundingText returns false
  (WebView/custom editors), send KEYCODE_DEL key events through the
  target's own key pipeline instead of dropping the deletion (which
  silently desynced the engine buffer from the field — every later
  commit then landed wrong).
- Buffer-clear (type 2) stays a no-op: committed text belongs to the
  field; the engine re-anchors separately.

Part 2 — no popup on Chrome under a hardware keyboard. Emulators
(MuMu) ship with a hardware keyboard attached; in that state Chrome
never calls showSoftInput when a field gains focus (Firefox does).
onEvaluateInputViewShown()=true only helps when someone asks. The IME
now requestShowSelf()s on every fresh focus of a real text field
(TYPE_NULL excluded) — consistent with the existing accessibility
decision that our users always want the soft keyboard.

Signed-off-by: will wade <willwade@gmail.com>
…), flood cap, field-switch reset, contract docs

Addresses the #71 review (score 7/10):

- C1: the KEYCODE_DEL fallback sent one DEL per UTF-16 unit, but one DEL
  deletes one CHARACTER — a surrogate pair (any emoji; we ship an Emoji
  alphabet) would over-delete into the preceding character. New pure
  delKeyEventCount(units, beforeCursor) derives the count from the actual
  text before the caret (codepoints in the last N units); unit-tested.
- C2: bulk control-mode deletes could flood the target with 2×N key
  events in one frame (jank/ANR). DEL fallback is now capped at
  MAX_DEL_KEY_EVENTS=64; beyond it the deletion is skipped with a log
  (desync is recoverable, an ANR is not).
- I2: pending deltas from the previous field could land in the next
  focused field after a null-IC flush. onStartInput(restarting=false)
  now clears the coalescer — text must never appear in the wrong app.
- I1: the class doc claimed deletes always remove the buffer tail —
  false for control-mode deletes (CAPI.cpp ctrlDelete removes AT the
  cursor, possibly mid-buffer/forwards; the event carries no position
  or direction). Documented as an approximation (same as pre-batching
  behavior); upstream filed as DasherCore#100. editDelete also fires
  the callback when its guard rejects the erase — DasherCore#101.
- I3: tests for the risky paths — delKeyEventCount (surrogates, null,
  clamped), accumulating deletes, interleaved delete/commit/spanning-
  delete, clear() semantics. M3 (NewStringUTF mangling astral chars)
  split out as #72.
- M1/M2/M4/M5: Hide-vs-force-show documented as deliberate; InputType
  import; unknown event types logged; type-2 safety invariant noted.

Signed-off-by: will wade <willwade@gmail.com>
…overy, caret-relative docs, cap decision extracted

Final polish from the #71 second review (8/10):

- I-1: type-2 (buffer clear) now clears pending deltas — dasher.h says
  subscribers must 'clear your copy', and the pending state IS a shadow
  copy. The old comment claimed an 'always empty at reset' invariant the
  code itself violated (stranded null-connection flushes + seedBuffer's
  type-2 on every toolbar reanchor). Test added.
- M-1: a skipped DEL fallback (no-text or over-cap) now re-anchors the
  engine from the field so the desync cannot compound on later deletes.
- M-2: editDelete doc corrected — the real invariant is caret-relative
  ('N units before the caret' = deleteSurroundingText semantics);
  mid-buffer-cursor after ctrlMove joins ctrlDelete in the documented
  approximation bucket.
- M-3: delKeyEventCount mid-surrogate-pair boundary pinned as decided
  desync behavior (orphaned low surrogate counts as one codepoint).
- M-4: cap decision extracted to pure delFallbackPlan (Send/SkipNoText/
  SkipOverCap) with boundary tests at the cap.
- M-5: docs cite DasherCore#100/#101.

Signed-off-by: will wade <willwade@gmail.com>
@willwade
willwade merged commit fe78bd0 into main Oct 15, 2026
2 checks passed
willwade added a commit that referenced this pull request Oct 15, 2026
…e restore, engine re-entry guard, unbind cleanup (#74)

Fixes #73.

Heide (testing v0.1.21, before v0.1.22): "The IME still starts in float
mode, then you have to tap on dock. Then it's fine unless you switch
keyboards."

**Not a v0.1.22 regression** — v0.1.22's IME changes are output batching
+ force-show. This hardens #64's mode-remember machinery, which has
three lifecycle holes found by inspection:

1. **`onCreateInputView` re-entry on a live instance** (config change;
OEM IME-switch flows that keep the service alive): `createEngine()`
built a *second* engine — the first kept its `dasher_frame` loop, both
submitting into the same canvas — and a stale `floating=true` made the
posted restore early-return inside `enterFloatingMode()`, presenting a
dead overlay canvas as "floating" over a full-height docked root. Tap
Dock → clean; switch keyboards → repeats. This matches the report
verbatim.
2. **No `onUnbindInput` cleanup** — a surviving service left the overlay
hovering over the next keyboard.
3. **`exitFloatingMode` early-returned on `floatingView == null`** — a
swallowed `removeView` failure (Samsung overlay quirks, cf. #46)
stranded a dead overlay over every subsequent show.

## Changes (single file, +51/−4)

- `createEngine()`: re-entry guard — the engine is per-process by design
(#53/#56)
- Posted restore: **self-heal** — `if (floating)
exitFloatingMode(persist = false)` *before* evaluating the saved
preference, so every keyboard show converges to the persisted mode
regardless of view recreation
- `onUnbindInput()`: exit floating without persisting (saved mode stays
the user's deliberate choice)
- `exitFloatingMode(persist)`: never early-returns on a missing overlay
window; `removeView` failures now log

## Testing

- [x] `:app:assembleDebug` + `:app:testDebugUnitTest` green locally
- [ ] Manual on Heide's device (Samsung/Android 16): dock → switch to
SwiftKey → switch back → still docked, no dead overlay
- [ ] Float → switch away → back: overlay gone during the other
keyboard, float restored on return

Given #71's human review is pending, happy to fold this into the same
review session.

Signed-off-by: will wade <willwade@gmail.com>
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.

IME: jumbled text in OneNote/Word (docked mode) + keyboard never pops up on Chrome/hardware-keyboard (MuMu emulator)

1 participant