Skip to content

fix(ime): render preedit inline and stop Windows key-repeat from stalling redraws - #1846

Closed
raphamorim wants to merge 8 commits into
mainfrom
ime-preedit-inline
Closed

raphamorim wants to merge 8 commits into
mainfrom
ime-preedit-inline

Conversation

@raphamorim

@raphamorim raphamorim commented Aug 9, 2026 •

Copy link
Copy Markdown
Owner

Supersedes #1557 (commits by @Tryanks and @shiena, rebased onto main's grid pipeline, plus review fixes). Draft — deep review 2026-08-10 found blocking defects in both halves; punch list below. CI is green, but CI cannot see any of these.

What this PR does

  • Inline IME preedit rendering for rioterm (wezterm-style block at the cursor with a caret beam) — rioterm currently renders no preedit at all.
  • Windows: redraws via a posted message so IME key-repeat can't starve WM_PAINT, plus IME message-handshake perf changes.

Blockers found by deep review (must fix before un-drafting)

Rendering half:

  1. The caret beam is invisible in its primary case — it's drawn in named_colors.cursor on a block filled with the same color (grid_emit.rs:2340 vs 1108-1110). Arrow-keying between Japanese conversion segments — the reason the caret exists — shows nothing.
  2. Bottom-row truncation — the overlay wraps down and breaks at the last row (renderer/preedit.rs:82-98). A shell prompt on the bottom row (the dominant case) composing CJK near the right edge loses the tail and the caret. Needs the alacritty/wezterm slide-left fallback.
  3. Scrollback mid-composition — cursor.state.pos is unshifted by display offset, and preedit forces a Block cursor before the visibility check (grid_emit.rs:527-531), so scrolling back paints the overlay + cursor over history at a stale position. Should suppress when display_offset > 0.
  4. Stale preedit across split/tab switches — Ime::Preedit mutates only the current context and nothing clears it on switch; the old split keeps a frozen block and swallows Enter/Backspace/Ctrl-C on refocus (screen/mod.rs:694-696) until a new composition clears it. Needs preedit-clear on context switch.
  5. Ime::Disabled clears preedit without damage/redraw (application.rs:1983-1991) — switching input source mid-composition leaves a ghost overlay.
  6. Grapheme clusters unrepresentable — one-char-per-cell layout gives ZWJ-emoji three emoji plus tofu cells, and decomposed e+U+0302 renders as two cells. Needs grapheme segmentation in the overlay model.

Windows half:
7. Lost redraw in the buffered branch — the REDRAW_REQUESTED_MSG handler calls clear_dirty (disarming the vsync backstop), then parks the request in redraw_requested, which only WM_PAINT reads — and nothing invalidates (event_loop.rs:2697-2712). Needs the RDW_INTERNALPAINT re-arm the WM_PAINT arm has.
8. Double-paint race — vblank worker swap(false) + RedrawWindow can fire before the posted MSG dispatches (likely exactly when the queue is flooded — the PR's own premise), yielding two paints per request (vsync.rs:114-141).
9. In-handler request_redraw re-posts unpaced (clear-at-entry) and double-dispatches via the WM_PAINT re-arm — animation loops get 2× events with no vblank pacing.

Also to fold in

  • Fix WM_IME_SETCONTEXT masking — it masks ISC_SHOWUICOMPOSITIONWINDOW off wParam, but the flag lives in lParam (upstream winit fixed this; the fork predates it). The mask is currently a no-op, which means the OS composition window suppression on main has never worked — and this fix must ship with this PR, never before it, or Windows users lose their only visible composition UI.
  • Restore DefWindowProc for WM_IME_STARTCOMPOSITION/ENDCOMPOSITION — verified against both wezterm (no STARTCOMPOSITION handler at all) and upstream winit (DefWindowProc for both): consuming them has no precedent and risks candidate-window placement under legacy IMM32 IMEs. With the SETCONTEXT mask fixed, suppression works the documented way.
  • Check PostMessageW result (failure currently wedges the coalescing latch — one line), gate the MSG dispatch on window visibility, skip empty Commit("") on cancel-with-GCS_RESULTSTR-size-0 IMEs.
  • Smaller: OSC 12 splits the block into two cursor colors; PreeditOverlay::get lacks a column bound; ImeCaret atlas key omits cell_h; overlay allocates rows×cols every frame (~190 KB at 300×80) — a sparse representation is O(preedit).

What held up under attack

Run-cache keying (no poisoning either direction — verified against the main emit loop's hasher scheme), Preedit::new char-boundary hardening (fixes a real panic vector from macOS surrogate-split offsets), BOOL_IS_CURSOR_GLYPH semantics in both render backends, wide-char lead/spacer math, run-breaking and decoration suppression, damage on preedit change, the Hangul IME fixes surviving the merge, WM_PAINT validation, vsync thread lifecycle, and the ENDCOMPOSITION stale-preedit guard.

Original verification: cargo check/clippy clean, rio-window type-checks for x86_64-pc-windows-msvc, 182 rioterm tests pass.

Tryanks and others added 8 commits August 10, 2026 00:07
The v4 grid renderer (build_row_bg/fg in grid_emit) reads cells
straight from terminal state and has no hook for IME composition, so
preedit text was invisible past the first char that landed on the
cursor cell. Plumb a PreeditOverlay through the row emit pass so the
composing string actually paints, and shape it like wezterm: every
composition cell takes the cursor color as a block bg with the glyph
inverted on top, the IME caret breaks the block as a thin vertical
beam, and CJK wide chars stay contiguous (Spacer cells emit no glyph
so the wide leading char's two-cell advance covers the continuation
without literal-space gaps).

- ime: tighten Preedit::new (reject non-char-boundary byte_offset);
  pin cursor anchor for the active session so a composition started
  right after Home/Ctrl-A or a commit doesn't snap to the previous
  line end while the PTY echo catches up.
- application: mark TerminalDamage::Full on preedit change so the
  overlay refreshes — the grid would otherwise keep its previous
  CPU buffers.
- renderer/preedit: PreeditOverlay maps the composition string onto
  visible cells, tracks the IME cursor position, and exposes a
  per-row "any preedit?" bit for the build_row_* fast paths.
- renderer/mod (Renderer::run): capture the cursor the user last
  saw before the snapshot overwrites it; if a preedit is active,
  swap in the anchored position via Ime::preedit_anchor_or_init.
- grid_emit: PreeditRow threaded through build_row_bg/fg;
  build_row_bg overrides bg to the cursor color on preedit cells;
  build_row_fg breaks runs at preedit boundaries and emits each
  composition char as a single-cell shape with bg-color fg +
  BOOL_IS_CURSOR_GLYPH so the cell-text shader keeps our inverse
  color instead of swapping it. Plain font attrs are forced so an
  italic prompt segment under the cursor doesn't bleed into the
  composing text. Underlines / strikethroughs skip preedit cells.
- DecorationStyle::ImeCaret: thin full-cell-height vertical bar
  rasterized as a grayscale sprite, emitted at the IME cursor
  column to break the block where arrow-key navigation lands.

Co-authored-by: KOGA Mitsuhiro <shiena.jp@gmail.com>
c39803c pinned the preedit overlay to the cursor captured at
composition start so it would not drift as the PTY thread caught up
with a just-committed run or a cursor-movement key. Pinning cures the
drift but freezes whichever transient position the first frame
happens to sample:

  - seeding from the previous render's cursor leaves the anchor on
    the pre-movement line end when a Home (or Up) after a wrapped
    line coalesces with the preedit event into one frame, so the
    overlay snaps to the wrap's end;
  - seeding from the snapshot cursor instead can catch the PTY
    mid-escape-sequence for Home (e.g. row-move processed, column
    move not yet), freezing the overlay a few cells to the right of
    the real line end.

Either seed trades drift for a stuck-at-wrong-position bug whose
exact symptom depends on which phase of PTY processing the first
frame lands on. wezterm's renderer reads `term.cursor_pos()` on
every frame and accepts the short flicker while PTY output settles;
the overlay always converges to the right place. Match that: drop
the anchor + pinning machinery and let the overlay follow whatever
`terminal_snapshot.cursor` reports each frame.
…s live

Under sustained IME key-repeat (corvus-skk hiragana holding a vowel)
each tick floods the message queue with WM_KEYDOWN + the three
WM_IME_* messages + a TerminalDamaged user event. The drain loop
never sees an empty queue, so `about_to_wait` — which is the only
site that calls `scheduler.update()` — never fires. The render
scheduler timer therefore never expires, and even if it did, its
RedrawRequested would still depend on WM_PAINT, the lowest-priority
slot in a Win32 message queue and also starved by the same flood.
Net effect: every `あ` is pasted to the PTY and echoed by the shell
while held, but the screen only repaints when the key is released
and the queue finally drains.

Register a custom `REDRAW_REQUESTED_MSG` via `RegisterWindowMessageA`
and post it from `Window::request_redraw`. Regular posted messages
are returned by `PeekMessageW` ahead of the synthesized WM_PAINT,
so the paint trigger interleaves with the IME flood. On the
application side, `RioEvent::TerminalDamaged` skips the scheduler
timer and calls `request_redraw` directly — vblank-rate pacing
still belongs to the DwmFlush VSync worker via the dirty flag, so
we aren't burning frames.
The previous commit's `Window::request_redraw` both posts
`REDRAW_REQUESTED_MSG` *and* raises the DwmFlush worker's dirty
flag, so each paint request caused two `RedrawRequested` dispatches
per vblank: one via the custom message, one via the worker's
`RedrawWindow(RDW_INVALIDATE)` -> `WM_PAINT` path. The second one
is a no-op inside per-context render (the dirty flag was already
cleared by the first pass) but still burns a frame of
`begin_render` / `pre_present_notify` / present work.

Clear the worker's dirty flag from the `REDRAW_REQUESTED_MSG`
handler so the next tick sees it false and skips the redundant
invalidate. Also drop the `present_after_input` fallback (and the
`last_input_timestamp` / `mark_input_received` plumbing that fed
it) since rio already drives redraws explicitly via `request_redraw`
on every event that can mutate the terminal — the fallback was
driving another 60 fps stream of invalidations during any input
window, stacking on top of the custom-message paints.
…handshake

Tracing the WndProc for a held 'a' in corvus-skk hiragana
direct-input showed a very sharp pattern: WM_IME_STARTCOMPOSITION
was delivered immediately after WM_KEYDOWN, but the paired
WM_IME_COMPOSITION only arrived ~95–100 ms later, with the main
thread completely idle in between. The OS auto-repeat (~30 Hz)
kept queuing WM_KEYDOWN messages with accumulating repeat counts
(lparam low word 2, 4, …) while we were stuck waiting, so the
user only saw ~10 cps. Forwarding the IME messages to
`DefWindowProc` runs a synchronous handshake with the TIP — that
handshake is the 100 ms wait.

Return 0 for both START and ENDCOMPOSITION the way wezterm does
for ENDCOMPOSITION. The composition result still arrives via
`WM_IME_COMPOSITION`, just without the handshake stall, and the
per-keystroke cycle drops to ~31 ms — matching the OS auto-repeat
cadence (confirmed on a corvus-skk hiragana trace: 21.502086 s
START → 21.502169 s COMPOSITION, i.e. 0.08 ms vs. the prior
~98 ms).

Drop the `Ime::Enabled` / `Ime::Disabled` dispatches that START /
END used to send: the app's handler only toggled an unread
`enabled` flag, so for IMEs that fire this whole trio every
keystroke we were paying one full event-handler round-trip per
press for nothing.

The pre-commit `Ime::Preedit("")` in the GCS_RESULTSTR branch is
intentionally kept: most IMEs (MS-IME, Google Japanese Input,
ATOK) fire WM_IME_COMPOSITION on confirm with only GCS_RESULTSTR
— no GCS_COMPSTR — so the GCS_COMPSTR branch below never runs and
the app's `ime.preedit` would otherwise stay `Some(...)` from the
last preedit update. `process_key_event` short-circuits while a
preedit is active, so without this clear every keystroke after a
commit is silently dropped. For direct-input IMEs (corvus-skk
hiragana) that never had a preedit, the app's handler skips
damage/redraw because `None != None` is false — the extra event
is one no-op handler call, paid only on commit (not auto-repeat).
WM_IME_ENDCOMPOSITION no longer dispatches Ime::Disabled, which was
the last guaranteed preedit-clear on the cancel path. If an IME ends
composition without a final WM_IME_COMPOSITION carrying an empty
GCS_COMPSTR (Esc, focus loss, IME switch), the app's ime.preedit
stays Some and process_key_event short-circuits every subsequent
key press — dead keyboard until the next composition. Send a
synthetic empty Preedit when ENDCOMPOSITION fires with a live
preedit and no composed result to recover.
The block wrote renderable_content.cursor.content /
is_ime_enabled every frame, but nothing has read either field
since the cursor pipeline moved to PanelFrame uniforms — and the
preedit overlay now renders the whole composition inline, so the
single-char cursor swap it fed is superseded anyway. Drop the
now-unread is_ime_enabled field with it.
map_or(false, ..) -> is_some_and, drop two usize -> usize casts,
and allow too_many_arguments on emit_ime_caret to match the
neighboring emit helpers.
@raphamorim
raphamorim marked this pull request as draft August 9, 2026 22:34
@raphamorim

Copy link
Copy Markdown
Owner Author

Closing in favor of a from-scratch implementation with a corrected design (single-row slide-left layout, grapheme-cluster shaping, contrast-safe caret, lifecycle handling); the review punch list in the body above documents why. The Windows redraw-pump work will be redone separately.

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.

3 participants