add caret blinking which is handled via a separate entity

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent e7e4c9adf4
commit f3e9ae3c56
5 changed files with 205 additions and 72 deletions
+1 -2
View File
@@ -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)
@@ -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<E>(&mut self, emitter: &Entity<E>, cx: &mut Context<Self>)
where
E: EventEmitter<CaretNotify>,
{
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<Self>) -> 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<Self>) {
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>) {
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<Self>) {
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<Self>) {
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();
}
}
@@ -197,6 +197,7 @@ struct InteractivityPrepaint {
hitbox: Option<Hitbox>,
scroll_offset: Point<Pixels>,
inner_bounds: Bounds<Pixels>,
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(
@@ -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<usize>,
pub text_payload: String,
pub new_length: usize,
pub selected_range: Range<usize>,
}
impl TextHistoryPushed {
pub fn new(
range: Range<usize>,
new_length: usize,
storage: &dyn UnicodeTextStorage,
selected_range: Range<usize>,
) -> 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,
}
}
}
+25 -14
View File
@@ -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<dyn UnicodeTextStorage>,
caret: Entity<Caret>,
/// 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<TextChanged> for EditableTextState {}
impl EventEmitter<TextHistoryPushed> for EditableTextState {}
impl EventEmitter<CaretNotify> 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<Box<dyn UnicodeTextStorage>>, cx: &mut Context<Self>) -> 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<Caret> {
&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<Self>) {
//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<Self>) {
//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();
}