From f3e9ae3c56fce8d04b70855d5a6b47b77a1d2cf4 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Thu, 25 Jun 2026 13:01:38 -0400 Subject: [PATCH] add caret blinking which is handled via a separate entity --- crates/gpui_elements/src/editable_text.rs | 3 +- .../gpui_elements/src/editable_text/caret.rs | 164 ++++++++++++++++++ .../src/editable_text/element.rs | 22 ++- .../gpui_elements/src/editable_text/notify.rs | 49 ------ .../gpui_elements/src/editable_text/state.rs | 39 +++-- 5 files changed, 205 insertions(+), 72 deletions(-) create mode 100644 crates/gpui_elements/src/editable_text/caret.rs delete mode 100644 crates/gpui_elements/src/editable_text/notify.rs diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs index 31fa9bf625..0e030c2b2a 100644 --- a/crates/gpui_elements/src/editable_text.rs +++ b/crates/gpui_elements/src/editable_text.rs @@ -1,7 +1,7 @@ pub mod actions; +mod caret; mod element; mod history; -pub mod notify; mod state; mod storage; @@ -11,7 +11,6 @@ pub use storage::*; /* TODO list - remove gpuikit based input -- cursor blinking - text sanitation - add page up/down actions to nav by an entire page or expand selection by an entire page - test IME (char palette only available on macos) diff --git a/crates/gpui_elements/src/editable_text/caret.rs b/crates/gpui_elements/src/editable_text/caret.rs new file mode 100644 index 0000000000..4f0db28fd0 --- /dev/null +++ b/crates/gpui_elements/src/editable_text/caret.rs @@ -0,0 +1,164 @@ +use std::time::Duration; + +use gpui::{Context, Entity, EventEmitter, Subscription}; +use smallvec::SmallVec; + +/// Default interval for caret blinking. +pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); + +pub enum CaretNotify { + PauseBlinking, +} + +pub struct Caret { + /// The frequency at which the caret blinks + interval: Duration, + generation: usize, + /// Whether the caret is presently visible in this frame + visible: bool, + /// Whether the caret is currently able to blink + active: bool, + /// true when blinking is active but paused for some number of frames + paused: bool, + #[allow(dead_code)] + subscriptions: SmallVec<[Subscription; 2]>, + /// Tracks whether we were focused on the last update. + was_focused: bool, +} +impl Default for Caret { + fn default() -> Self { + Self { + interval: Duration::ZERO, + generation: Default::default(), + visible: false, + active: false, + paused: false, + subscriptions: SmallVec::new(), + was_focused: false, + } + } +} + +impl Caret { + pub fn blink_interval_default(mut self) -> Self { + self.interval = DEFAULT_BLINK_INTERVAL; + self + } + + pub fn blink_interval(mut self, interval: Duration) -> Self { + self.interval = interval; + self + } + + pub fn subscribe_to(&mut self, emitter: &Entity, cx: &mut Context) + where + E: EventEmitter, + { + let handle = cx.subscribe(emitter, |state, _emitter, event, cx| match event { + CaretNotify::PauseBlinking => { + if !state.interval.is_zero() { + state.pause_blinking(cx); + cx.notify(); + } + } + }); + self.subscriptions.push(handle); + } + + /// Processes updates during prepaint and returns whether the caret is currently visible. + pub fn update_focus(&mut self, is_focused: bool, cx: &mut Context) -> bool { + let was_focused = self.was_focused; + self.was_focused = is_focused; + + match (self.interval.is_zero(), is_focused, was_focused) { + (true, _, _) => true, + (false, true, false) => { + self.enable(cx); + true + } + (false, false, true) => { + self.disable(cx); + false + } + (false, _, _) => self.visible, + } + } + + /// Activates caret blinking. + /// + /// While active, the caret will alternate between visible and hidden states at the + /// configured interval. Has no effect if already active. + fn enable(&mut self, cx: &mut Context) { + if self.active { + return; + } + + self.active = true; + self.visible = false; + self.paused = false; + self.spawn_ticker(cx); + } + + /// Deactivates caret blinking. + /// + /// Marks the caret as invisible and pauses blinking indefinitely. `enable` must be called + /// explicitly to resume visibility and blinking. Call `pause_blinking` instead if you want to + /// temporarily stop blinking while keeping the caret visible. + fn disable(&mut self, cx: &mut Context) { + self.active = false; + self.visible = false; + self.paused = false; + cx.notify(); + } + + /// Temporarily pauses blinking and leaves the caret visible. Blinking will resume after + /// the pre-established interval elapses from the time this is called. + fn pause_blinking(&mut self, cx: &mut Context) { + if !self.visible { + self.visible = true; + cx.notify(); + } + + self.paused = true; + self.generation = self.generation.wrapping_add(1); + + let generation = self.generation; + let interval = self.interval; + + cx.spawn(async move |this, cx| { + async_io::Timer::after(interval).await; + this.update(cx, |this, cx| { + if this.generation == generation { + this.paused = false; + this.spawn_ticker(cx); + } + }) + }) + .detach(); + } + + fn spawn_ticker(&mut self, cx: &mut Context) { + if !self.active || self.paused { + return; + } + + self.visible = !self.visible; + cx.notify(); + + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + let interval = self.interval; + + cx.spawn(async move |this, cx| { + async_io::Timer::after(interval).await; + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + if this.generation == generation { + this.spawn_ticker(cx); + } + }); + } + }) + .detach(); + } +} diff --git a/crates/gpui_elements/src/editable_text/element.rs b/crates/gpui_elements/src/editable_text/element.rs index 003ab3aac7..b3622ff8bf 100644 --- a/crates/gpui_elements/src/editable_text/element.rs +++ b/crates/gpui_elements/src/editable_text/element.rs @@ -197,6 +197,7 @@ struct InteractivityPrepaint { hitbox: Option, scroll_offset: Point, inner_bounds: Bounds, + caret_visible: bool, } #[doc(hidden)] @@ -424,10 +425,18 @@ impl Element for EditableTextElement { ) -> Self::PrepaintState { // should reflect the text content layout size of the stored text, // so that scrolling can take it into account during prepaint. - let content_size = { + let content_size; + let caret; + let focus_handle; + { let state = request_layout.state.read(cx); - state.layout_data.size.unwrap_or_else(|| bounds.size) - }; + content_size = state.layout_data.size.unwrap_or_else(|| bounds.size); + caret = state.caret_entity().clone(); + focus_handle = state.focus_handle(cx); + } + + let is_focused = focus_handle.is_focused(window); + let caret_visible = caret.update(cx, |caret, cx| caret.update_focus(is_focused, cx)); let prepaint = self.interactivity().prepaint( global_id, @@ -460,12 +469,12 @@ impl Element for EditableTextElement { hitbox, scroll_offset, inner_bounds, + caret_visible, } }, ); let state = request_layout.state.read(cx); - let focus_handle = state.focus_handle(cx); let elements = self.build_elements(state, &prepaint, window); PrepaintState { @@ -586,6 +595,7 @@ impl EditableTextElement { hitbox: _, scroll_offset, inner_bounds, + caret_visible, } = prepaint; let caret_pos = state.caret_pos(); @@ -691,9 +701,7 @@ impl EditableTextElement { } } - if state.is_caret_visible(window) - && let Some(carent_point) = caret_point - { + if *caret_visible && let Some(carent_point) = caret_point { const CURSOR_WIDTH: f32 = 2.0; let quad = fill( Bounds::new( diff --git a/crates/gpui_elements/src/editable_text/notify.rs b/crates/gpui_elements/src/editable_text/notify.rs deleted file mode 100644 index 4fcb0467ff..0000000000 --- a/crates/gpui_elements/src/editable_text/notify.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::{ops::Range, time::Instant}; - -use crate::editable_text::UnicodeTextStorage; - -pub struct TextChanged; - -pub struct TextHistoryPushed { - pub timestamp: Instant, - pub modified_range: Range, - pub text_payload: String, - pub new_length: usize, - pub selected_range: Range, -} -impl TextHistoryPushed { - pub fn new( - range: Range, - new_length: usize, - storage: &dyn UnicodeTextStorage, - selected_range: Range, - ) -> Self { - let timestamp = Instant::now(); - let modified_range = range.start..range.start + new_length; - // NOTE: not performant to allocate a new text payload if the event doesnt - // need to be logged (based on timestamp). Should consider a more robust way to access - // the storage only if it absolutely needs to be cloned from. - let text_payload = storage.content_utf8()[range].to_string(); - Self { - timestamp, - modified_range, - text_payload, - new_length, - selected_range, - } - } - - pub fn convert_to_redo(self, content: &str) -> Self { - let undo_start = self.modified_range.start; - let undo_end = (self.modified_range.start + self.new_length).min(content.len()); - let text_payload = content[undo_start..undo_end].to_string(); - let new_length = self.text_payload.len(); - Self { - timestamp: self.timestamp, - modified_range: undo_start..undo_start + self.text_payload.len(), - text_payload, - new_length, - selected_range: self.selected_range, - } - } -} diff --git a/crates/gpui_elements/src/editable_text/state.rs b/crates/gpui_elements/src/editable_text/state.rs index 48d1b6fe2f..96d379ff7e 100644 --- a/crates/gpui_elements/src/editable_text/state.rs +++ b/crates/gpui_elements/src/editable_text/state.rs @@ -1,17 +1,21 @@ use crate::editable_text::{ TextBoundary, UnicodeTextStorage, actions::EditableTextActionHandler, + caret::{Caret, CaretNotify}, history::EditableTextHistory, - notify::{TextChanged, TextHistoryPushed}, }; use gpui::{ - App, Bounds, ClipboardItem, Context, EntityInputHandler, EventEmitter, FocusHandle, Focusable, - NavigationDirection, Pixels, Point, Size, UTF16Selection, Window, WrappedLine, point, + App, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, + Focusable, NavigationDirection, Pixels, Point, Size, UTF16Selection, Window, WrappedLine, + point, }; use std::{borrow::Cow, ops::Range, sync::Arc}; +pub struct TextChanged; + pub struct EditableTextState { storage: Box, + caret: Entity, /// The utf-8 character range that is currently selected by the user. /// Valid both when start < end and start > end (which dictates the direction of the selection). Empty when start==end. @@ -91,7 +95,7 @@ impl TextLineSegment { } impl EventEmitter for EditableTextState {} -impl EventEmitter for EditableTextState {} +impl EventEmitter for EditableTextState {} impl Focusable for EditableTextState { fn focus_handle(&self, _: &App) -> FocusHandle { @@ -101,8 +105,18 @@ impl Focusable for EditableTextState { impl EditableTextState { pub fn new(storage: impl Into>, cx: &mut Context) -> Self { + use gpui::AppContext; + let caret = cx.new({ + let state_entity = cx.entity(); + move |cx| { + let mut caret = Caret::default().blink_interval_default(); + caret.subscribe_to(&state_entity, cx); + caret + } + }); Self { storage: storage.into(), + caret, selected_range: 0..0, marked_range: None, @@ -138,6 +152,10 @@ impl EditableTextState { } } + pub fn caret_entity(&self) -> &Entity { + &self.caret + } + pub fn caret_pos(&self) -> usize { self.selected_range.start } @@ -310,7 +328,7 @@ impl EditableTextState { } pub fn move_to(&mut self, caret_pos: usize, cx: &mut Context) { - //cx.emit(CursorTrigger::PauseBlinkingForUserAction); + cx.emit(CaretNotify::PauseBlinking); let caret_pos = caret_pos.min(self.storage.content_utf8().len()); self.selected_range = caret_pos..caret_pos; self.scroll_to_caret(); @@ -318,7 +336,7 @@ impl EditableTextState { } pub fn select_to(&mut self, caret_pos: usize, cx: &mut Context) { - //cx.emit(CursorTrigger::PauseBlinkingForUserAction); + cx.emit(CaretNotify::PauseBlinking); let caret_pos = caret_pos.min(self.storage().content_utf8().len()); self.selected_range.start = caret_pos; self.scroll_to_caret(); @@ -528,13 +546,6 @@ impl EditableTextState { } } -impl EditableTextState { - pub fn is_caret_visible(&self, window: &Window) -> bool { - // TODO: Cursor blinking - self.focus_handle.is_focused(window) - } -} - impl EntityInputHandler for EditableTextState { fn text_for_range( &mut self, @@ -587,7 +598,7 @@ impl EntityInputHandler for EditableTextState { ) { let range_utf8 = self.ime_resolve_range(range_utf16); self.replace_text_in_range_bytes(range_utf8, text_to_insert, cx); - //cx.emit(CursorTrigger::PauseBlinkingForUserAction); + cx.emit(CaretNotify::PauseBlinking); cx.emit(TextChanged); cx.notify(); }