add caret blink to input
This commit is contained in:
+114
-4
@@ -11,7 +11,8 @@ use gpui::{
|
|||||||
EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyBinding,
|
EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyBinding,
|
||||||
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
||||||
Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled 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};
|
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ use crate::common::{Sizable, h_flex};
|
|||||||
pub const INPUT_CONTEXT: &str = "Input";
|
pub const INPUT_CONTEXT: &str = "Input";
|
||||||
/// Consecutive edits within this interval merge into one undo entry.
|
/// Consecutive edits within this interval merge into one undo entry.
|
||||||
const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
|
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.
|
/// Maximum undo entries kept; the oldest drops off the front.
|
||||||
const MAX_HISTORY: usize = 100;
|
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.
|
/// Input state for a single-line text field.
|
||||||
pub struct InputState {
|
pub struct InputState {
|
||||||
text: String,
|
text: String,
|
||||||
@@ -341,15 +431,20 @@ pub struct InputState {
|
|||||||
group_interval: Duration,
|
group_interval: Duration,
|
||||||
/// When set, user edits are refused, focus, movement, selection and copy stay.
|
/// When set, user edits are refused, focus, movement, selection and copy stay.
|
||||||
read_only: bool,
|
read_only: bool,
|
||||||
|
/// Drives the focused caret's blink.
|
||||||
|
caret_blink: Entity<CaretBlink>,
|
||||||
/// The shaped line and element bounds, recorded during each paint,
|
/// The shaped line and element bounds, recorded during each paint,
|
||||||
/// and shared with hit-testing and the IME geometry conversions.
|
/// and shared with hit-testing and the IME geometry conversions.
|
||||||
layout: Rc<RefCell<Option<InputLayout>>>,
|
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 EventEmitter<InputStateEvent> for InputState {}
|
||||||
|
|
||||||
impl 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 {
|
Self {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -372,13 +467,15 @@ impl InputState {
|
|||||||
redo_stack: Vec::new(),
|
redo_stack: Vec::new(),
|
||||||
group_interval: DEFAULT_GROUP_INTERVAL,
|
group_interval: DEFAULT_GROUP_INTERVAL,
|
||||||
read_only: false,
|
read_only: false,
|
||||||
|
caret_blink: caret_blink.clone(),
|
||||||
layout: Rc::new(RefCell::new(None)),
|
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.
|
/// Creates the state entity for an application-owned input.
|
||||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
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.
|
/// 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>) {
|
fn notify(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
cx.notify();
|
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);
|
window.notify_text_input_changed(&self.focus_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +631,12 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
self.focused = focused;
|
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 {
|
cx.emit(if focused {
|
||||||
InputStateEvent::Focused
|
InputStateEvent::Focused
|
||||||
} else {
|
} 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.
|
/// Replaces a byte range with `text` and collapses the caret to its end.
|
||||||
fn replace_range(
|
fn replace_range(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -792,7 +902,7 @@ impl InputState {
|
|||||||
self.paint_text(origin, line_height, window, cx);
|
self.paint_text(origin, line_height, window, cx);
|
||||||
|
|
||||||
// A caret promises that typing lands there; in read-only it does not.
|
// 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(
|
self.paint_caret(
|
||||||
origin,
|
origin,
|
||||||
caret_x,
|
caret_x,
|
||||||
|
|||||||
@@ -262,6 +262,44 @@ fn read_only_refuses_edits_but_keeps_selection_and_copy() {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
});
|
});
|
||||||
assert_eq!(clipboard, "lo");
|
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]
|
#[test]
|
||||||
|
|||||||
+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.
|
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.
|
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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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).
|
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