Repository navigation
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.
… 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
force-pushed
the
ime-canvas-offset
branch
from
October 9, 2026 14:01
7863f0e to
453ebfb
Compare
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
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:
ImeOverlayplaced both elements atcol * width,row * height.position: absolutemeasures 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.ImeOverlayOptionsgets an optionalorigin()callback, andTerminalpasses the canvas'soffsetLeft/offsetTop.moveToreturned 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:origin: 12,10→ cell (2,1) at28px, 26px, for both the textarea and the view)bun run fmt,bun run lint,bun run typecheck, andbun test(382 pass) all pass, andbun run build:libsucceeds. I ran the tests against theghostty-vt.wasmbuilt from1858a59because I don't have zig locally. This change doesn't touch the WASM.