diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index fd6e3cc..21084a4 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -12,7 +12,7 @@ use gpui::{ RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection, Window, canvas, div, fill, point, px, }; -use gpui_native_theme::{ActiveTheme as _, Size}; +use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size}; use crate::common::{Sizable, h_flex}; @@ -39,6 +39,8 @@ struct InputLayout { /// Handler for key down events. type KeyDownHandler = std::rc::Rc; +/// Handler for mouse down events. +type MouseDownHandler = std::rc::Rc; /// Input state for a single-line text field. pub struct InputState { @@ -54,6 +56,10 @@ pub struct InputState { focus_handle: gpui::FocusHandle, /// The key-down handler, bound to this entity with `cx.listener`. key_down_handler: Option, + /// The mouse-down handler, bound to this entity with `cx.listener`. + mouse_down_handler: Option, + /// Whether a drag-select is in progress. + dragging: bool, /// The shaped line and element bounds, recorded during each paint, /// and shared with hit-testing and the IME geometry conversions. layout: Rc>>, @@ -75,6 +81,12 @@ impl InputState { state.handle_key_down(event, window, cx); }, ))), + mouse_down_handler: Some(Rc::new(cx.listener( + |state, event: &MouseDownEvent, window, cx| { + state.handle_mouse_down(event, window, cx); + }, + ))), + dragging: false, layout: Rc::new(RefCell::new(None)), } } @@ -393,6 +405,47 @@ impl InputState { window.paint_quad(fill(caret, color)); } + /// Paints the field contents; returns whether a drag-select is in progress. + fn paint( + &mut self, + line: gpui::ShapedLine, + bounds: Bounds, + padding: Pixels, + colors: ColorTokens, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let focused = self.focus_handle.is_focused(window); + self.sync_focus(focused, cx); + + let text_style = window.text_style(); + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let line_height = text_style + .line_height + .to_pixels(text_style.font_size, window.rem_size()); + + let (origin, caret_x) = self.store_layout(line, bounds, padding, line_height); + + if focused && !self.selection().is_empty() { + self.paint_selection(origin, caret_x, bounds, colors.selection, window); + } + + self.paint_text(origin, line_height, window, cx); + + if focused && self.selection().is_empty() { + self.paint_caret( + origin, + caret_x, + font_size, + line_height, + colors.foreground, + window, + ); + } + + self.dragging + } + /// The text index at window x, using the last painted layout. fn index_at_x(&self, x: Pixels) -> usize { let layout = self.layout.borrow(); @@ -411,6 +464,57 @@ impl InputState { self.notify(window, cx); } + /// 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) { + window.on_mouse_event({ + let state = entity.clone(); + move |event: &MouseMoveEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble || event.pressed_button != Some(MouseButton::Left) + { + return; + } + state.update(cx, |state, cx| { + state.drag_select_to(event.position.x, window, cx) + }); + } + }); + window.on_mouse_event({ + let state = entity.clone(); + move |event: &MouseUpEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble || event.button != MouseButton::Left { + return; + } + state.update(cx, |state, _| state.dragging = false); + } + }); + } + + /// The mouse-down handler: starts a drag and places or extends the selection. + fn handle_mouse_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.dragging = true; + + let index = self.index_at_x(event.position.x); + + if event.click_count == 2 { + let start = previous_word_start(&self.text, index); + let end = next_word_end(&self.text, index); + self.select_range(start..end, window, cx); + } else if event.modifiers.shift { + self.cursor = index; + self.notify(window, cx); + } else { + self.cursor = index; + self.anchor = index; + self.notify(window, cx); + } + } + /// The key-down handler: navigation, editing, clipboard, and submit. /// /// Runs only while the field is focused. @@ -774,16 +878,9 @@ impl RenderOnce for Input { let metrics = theme.sizes.resolve(self.size); let focus_handle = self.state.read(cx).focus_handle.clone(); - // Drag selection crosses frames, so it lives in keyed element state. - let dragging = window.use_keyed_state("input-dragging", cx, |_, _| false); - let down_dragging = dragging.clone(); - let paint_dragging = dragging.clone(); - let focused = focus_handle.is_focused(window); let is_empty = self.state.read(cx).text.is_empty(); - let down_state = self.state.clone(); - let fallback_label = self .a11y_label .clone() @@ -826,43 +923,13 @@ impl RenderOnce for Input { { let state = self.state.clone(); let focus = focus_handle.clone(); - let dragging = paint_dragging; + let padding = metrics.padding_x / 2.0; let disabled = self.disabled; // `line` comes from the prepaint closure above. move |bounds, line, window, cx| { - state.update(cx, |state, cx| { - state.sync_focus(focused, cx); - - let text_style = window.text_style(); - let font_size = text_style.font_size.to_pixels(window.rem_size()); - let line_height = text_style - .line_height - .to_pixels(text_style.font_size, window.rem_size()); - let padding = metrics.padding_x / 2.0; - let (origin, caret_x) = - state.store_layout(line, bounds, padding, line_height); - - if focused && !state.selection().is_empty() { - state.paint_selection( - origin, - caret_x, - bounds, - colors.selection, - window, - ); - } - state.paint_text(origin, line_height, window, cx); - if focused && state.selection().is_empty() { - state.paint_caret( - origin, - caret_x, - font_size, - line_height, - colors.foreground, - window, - ); - } + let dragging = state.update(cx, |state, cx| { + state.paint(line, bounds, padding, colors, window, cx) }); if disabled { @@ -875,32 +942,8 @@ impl RenderOnce for Input { cx, ); - if *dragging.read(cx) { - let state = state.clone(); - let dragging = dragging.clone(); - window.on_mouse_event( - move |event: &MouseMoveEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble - || event.pressed_button != Some(MouseButton::Left) - { - return; - } - state.update(cx, |state, cx| { - state.drag_select_to(event.position.x, window, cx) - }); - }, - ); - let dragging = dragging.clone(); - window.on_mouse_event( - move |event: &MouseUpEvent, phase, _window, cx| { - if phase != DispatchPhase::Bubble - || event.button != MouseButton::Left - { - return; - } - dragging.update(cx, |dragging, _| *dragging = false); - }, - ); + if dragging { + InputState::register_drag_listeners(&state, window); } } }, @@ -911,26 +954,14 @@ impl RenderOnce for Input { .when(!self.disabled, |input| { input .on_mouse_down(MouseButton::Left, { - let state = down_state; - let dragging = down_dragging; - + let mouse_down_handler = self + .state + .read(cx) + .mouse_down_handler + .clone() + .expect("input state always carries its mouse-down handler"); move |event: &MouseDownEvent, window, cx| { - dragging.update(cx, |dragging, _| *dragging = true); - state.update(cx, |state, cx| { - let index = state.index_at_x(event.position.x); - if event.click_count == 2 { - let start = previous_word_start(&state.text, index); - let end = next_word_end(&state.text, index); - state.select_range(start..end, window, cx); - } else if event.modifiers.shift { - state.cursor = index; - state.notify(window, cx); - } else { - state.cursor = index; - state.anchor = index; - state.notify(window, cx); - } - }); + mouse_down_handler(event, window, cx); } }) .on_key_down({ @@ -940,7 +971,9 @@ impl RenderOnce for Input { .key_down_handler .clone() .expect("input state always carries its key handler"); - move |event: &KeyDownEvent, window, cx| key_down_handler(event, window, cx) + move |event: &KeyDownEvent, window, cx| { + key_down_handler(event, window, cx); + } }) }) } diff --git a/docs/PLAN.md b/docs/PLAN.md index 3ee46e2..1f8f812 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -207,7 +207,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 7 | core | Slider | Click-to-set + in-track drag; controlled value. | | 8 | core | Progress | Determinate + indeterminate. | | 9 | core | Avatar | Initials; image slot later. | -| 10 | core | Input (single-line) then multiline, NumberInput | String buffer + `unicode-segmentation` word movement; the field owns its own selection. May seed `crates/editor`. | +| 10 | core | Input (single-line) then multiline, NumberInput | String buffer + `unicode-segmentation` word movement; the field owns its own selection. May seed `crates/editor`. Single-line finishing pass: §6.2. | | 11+ | core | Popover, Tooltip, Menu, Dialog, AlertDialog, Sheet, Toast | One unit each, same gate. | | then | data (new) | List, Table, Tree, Tabs, Sidebar | Created on demand, same gate. | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | @@ -219,7 +219,26 @@ Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide te **Landed** (2026-10-29): the `Size` enum (`XSmall` / `Small` / `Medium` default / `Large` / `Custom(Pixels)`), the `Sizable` trait (`with_size(impl Into)` + `xsmall()` / `small()` / `large()`), and the `SizeTable` of per-class control metrics (height, padding-x, text size, icon/indicator size, avatar size) now live where they belong — `Size`/`SizeTable`/`ControlMetrics` in the theme (they are native-metric values, §5.5), the trait in core's ungated `common`. -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. +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) — next + +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. + +Already at parity: buffer + selection + caret, grapheme/word movement, home/end, clipboard, select-all, IME (`EntityInputHandler`, marked range, UTF-16 conversions), double-click word / shift-click / drag selection, placeholder, disabled, focus ring, a11y `TextInput` role, `TextChanged`/`Focused`/`Blurred`/`Submitted` events, `set_value` + silent variant. + +Remaining, in order: + +| Step | Scope | Notes | +| --- | --- | --- | +| 1 | macOS modifier split | `cmd-left/right` → home/end, `cmd-up/down` → start/end of field, `cmd-backspace` → delete to start, `alt` modifiers → word ops. The handler currently treats cmd and alt alike — wrong on macOS (D8); ctrl stays the word-modifier fallback off-mac | +| 2 | Undo/redo | Patch-based history (range, replaced text, new length, prior selection), 300 ms grouping (`set_group_interval`), `cmd-z` / `cmd-shift-z`; `set_value` clears the stacks | +| 3 | Read-only, Escape-blur, triple-click | Read-only: focus, movement, selection, copy still work; edits and paste refused — distinct from disabled. Escape → `window.blur()`. Triple-click selects the line (all of it, single-line) | +| 4 | Actions + key context | Replace the monolithic `on_key_down` match with `actions!(input, [...])` + an `"Input"` key context; `bind_input_keys(cx, overrides)` over a per-platform-default `InputBindings` table of `Option` fields (override or unbind each). Step 1's bindings become data | +| 5 | Caret blink | 500 ms tick on the GPUI executor, paused (caret visible) while typing or moving, suppressed in read-only | +| 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 | + +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). ## 7. Milestones