fix(ime): draw the composition on the cursor, not in the corner - #190
Open
midagedev wants to merge 1 commit into
Open
fix(ime): draw the composition on the cursor, not in the corner#190midagedev wants to merge 1 commit into
midagedev wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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. (handleCompositionEndalready swept those nodes back up afterwards, which is how visible the leak was.)left:0/top:0and never moved.position: static, soposition: absoluteon the textarea resolved against whatever ancestor happened to be positioned. In a host app whose panel isrelative, 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%)).handleCompositionUpdatewas 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: relativepanel, cursor on the last line, composing한글. The marked text is set through Chrome DevToolsInput.imeSetComposition— the same path a platform IME takes — so the browser really renders these preedits. They are not syntheticCompositionEvents.Before (this branch's parent commit) — the preedit is detached from the cursor:
After — it sits on the cursor cell, in the terminal's font and colors, underlined:
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.ts—focus()focuses the textarea; the parent becomes a containing block when it is otherwisestatic; afocushandler on the parent forwards to the textarea and is removed indispose(); the textarea keeps a real box with a transparent caret and text instead ofclip-path: inset(50%), because the platform anchors the candidate window to that box. Tracking rides the existing render loop, which already readsgetCursor()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:
focus()→ textarea change, and moves the composition listeners onto the textarea. If fix: Korean/CJK IME composition events not captured #120 lands first, that hunk of mine disappears in a rebase and the rest applies unchanged — happy to rebase on it, or to split this into positioning-only on request. fix: Korean/CJK IME composition events not captured #120 also carries a wide-character copy fix that is not in scope here.clip-path: inset(50%)and adds no preedit rendering, so the composing text stays invisible there.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 fourTerminal IME wiringcases were confirmed failing against the parent commit before the fix was written.bun run lint,bun run typecheck,bun run build(withbuild:wasmunder Zig 0.15.2) — all clean.bun run fmtreportslib/selection-manager.ts, which is pre-existing:prettier --check .reports exactly the same file onmainat the parent commit. I left it alone rather than mix an unrelated reformat into this diff.