add caret blink to input

This commit is contained in:
2026-10-29 15:40:20 +07:00
parent 5f53e0c89f
commit b57547ab8c
3 changed files with 154 additions and 6 deletions
+114 -4
View File
@@ -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 {
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<Self>) {
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>) {
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<Self>) {
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<Self>) {
if !self.active || self.paused {
return;
}
self.visible = !self.visible;
cx.notify();
self.schedule(cx);
}
fn schedule(&mut self, cx: &mut Context<Self>) {
self.generation += 1;
self.schedule_pause(cx);
}
fn schedule_pause(&mut self, cx: &mut Context<Self>) {
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<CaretBlink>,
/// 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>>>,
/// Keeps the field repainting on blink ticks. Dropping it unsubscribes.
_caret_blink_subscription: Subscription,
}
impl EventEmitter<InputStateEvent> for InputState {}
impl InputState {
fn new_inner(cx: &Context<Self>) -> Self {
fn new_inner(cx: &mut Context<Self>) -> 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<Self> {
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<Self>) {
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<Self>) -> 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,
+38
View File
@@ -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]
+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–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<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 | 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).