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,