diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 0531c17..a51fdb5 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -11,7 +11,8 @@ use gpui::{ EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, - TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point, px, + Subscription, TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point, + px, }; use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size}; @@ -21,6 +22,8 @@ use crate::common::{Sizable, h_flex}; pub const INPUT_CONTEXT: &str = "Input"; /// Consecutive edits within this interval merge into one undo entry. const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300); +/// How often the focused caret blinks. +const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500); /// Maximum undo entries kept; the oldest drops off the front. const MAX_HISTORY: usize = 100; @@ -316,6 +319,93 @@ impl HistoryEntry { } } +/// Caret blink controller. +struct CaretBlink { + interval: Duration, + generation: usize, + visible: bool, + active: bool, + paused: bool, +} + +impl CaretBlink { + fn new(_cx: &mut Context) -> Self { + Self { + interval: CARET_BLINK_INTERVAL, + generation: 0, + visible: true, + active: false, + paused: false, + } + } + + fn visible(&self) -> bool { + self.visible + } + + /// Starts blinking; the caret shows first and hides after one interval. + fn enable(&mut self, cx: &mut Context) { + if self.active { + return; + } + self.active = true; + self.visible = true; + self.paused = false; + self.schedule(cx); + } + + /// Stops blinking. The caret stays hidden; paint gates on focus anyway. + fn disable(&mut self, cx: &mut Context) { + self.active = false; + self.visible = false; + self.paused = false; + cx.notify(); + } + + /// Holds the caret visible and restarts the hide timer. + fn pause(&mut self, cx: &mut Context) { + if !self.active { + return; + } + if !self.visible { + self.visible = true; + cx.notify(); + } + self.paused = true; + self.generation += 1; + self.schedule_pause(cx); + } + + fn tick(&mut self, cx: &mut Context) { + if !self.active || self.paused { + return; + } + self.visible = !self.visible; + cx.notify(); + self.schedule(cx); + } + + fn schedule(&mut self, cx: &mut Context) { + self.generation += 1; + self.schedule_pause(cx); + } + + fn schedule_pause(&mut self, cx: &mut Context) { + let generation = self.generation; + let interval = self.interval; + cx.spawn(async move |this, cx| { + cx.background_executor().timer(interval).await; + this.update(cx, |this, cx| { + if this.generation == generation { + this.paused = false; + this.tick(cx); + } + }) + }) + .detach(); + } +} + /// Input state for a single-line text field. pub struct InputState { text: String, @@ -341,15 +431,20 @@ pub struct InputState { group_interval: Duration, /// When set, user edits are refused, focus, movement, selection and copy stay. read_only: bool, + /// Drives the focused caret's blink. + caret_blink: Entity, /// The shaped line and element bounds, recorded during each paint, /// and shared with hit-testing and the IME geometry conversions. layout: Rc>>, + /// Keeps the field repainting on blink ticks. Dropping it unsubscribes. + _caret_blink_subscription: Subscription, } impl EventEmitter for InputState {} impl InputState { - fn new_inner(cx: &Context) -> Self { + fn new_inner(cx: &mut Context) -> Self { + let caret_blink = cx.new(CaretBlink::new); Self { text: String::new(), cursor: 0, @@ -372,13 +467,15 @@ impl InputState { redo_stack: Vec::new(), group_interval: DEFAULT_GROUP_INTERVAL, read_only: false, + caret_blink: caret_blink.clone(), layout: Rc::new(RefCell::new(None)), + _caret_blink_subscription: cx.observe(&caret_blink, |_, _, cx| cx.notify()), } } /// Creates the state entity for an application-owned input. pub fn new(cx: &mut App) -> Entity { - cx.new(|cx| Self::new_inner(cx)) + cx.new(Self::new_inner) } /// Creates the state entity with initial text, caret at the end. @@ -522,6 +619,8 @@ 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.caret_blink.update(cx, |blink, cx| blink.pause(cx)); window.notify_text_input_changed(&self.focus_handle); } @@ -532,6 +631,12 @@ impl InputState { } self.focused = focused; + if focused { + self.caret_blink.update(cx, |blink, cx| blink.enable(cx)); + } else { + self.caret_blink.update(cx, |blink, cx| blink.disable(cx)); + } + cx.emit(if focused { InputStateEvent::Focused } else { @@ -539,6 +644,11 @@ impl InputState { }); } + /// Whether the caret should paint. + pub fn caret_visible(&mut self, focused: bool, cx: &mut Context) -> bool { + focused && !self.read_only && self.caret_blink.read(cx).visible() + } + /// Replaces a byte range with `text` and collapses the caret to its end. fn replace_range( &mut self, @@ -792,7 +902,7 @@ impl InputState { self.paint_text(origin, line_height, window, cx); // A caret promises that typing lands there; in read-only it does not. - if focused && !self.read_only && self.selection().is_empty() { + if focused && self.caret_visible(focused, cx) && self.selection().is_empty() { self.paint_caret( origin, caret_x, diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 65d4b2f..79ecc3b 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -262,6 +262,44 @@ fn read_only_refuses_edits_but_keeps_selection_and_copy() { .unwrap_or_default() }); assert_eq!(clipboard, "lo"); + + // The caret is suppressed entirely while read-only. + let caret = cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx))); + assert!(!caret); +} + +#[test] +fn caret_blinks_while_focused_and_holds_solid_while_typing() { + let (cx, state, mut visual) = input_window("", false); + focus(&mut visual, &state); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + visual.run_until_parked(); + + let caret = || cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx))); + + // The caret shows on focus, hides after one interval, and reappears + // after another. + assert!(caret()); + cx.executor().advance_clock(Duration::from_millis(500)); + visual.run_until_parked(); + assert!(!caret()); + cx.executor().advance_clock(Duration::from_millis(500)); + visual.run_until_parked(); + assert!(caret()); + + // Typing holds the caret solid and restarts the cycle. + visual.simulate_keystrokes("a"); + assert!(caret()); + cx.executor().advance_clock(Duration::from_millis(500)); + visual.run_until_parked(); + assert!(!caret()); + + // An unfocused field reports no caret. + let caret_unfocused = + cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(false, cx))); + assert!(!caret_unfocused); } #[test] diff --git a/docs/PLAN.md b/docs/PLAN.md index 4f12de6..f5c0325 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–4 landed 2026-10-29) +### 6.2 Input completion (unit 10 finishing pass) — in progress (steps 1–5 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. @@ -235,7 +235,7 @@ Remaining, in order: | 2 | Undo/redo | ✅ Landed. Patch-based history (range, replaced text, new length, prior selection), 300 ms grouping (`set_group_interval`, `Duration::ZERO` disables merging), `cmd-z` / `cmd-shift-z`, 100-entry cap; `set_value` clears the stacks, any new edit clears redo. Unlike gpuikit — which skips recording inside the interval and so undoes groups lossily — entries merge so a group fully reverses. Redo lands the caret at the end of the restored text; undo restores the pre-edit selection | | 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 | 500 ms tick on the GPUI executor, paused (caret visible) while typing or moving, suppressed in read-only | +| 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 | 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).