Skip to content

fix(ime): draw the composition on the cursor, not in the corner - #190

Open
midagedev wants to merge 1 commit into
coder:mainfrom
midagedev:ime-cursor-position
Open

fix(ime): draw the composition on the cursor, not in the corner#190
midagedev wants to merge 1 commit into
coder:mainfrom
midagedev:ime-cursor-position

Conversation

@midagedev

Copy link
Copy Markdown

Summary

An IME's preedit — the text being composed, before it is committed — is drawn by the browser 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:

  1. 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.)
  2. The textarea was created at left:0/top:0 and never moved.
  3. 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 is relative, that puts the IME target outside the terminal entirely.

And even once the target is in the right place, the preedit inside it is invisible, because the textarea is (opacity: 0, clip-path: inset(50%)). handleCompositionUpdate was a deliberate no-op that deferred to "the browser's input method editor UI" — but there is no such UI to defer to when the input it would draw into cannot be seen.

Before / after

Same harness both times: a terminal inside a position: relative panel, cursor on the last line, composing 한글. The marked text is set through Chrome DevTools Input.imeSetComposition — the same path a platform IME takes — so the browser really renders these preedits. They are not synthetic CompositionEvents.

Before (this branch's parent commit) — the preedit is detached from the cursor:

preedit rendered away from the cursor

After — it sits on the cursor cell, in the terminal's font and colors, underlined:

preedit rendered inline at the cursor

Why the visible preedit matters, specifically for Korean

Hangul composes within a syllable: becomes becomes as you type, and only the final syllable is committed. Without a visible preedit there is nothing on screen between pressing a key and committing — you are typing blind. Chinese and Japanese at least have a candidate window to read; Korean has only the preedit itself. This is the part of #119 that positioning alone does not fix.

Changes

  • lib/ime.ts (new) — ImeOverlay: keeps the textarea on the cursor cell and draws the composing text there, in the terminal's own font/colors, underlined the way platforms mark uncommitted text.
  • lib/terminal.tsfocus() focuses the textarea; the parent becomes a containing block when it is otherwise static; a focus handler on the parent forwards to the textarea and is removed in dispose(); the textarea keeps a real box with a transparent caret and text instead of clip-path: inset(50%), because the platform anchors the candidate window to that box. Tracking rides the existing render loop, which already reads getCursor() every frame.
  • lib/input-handler.ts — the composition handlers drive the overlay.
  • lib/ime.test.ts (new) — 8 cases.

The parent keeps contenteditable: extensions like Vimium look for it. It just no longer holds focus.

The transparent caret also removes the "ghost cursor at (0,0)" from #122 — that cursor is this textarea's caret. I did not set out to fix that one; it falls out of giving the textarea a real box in the right place.

Relationship to open work — please read before reviewing

This overlaps existing work, and I would rather say so than have a reviewer find it:

I opened this as a separate, small PR rather than pushing to either, because the visible-preedit half is not in either one and this is the part CJK users actually hit. If maintainers would rather see it folded into #120, say the word.

Fixes #122.
Addresses the composition-visibility half of #119, and #97's positioning symptom.

Credit: #119 and #120 by @hongsw are what identified the focus/listener mismatch; the repositioning idea is the same one @diegosouzapw shipped in their fork.

How this has been tested

  • bun test — 380 pass / 0 fail (372 before this branch, +8 new). All four Terminal IME wiring cases were confirmed failing against the parent commit before the fix was written.
  • bun run lint, bun run typecheck, bun run build (with build:wasm under Zig 0.15.2) — all clean.
  • bun run fmt reports lib/selection-manager.ts, which is pre-existing: prettier --check . reports exactly the same file on main at the parent commit. I left it alone rather than mix an unrelated reformat into this diff.
  • The screenshots above are Chromium only, via CDP marked text. Not tested: a real platform IME on macOS/Windows/Linux, Firefox, or Safari — I do not have a way to drive a native IME in CI, and I would value a maintainer or @hongsw trying the real thing. The positioning and visibility logic itself is platform-independent DOM, but the candidate-window placement is the platform's call and I can only show that we now hand it the right anchor.

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.
midagedev added a commit to midagedev/gadak that referenced this pull request Aug 26, 2026
…y-web)

Compose Korean in the terminal and the preedit appeared away from the
cursor — in the desktop app, at the top of the pane. Measured, not read:
the renderer's helper textarea sits at left:0/top:0 and is never moved,
`focus()` focuses the contenteditable parent instead of it, and that
parent is `position: static`, so `absolute` on the textarea resolves
against the app's own panel. A browser draws a preedit inside the
focused element at its caret; a canvas has no caret, so nothing lined
the two up. Under the xterm renderer the same probe put the target at
the cursor cell — the defect is ghostty-web's, not ours.

Upstream: coder/ghostty-web#190. Until it lands, ghostty-web resolves to
midagedev/ghostty-web `gadak-dist`, which is that branch plus a
prebuilt bundle — npm installs a git dependency by packing the tree as
it stands, so a source-only branch would need bun and Zig 0.15.2 on
every machine that runs `npm ci`.

Pinned over git+https, not the `github:` shorthand: npm normalises the
shorthand's `resolved` to git+ssh, and CI has no key. Verified by
running `npm ci` with `GIT_SSH_COMMAND=/bin/false` — it resolves.

Measured after the swap, same probe: the IME target moves to the cursor
cell (top 90px for row 6 at 15px), focus lands on the textarea, the
caret is transparent, and the composing text renders at the cursor and
clears on commit. Full e2e 326 passed.

Closes GDK-956.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.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.

Ghost cursor appears at top-left (0,0) on initialization

1 participant