Skip to content

input: IME candidate bounds jump to the input origin before repaint #3286

Description

@shenxiangzhuang

Description

Chinese IME candidate windows can jump horizontally while composing text in a Textarea. The shared InputBaseState::bounds_for_range returns the input origin when a newly inserted caret cannot be found in the previous painted layout.

The geometry failure is reproducible by updating composition and querying its caret bounds before repaint. This is independent of the IME undo issue in #2761; no undo is involved.

Environment

  • Reproduced with GPUI Kit / gpui-base / gpui-component 0.6.4, gpui-pre 0.3.5.
  • Automated geometry test: macOS 26.5.2, arm64.
  • Also inspected v0.7.0: its bounds_for_range implementation is identical to unpatched 0.6.4. I have not run the reproduction against 0.7.0, so this is source verification, not a claim of native reproduction on that release.
  • The original candidate-window report did not identify the exact input method. Native candidate-panel acceptance remains pending.

Steps to Reproduce

Using a rendered, focused TextareaState:

  1. Insert 这是一段已经输入的中文文字 with replace_text_in_range(None, text, window, cx), leaving the caret at the end.
  2. Allow layout/paint to complete, then query the caret bounds.
  3. In the same UI update, call replace_and_mark_text_in_range(None, "n", None, window, cx) and immediately query the new caret bounds, without allowing another paint between these operations.

The critical sequence is:

// input is a focused TextareaState whose initial text has already been painted.
let bounds = input.text_bounds().unwrap();
let before = input.selected_text_range(false, window, cx).unwrap().range;
let previous = input
    .bounds_for_range(before.end..before.end, bounds, window, cx)
    .unwrap();

input.replace_and_mark_text_in_range(None, "n", None, window, cx);

let after = input.selected_text_range(false, window, cx).unwrap().range;
let pending = input
    .bounds_for_range(after.end..after.end, bounds, window, cx)
    .unwrap();
assert!((pending.origin.x - previous.origin.x).abs() < px(40.0));

Local geometry checks additionally cover CJK/emoji mixed text, wrapping and a scrolled multiline input. The pre-repaint check fails with the original dependency and passes with the fallback change below.

Expected

While waiting for the next paint, the candidate-window anchor should remain at a meaningful text/caret position rather than jumping to the input's left edge.

Actual

The failing test measured:

before composition: origin (475.8 px, 353 px), size (0 px, 20 px)
before repaint:     origin (351 px,   353 px), size (0 px, 20 px)

The new UTF-8 offset is beyond the old shaped line, so lookup returns None. Both origins then use unwrap_or_default(), yielding the input origin. When only the end lookup fails, the returned range can also have a negative width.

Diagnosis / possible fix

The fallback is still present in v0.7.0 source.

A local experiment falls back to the last laid-out caret for an unresolved start and to the resolved start for an unresolved end:

let start_origin = start_origin.or_else(|| {
    let (_, _, origin) = self.line_and_position_for_offset(self.last_cursor?);
    origin.map(|origin| origin - line_number_origin)
})?;
let mut end_origin = end_origin.unwrap_or(start_origin);

This passes the local pre-repaint geometry checks. It is a proposed fallback, not a fully validated native IME fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions