Add IME underline, I-beam cursor, and horizontal scroll to Input
This commit is contained in:
+101
-18
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user