Skip to content

fix(ime): place the IME target from the canvas origin and follow cell resizes - #202

Open
jesse23 wants to merge 2 commits into
coder:mainfrom
jesse23:ime-canvas-offset
Open

jesse23 wants to merge 2 commits into
coder:mainfrom
jesse23:ime-canvas-offset

Conversation

@jesse23

@jesse23 jesse23 commented Oct 9, 2026 •

Copy link
Copy Markdown

Summary

This PR builds on #190, which fixes #97, and includes its commit. Only the last commit (453ebfb) is new. If #190 merges first, this PR reduces to that one commit. I'm also happy for it to be folded into #190 instead.

#190 moves the IME target and the composition view onto the cursor cell. Using it in webtty, where we hit the same bug with macOS dictation as well as CJK IMEs, turned up two placement gaps:

  1. The parent's padding is ignored. ImeOverlay placed both elements at col * width, row * height. position: absolute measures from the parent's padding box, but the canvas, and so cell (0, 0), starts after the padding. A host that pads the terminal's parent got the preedit and the candidate window offset from the cursor by that padding. ImeOverlayOptions gets an optional origin() callback, and Terminal passes the canvas's offsetLeft / offsetTop.
  2. Cell resizes were missed. moveTo returned early when the cell was unchanged, so a font size change or a re-measure after a web font loads left the target at the old pixel position until the cursor moved. It now computes the box every call and writes only the styles whose value changed, so the per-frame call stays cheap.

Tests

Two new cases in lib/ime.test.ts:

  • cells are measured from the canvas origin (origin: 12,10 → cell (2,1) at 28px, 26px, for both the textarea and the view)
  • the target follows a cell size change while the cursor stays on the same cell

bun run fmt, bun run lint, bun run typecheck, and bun test (382 pass) all pass, and bun run build:lib succeeds. I ran the tests against the ghostty-vt.wasm built from 1858a59 because I don't have zig locally. This change doesn't touch the WASM.

midagedev and others added 2 commits August 26, 2026 07:13
A browser draws an IME's preedit inside the focused element, at that
element's caret. The terminal draws its cursor on a canvas, and a canvas
has no caret, so nothing lines the two up on its own: the preedit and
the platform's candidate window land wherever the focused input happens
to sit. Three things put that input away from the cursor.

`focus()` focused the contenteditable parent rather than the helper
textarea, so the browser inserted the preedit as text nodes in the
parent's own content flow — beside the canvas, not on the cursor.
(handleCompositionEnd already swept those nodes back up afterwards,
which is how visible the leak was.) The textarea was created at
left:0/top:0 and never moved. And the parent was left `position:
static`, so `position: absolute` on the textarea resolved against
whatever ancestor happened to be positioned; in a host app whose panel
was `relative`, that put the IME target outside the terminal entirely.

So: the textarea becomes the focused input and is tracked to the cursor
cell from the render loop that already reads getCursor() every frame,
and the parent becomes a containing block so a cell offset means what it
says. The parent keeps `contenteditable` — extensions like Vimium look
for it — it just no longer holds focus; a focus handler forwards, and
dispose() takes it back off. Tracking follows the renderer's own rule
for drawing the cursor: only while the viewport is at the bottom.

The textarea also keeps a real box instead of `clip-path: inset(50%)`,
because the platform anchors the candidate window to it, with a
transparent caret and text in place of the clip. That caret is the ghost
cursor that shows up at 0,0 beside the canvas cursor.

Since the textarea is invisible, the preedit inside it is invisible too,
so ImeOverlay draws the composing text at the cursor cell in the
terminal's own font and colors, underlined the way platforms mark
uncommitted text. handleCompositionUpdate had been a deliberate no-op
that deferred to "the browser's input method editor UI"; there is no
such UI to defer to when the input it would draw into is invisible.

That view is not a nicety for CJK. Hangul composes *within* a syllable —
ㄱ becomes 가 becomes 각 as you type — so without a visible preedit there
is nothing on screen between pressing a key and committing a syllable.
Chinese and Japanese at least have a candidate window to read.

Tests: lib/ime.test.ts. The four Terminal-level cases were confirmed
failing against the parent commit; the existing suite is unchanged at
372 pass.
… resizes

Cells start where the canvas does. When the host pads the terminal's parent,
that is not the padding-box origin that position: absolute measures from, so
the textarea and the composition view landed off the cursor by the padding.

moveTo also skipped any call on the same cell, so a font change or a re-measure
left the target at the old pixel position until the cursor moved. Write the
computed styles only when they change instead.
@jesse23
jesse23 force-pushed the ime-canvas-offset branch from 7863f0e to 453ebfb Compare October 9, 2026 14:01
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.

[DEMO]CJK Input position issue in DEMO

2 participants