From 2ecde30ae21d0b8b58e46afa8f367aba25b1239e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 29 Oct 2026 15:59:42 +0700 Subject: [PATCH] Add IME underline, I-beam cursor, and horizontal scroll to Input --- crates/core/src/input.rs | 119 +++++++++++++++++++++++++++++++------ crates/core/tests/input.rs | 49 ++++++++++++++- docs/PLAN.md | 4 +- 3 files changed, 150 insertions(+), 22 deletions(-) diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index a51fdb5..c8d7559 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -7,12 +7,12 @@ use unicode_segmentation::UnicodeSegmentation; use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, Entity, - EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyBinding, - KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, - Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, - Subscription, TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point, - px, + App, AppContext as _, Bounds, ClipboardItem, Context, CursorStyle, DispatchPhase, ElementId, + Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _, + IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent, + SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, TextAlign, TextRun, + UTF16Selection, Window, actions, canvas, div, fill, point, px, }; use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size}; @@ -24,6 +24,8 @@ pub const INPUT_CONTEXT: &str = "Input"; const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300); /// How often the focused caret blinks. const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500); +/// Pixel height of one scroll "line" for horizontal wheel scrolling. +const HORIZONTAL_SCROLL_LINES_HEIGHT: f32 = 20.0; /// Maximum undo entries kept; the oldest drops off the front. const MAX_HISTORY: usize = 100; @@ -431,6 +433,10 @@ pub struct InputState { group_interval: Duration, /// When set, user edits are refused, focus, movement, selection and copy stay. read_only: bool, + /// Horizontal scroll of the content inside the field, clamped at paint. + scroll_offset: Pixels, + /// Set by every caret-affecting change. + scroll_to_caret_pending: bool, /// Drives the focused caret's blink. caret_blink: Entity, /// The shaped line and element bounds, recorded during each paint, @@ -467,6 +473,8 @@ impl InputState { redo_stack: Vec::new(), group_interval: DEFAULT_GROUP_INTERVAL, read_only: false, + scroll_offset: Pixels::ZERO, + scroll_to_caret_pending: true, caret_blink: caret_blink.clone(), layout: Rc::new(RefCell::new(None)), _caret_blink_subscription: cx.observe(&caret_blink, |_, _, cx| cx.notify()), @@ -619,11 +627,18 @@ impl InputState { fn notify(&mut self, window: &mut Window, cx: &mut Context) { cx.notify(); - // Any change holds the caret solid and restarts the blink cycle. + self.scroll_to_caret_pending = true; self.caret_blink.update(cx, |blink, cx| blink.pause(cx)); window.notify_text_input_changed(&self.focus_handle); } + /// Scrolls the field horizontally by a wheel delta, clamped at paint. + fn scroll_by(&mut self, delta: Pixels, window: &mut Window, cx: &mut Context) { + self.scroll_offset = (self.scroll_offset - delta).max(Pixels::ZERO); + cx.notify(); + window.notify_text_input_changed(&self.focus_handle); + } + /// Emits `Focused`/`Blurred` when the field's focus state changed since the last paint. fn sync_focus(&mut self, focused: bool, cx: &mut Context) { if self.focused == focused { @@ -788,16 +803,23 @@ impl InputState { let selection = self.selection(); let caret_x = line.x_for_index(selection.end); - // Horizontal scroll keeps the caret inside the field. - let visible = bounds.size.width - padding * 2.0; - let scroll_x = if caret_x > visible { - caret_x - visible - } else { - Pixels::ZERO - }; + let visible = (bounds.size.width - padding * 2.0).max(Pixels::ZERO); + let max_scroll = (line.width - visible).max(Pixels::ZERO); + + if self.scroll_to_caret_pending { + self.scroll_to_caret_pending = false; + // Pull the scroll just far enough to reveal the caret on whichever edge it left. + if caret_x - self.scroll_offset > visible { + self.scroll_offset = caret_x - visible; + } + if caret_x < self.scroll_offset { + self.scroll_offset = caret_x; + } + } + self.scroll_offset = self.scroll_offset.clamp(Pixels::ZERO, max_scroll); let origin = point( - bounds.left() + padding - scroll_x, + bounds.left() + padding - self.scroll_offset, bounds.top() + (bounds.size.height - line_height) / 2.0, ); @@ -901,6 +923,13 @@ impl InputState { self.paint_text(origin, line_height, window, cx); + // The composition underline shows the in-progress IME text. + if let Some(range) = self.marked_range.clone() + && !range.is_empty() + { + self.paint_marked_underline(origin, line_height, range, colors.foreground, window); + } + // A caret promises that typing lands there; in read-only it does not. if focused && self.caret_visible(focused, cx) && self.selection().is_empty() { self.paint_caret( @@ -934,6 +963,32 @@ impl InputState { self.notify(window, cx); } + /// Paints the composition underline under the marked range. + fn paint_marked_underline( + &self, + origin: gpui::Point, + line_height: Pixels, + range: Range, + color: gpui::Hsla, + window: &mut Window, + ) { + let layout = self.layout.borrow(); + let Some(layout) = layout.as_ref() else { + return; + }; + + let start_x = layout.line.x_for_index(range.start); + let end_x = layout.line.x_for_index(range.end); + let thickness = px(1.0); + let underline_y = origin.y + line_height - thickness; + let rect = Bounds::from_corners( + point(origin.x + start_x, underline_y), + point(origin.x + end_x, underline_y + thickness), + ); + + window.paint_quad(fill(rect, color)); + } + /// Registers the window-level listeners that continue a drag-select /// while the pointer moves outside the field. fn register_drag_listeners(entity: &Entity, window: &mut Window) { @@ -1519,7 +1574,12 @@ impl RenderOnce for Input { canvas( { let state = self.state.clone(); - move |_bounds, window, cx| state.read(cx).shape_current_text(window) + move |bounds, window, cx| { + // The hitbox backs the I-beam hover cursor. + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + let line = state.read(cx).shape_current_text(window); + (line, hitbox) + } }, { let state = self.state.clone(); @@ -1527,8 +1587,12 @@ impl RenderOnce for Input { let padding = metrics.padding_x / 2.0; let disabled = self.disabled; - // `line` comes from the prepaint closure above. - move |bounds, line, window, cx| { + // `line` and the hitbox come from the prepaint closure above. + move |bounds, (line, hitbox), window, cx| { + if !disabled { + window.set_cursor_style(CursorStyle::IBeam, &hitbox); + } + let dragging = state.update(cx, |state, cx| { state.paint(line, bounds, padding, colors, window, cx) }); @@ -1543,6 +1607,25 @@ impl RenderOnce for Input { cx, ); + window.on_mouse_event({ + let state = state.clone(); + move |event: &ScrollEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble + || !bounds.contains(&event.position) + { + return; + } + let delta = match event.delta { + ScrollDelta::Pixels(delta) => delta.x, + ScrollDelta::Lines(lines) => { + px(lines.x * HORIZONTAL_SCROLL_LINES_HEIGHT) + } + }; + state + .update(cx, |state, cx| state.scroll_by(delta, window, cx)); + } + }); + if dragging { InputState::register_drag_listeners(&state, window); } diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 79ecc3b..39a41d8 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -1,6 +1,7 @@ use gpui::{ - Entity, KeyBinding, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, - Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, + Entity, EntityInputHandler as _, KeyBinding, Modifiers, MouseButton, MouseDownEvent, + MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _, + TestAppContext, VisualTestContext, Window, div, point, px, }; use gpui_native_core::input::{ INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, bind_input_keys, @@ -302,6 +303,50 @@ fn caret_blinks_while_focused_and_holds_solid_while_typing() { assert!(!caret_unfocused); } +#[test] +fn horizontal_scroll_follows_the_caret_and_wheels_back() { + let text = "a".repeat(300); + let (_cx, state, mut visual) = input_window(&text, false); + focus(&mut visual, &state); + let draw = |visual: &mut VisualTestContext| { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + }; + + // The text index at the field's left edge, through the last paint. + let left_edge_index = |visual: &mut VisualTestContext| -> usize { + visual + .update(|window, cx| { + state.update(cx, |state, cx| { + state.character_index_for_point(point(px(2.), px(14.)), window, cx) + }) + }) + .unwrap_or(0) + }; + + // The caret starts at the end; the view scrolls to keep it in view, so + // the left edge shows a later character. + draw(&mut visual); + let scrolled_index = left_edge_index(&mut visual); + assert!(scrolled_index > 0, "left edge index: {scrolled_index}"); + + // Moving to the start scrolls the view back: the left edge is index 0. + visual.simulate_keystrokes("home"); + draw(&mut visual); + assert_eq!(left_edge_index(&mut visual), 0); + + // A wheel scroll pans the view right without moving the caret. + visual.simulate_event(ScrollEvent { + position: point(px(200.), px(20.)), + delta: ScrollDelta::Pixels(point(px(-20.), px(0.))), + ..Default::default() + }); + draw(&mut visual); + let panned_index = left_edge_index(&mut visual); + assert!(panned_index > 0, "left edge index: {panned_index}"); +} + #[test] fn read_only_blocks_undo_redo_but_set_value_still_writes() { let (cx, state, mut visual) = input_window("", false); diff --git a/docs/PLAN.md b/docs/PLAN.md index f5c0325..3180449 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -221,7 +221,7 @@ Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide te Our divergence from gpui-kit held: the per-size metrics resolve from **the theme's size table at paint time**, not from constants in the component layer. All ten controls implement `Sizable`; `Size::Custom(side)` derives the other metrics from the side length. Still open, when a real need arrives: TOML overrides for the size table in `ThemeDefinition`, and per-platform size tables from the Phase F native-metrics work. -### 6.2 Input completion (unit 10 finishing pass) — in progress (steps 1–5 landed 2026-10-29) +### 6.2 Input completion (unit 10 finishing pass) — ✅ landed (2026-10-29) Gap analysis against gpuikit's input (2026-10-29; source read as an API reference, §9 — no porting). gpuikit splits input three ways: core state whose edits are actions, infrastructure (`bindings` / `blink` / `bidi`), and a chrome-less element with chrome in wrapper elements. We keep one component with chrome included; only the ideas below are adopted. @@ -236,7 +236,7 @@ Remaining, in order: | 3 | Read-only, Escape-blur, triple-click | ✅ Landed. Read-only: focus, movement, selection, copy still work; edits, paste, cut (clipboard included), undo/redo and IME refused — distinct from disabled; programmatic setters unaffected; the caret is hidden while read-only (a caret promises typing lands there). Escape → `window.blur()`. Triple-click selects all (single-line) | | 4 | Actions + key context | ✅ Landed. 25 `actions!(input, …)` + an `"Input"` key context; the element registers `on_action` handlers and the residual `on_key_down` only inserts printable typed chars. `InputBindings` table of `Option` fields — `Default` is platform defaults (cmd/alt on macOS, ctrl elsewhere), `empty()` starts bare, any field replaceable/unbindable; `bind_input_keys(cx, None | table)` registers at startup (gallery + tests call it). Verified gpui semantics first: matched bindings consume the keystroke before `on_key_down`, and actions claim by default in bubble (`cx.propagate()` yields — Copy does when the selection is empty). macOS jump extras (`cmd-left/right/up/down`, `cmd-shift-left/right`) are appended in `into_bindings`, not field-configurable | | 5 | Caret blink | ✅ Landed. A private `CaretBlink` entity per `InputState`: 500 ms tick on the GPUI executor, visible on focus and hidden after one interval. Any field change (`InputState::notify`) pauses it — caret solid, cycle restarts — so typing, moving and dragging never hide it; generation counters invalidate stale ticks. Read-only suppresses the caret entirely. `caret_visible(focused, cx)` is the single gate used by paint and tests | -| 6 | Paint polish | IME marked-text underline; I-beam cursor on hover; two-sided horizontal scroll (clamped offset in state, follows caret) plus horizontal wheel scroll | +| 6 | Paint polish | ✅ Landed. IME composition underline (1 px, foreground token) under the marked range. I-beam hover cursor via a canvas-prepaint hitbox + `set_cursor_style` in paint. Two-sided horizontal scroll: `scroll_offset` lives in state; caret-affecting changes set a pending flag that the next paint consumes with a fresh layout (two-sided pull-back + clamp to content), while `scroll_by` (wheel, `ScrollEvent`, lines→pixels via a fixed unit) bypasses the flag so manual pans survive. Scroll-aware hit-testing, IME `bounds_for_range` and drag select all come free through the scroll-adjusted origin | Deferred until a real need: bidi/RTL; prefix/suffix adornments and a form field wrapper (label/description/error — separate units); `SubmitOn` enter/cmd-enter modes (multiline concern); `Undo`/`Redo` events; selection/cursor color overrides (theme tokens are deliberate); password masking, max length, clear button (gpuikit has none of these either — decide on first need).