improve input
This commit is contained in:
+120
-87
@@ -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<dyn Fn(&KeyDownEvent, &mut Window, &mut App)>;
|
||||
/// Handler for mouse down events.
|
||||
type MouseDownHandler = std::rc::Rc<dyn Fn(&MouseDownEvent, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<KeyDownHandler>,
|
||||
/// The mouse-down handler, bound to this entity with `cx.listener`.
|
||||
mouse_down_handler: Option<MouseDownHandler>,
|
||||
/// 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<RefCell<Option<InputLayout>>>,
|
||||
@@ -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<Pixels>,
|
||||
padding: Pixels,
|
||||
colors: ColorTokens,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>, 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>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+21
-2
@@ -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<Size>)` + `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<KeyBinding>` 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user