Add IME underline, I-beam cursor, and horizontal scroll to Input

This commit is contained in:
2026-10-29 15:59:42 +07:00
parent b57547ab8c
commit 2ecde30ae2
3 changed files with 150 additions and 22 deletions
+101 -18
View File
@@ -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<CaretBlink>,
/// 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<Self>) {
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>) {
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<Self>) {
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<Pixels>,
line_height: Pixels,
range: Range<usize>,
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<Self>, 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);
}
+47 -2
View File
@@ -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);
+2 -2
View File
@@ -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<KeyBinding>` 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).