From 1453e45755004a95cb72b5c80f5e4543ce187a9b Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Sat, 28 Feb 2026 10:17:48 +0200 Subject: [PATCH 1/2] feat(ui): add cursor blink support with BlinkManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add epoch-based cursor blink timer to TerminalView. The BlinkManager toggles cursor visibility every 500ms via cx.spawn(), with epoch tracking to cancel stale timers when a new cycle starts. Cursor stays visible during typing — pause() on every keystroke resets the blink timer. Unfocused terminals show a static hollow block cursor (no blinking). Closes #471 --- crates/kild-ui/src/terminal/blink.rs | 81 ++++++++++++++++++++ crates/kild-ui/src/terminal/mod.rs | 1 + crates/kild-ui/src/terminal/terminal_view.rs | 18 ++++- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 crates/kild-ui/src/terminal/blink.rs diff --git a/crates/kild-ui/src/terminal/blink.rs b/crates/kild-ui/src/terminal/blink.rs new file mode 100644 index 00000000..dc3f7213 --- /dev/null +++ b/crates/kild-ui/src/terminal/blink.rs @@ -0,0 +1,81 @@ +use std::time::Duration; + +use gpui::{Context, Task}; + +use super::terminal_view::TerminalView; + +const BLINK_INTERVAL: Duration = Duration::from_millis(500); + +/// Manages cursor blink state with epoch-based timer cancellation. +/// +/// Each call to `enable()` or `pause()` increments the epoch and spawns a new +/// async blink cycle via `cx.spawn()` on the owning `TerminalView`. Old cycles +/// detect the stale epoch and exit, so at most one timer drives repaints. +pub struct BlinkManager { + visible: bool, + enabled: bool, + epoch: usize, + /// Stored to prevent cancellation of the current blink timer task. + _task: Option>, +} + +impl BlinkManager { + pub fn new() -> Self { + Self { + visible: true, + enabled: false, + epoch: 0, + _task: None, + } + } + + /// Whether the cursor should be painted this frame. + pub fn visible(&self) -> bool { + !self.enabled || self.visible + } + + /// Start blinking. Resets visibility and spawns a new blink cycle. + pub fn enable(&mut self, cx: &mut Context) { + self.enabled = true; + self.visible = true; + self.start_cycle(cx); + } + + /// Show the cursor immediately and restart the blink timer. + /// + /// Call on every keystroke so the cursor stays visible during typing + /// and only starts blinking again after a full interval of inactivity. + pub fn pause(&mut self, cx: &mut Context) { + if !self.enabled { + return; + } + self.visible = true; + self.start_cycle(cx); + } + + fn start_cycle(&mut self, cx: &mut Context) { + self.epoch = self.epoch.wrapping_add(1); + let epoch = self.epoch; + + self._task = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + loop { + cx.background_executor().timer(BLINK_INTERVAL).await; + + let should_continue = this + .update(cx, |view, cx| { + if view.blink.epoch != epoch { + return false; + } + view.blink.visible = !view.blink.visible; + cx.notify(); + true + }) + .unwrap_or(false); + + if !should_continue { + break; + } + } + })); + } +} diff --git a/crates/kild-ui/src/terminal/mod.rs b/crates/kild-ui/src/terminal/mod.rs index 49820fe7..966c119b 100644 --- a/crates/kild-ui/src/terminal/mod.rs +++ b/crates/kild-ui/src/terminal/mod.rs @@ -1,3 +1,4 @@ +pub mod blink; pub mod colors; pub mod errors; pub mod input; diff --git a/crates/kild-ui/src/terminal/terminal_view.rs b/crates/kild-ui/src/terminal/terminal_view.rs index 3a54245e..b7e50f7e 100644 --- a/crates/kild-ui/src/terminal/terminal_view.rs +++ b/crates/kild-ui/src/terminal/terminal_view.rs @@ -3,6 +3,7 @@ use gpui::{ ScrollWheelEvent, Task, Window, div, prelude::*, px, }; +use super::blink::BlinkManager; use super::terminal_element::scroll_delta_lines; use super::input; @@ -23,6 +24,8 @@ pub struct TerminalView { focus_handle: FocusHandle, /// Event batching task. Stored to prevent cancellation. _event_task: Task<()>, + /// Cursor blink state. Toggled by an epoch-based async timer. + pub(super) blink: BlinkManager, /// Mouse state passed to TerminalElement on each render. /// TerminalElement is reconstructed every frame -- do not cache instances. mouse_state: MouseState, @@ -71,10 +74,14 @@ impl TerminalView { .await; }); + let mut blink = BlinkManager::new(); + blink.enable(cx); + Self { terminal, focus_handle, _event_task: event_task, + blink, mouse_state: MouseState { position: None, cmd_held: false, @@ -120,10 +127,14 @@ impl TerminalView { .await; }); + let mut blink = BlinkManager::new(); + blink.enable(cx); + Self { terminal, focus_handle, _event_task: event_task, + blink, mouse_state: MouseState { position: None, cmd_held: false, @@ -191,6 +202,8 @@ impl TerminalView { } fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + self.blink.pause(cx); + let key = event.keystroke.key.as_str(); let cmd = event.keystroke.modifiers.platform; @@ -310,12 +323,15 @@ impl Render for TerminalView { ); } + // Only blink when focused — unfocused terminals show a static hollow block. + let cursor_visible = !has_focus || self.blink.visible(); + container.child(TerminalElement::new( content, term, has_focus, resize_handle, - true, + cursor_visible, MouseState { position: self.mouse_state.position, cmd_held: self.mouse_state.cmd_held, From 3b9306de5813d920aa2284766b8c4fbbedc44910 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Sat, 28 Feb 2026 10:25:36 +0200 Subject: [PATCH 2/2] fix(ui): address review findings in cursor blink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop blink timer on focus loss, restart on focus gain — no more wasteful repaints on unfocused terminals, and cursor state is always reset when focus returns (even via mouse click) - Replace .unwrap_or(false) with explicit match on view update — view-released teardown is now a distinct code path, not silently collapsed with stale-epoch exits - Encapsulate toggle logic behind toggle_if_current() method — blink field stays pub(super) for the spawn closure but mutation is self-contained - Rename pause() to reset() — matches actual semantics (restarts the blink cycle, not a pause) - Remove two-step new() + enable() — blink starts inert, render() drives the lifecycle based on focus state - Tighten module visibility: pub mod blink → mod blink - Fix doc comments for accuracy --- crates/kild-ui/src/terminal/blink.rs | 64 ++++++++++++++------ crates/kild-ui/src/terminal/mod.rs | 2 +- crates/kild-ui/src/terminal/terminal_view.rs | 27 ++++++--- 3 files changed, 63 insertions(+), 30 deletions(-) diff --git a/crates/kild-ui/src/terminal/blink.rs b/crates/kild-ui/src/terminal/blink.rs index dc3f7213..1b085038 100644 --- a/crates/kild-ui/src/terminal/blink.rs +++ b/crates/kild-ui/src/terminal/blink.rs @@ -8,14 +8,19 @@ const BLINK_INTERVAL: Duration = Duration::from_millis(500); /// Manages cursor blink state with epoch-based timer cancellation. /// -/// Each call to `enable()` or `pause()` increments the epoch and spawns a new -/// async blink cycle via `cx.spawn()` on the owning `TerminalView`. Old cycles -/// detect the stale epoch and exit, so at most one timer drives repaints. -pub struct BlinkManager { +/// Constructed in an inert state. Call `enable()` to start blinking and +/// `disable()` to stop. `TerminalView` drives the lifecycle from `render()` +/// based on focus state — blinking only runs while the terminal is focused. +/// +/// Each `enable()` or `reset()` call increments the epoch and spawns a new +/// async blink cycle via `cx.spawn()`. Old cycles detect the stale epoch +/// and exit, so at most one timer drives repaints. +pub(super) struct BlinkManager { visible: bool, enabled: bool, epoch: usize, - /// Stored to prevent cancellation of the current blink timer task. + /// Dropping a GPUI `Task` detaches it (the future keeps running until it + /// exits). Stored here so the current cycle is accessible for replacement. _task: Option>, } @@ -30,10 +35,17 @@ impl BlinkManager { } /// Whether the cursor should be painted this frame. + /// + /// Returns `true` when blinking is disabled (cursor always on) or when + /// the blink cycle is in its visible phase. pub fn visible(&self) -> bool { !self.enabled || self.visible } + pub fn is_enabled(&self) -> bool { + self.enabled + } + /// Start blinking. Resets visibility and spawns a new blink cycle. pub fn enable(&mut self, cx: &mut Context) { self.enabled = true; @@ -41,11 +53,19 @@ impl BlinkManager { self.start_cycle(cx); } - /// Show the cursor immediately and restart the blink timer. + /// Stop blinking and keep the cursor permanently visible. + pub fn disable(&mut self) { + self.enabled = false; + self.visible = true; + self.epoch = self.epoch.wrapping_add(1); + self._task = None; + } + + /// Reset cursor to visible and restart the blink timer from zero. /// - /// Call on every keystroke so the cursor stays visible during typing - /// and only starts blinking again after a full interval of inactivity. - pub fn pause(&mut self, cx: &mut Context) { + /// Call on every keystroke so the cursor stays solid during typing and + /// only resumes blinking after a full 500ms of inactivity. + pub fn reset(&mut self, cx: &mut Context) { if !self.enabled { return; } @@ -53,6 +73,17 @@ impl BlinkManager { self.start_cycle(cx); } + /// Toggle visibility if this cycle is still current. Returns `false` when + /// the epoch is stale, signaling the caller to exit the blink loop. + fn toggle_if_current(&mut self, epoch: usize, cx: &mut Context) -> bool { + if self.epoch != epoch { + return false; + } + self.visible = !self.visible; + cx.notify(); + true + } + fn start_cycle(&mut self, cx: &mut Context) { self.epoch = self.epoch.wrapping_add(1); let epoch = self.epoch; @@ -61,16 +92,11 @@ impl BlinkManager { loop { cx.background_executor().timer(BLINK_INTERVAL).await; - let should_continue = this - .update(cx, |view, cx| { - if view.blink.epoch != epoch { - return false; - } - view.blink.visible = !view.blink.visible; - cx.notify(); - true - }) - .unwrap_or(false); + let should_continue = + match this.update(cx, |view, cx| view.blink.toggle_if_current(epoch, cx)) { + Ok(cont) => cont, + Err(_) => break, // view released — normal teardown + }; if !should_continue { break; diff --git a/crates/kild-ui/src/terminal/mod.rs b/crates/kild-ui/src/terminal/mod.rs index 966c119b..31e6bcee 100644 --- a/crates/kild-ui/src/terminal/mod.rs +++ b/crates/kild-ui/src/terminal/mod.rs @@ -1,4 +1,4 @@ -pub mod blink; +mod blink; pub mod colors; pub mod errors; pub mod input; diff --git a/crates/kild-ui/src/terminal/terminal_view.rs b/crates/kild-ui/src/terminal/terminal_view.rs index b7e50f7e..5537d2b4 100644 --- a/crates/kild-ui/src/terminal/terminal_view.rs +++ b/crates/kild-ui/src/terminal/terminal_view.rs @@ -19,12 +19,15 @@ use crate::views::main_view::keybindings::UiKeybindings; /// - Focus handling (keyboard events route here when terminal is visible) /// - Key-to-escape translation via `input::keystroke_to_escape()` /// - Event batching with repaint notification after each batch +/// - Cursor blink timing via `BlinkManager` (epoch-based, resets on keystroke) pub struct TerminalView { terminal: Terminal, focus_handle: FocusHandle, /// Event batching task. Stored to prevent cancellation. _event_task: Task<()>, /// Cursor blink state. Toggled by an epoch-based async timer. + /// Enabled/disabled in `render()` based on focus state. + /// `pub(super)` so the blink timer closure in `blink.rs` can access it. pub(super) blink: BlinkManager, /// Mouse state passed to TerminalElement on each render. /// TerminalElement is reconstructed every frame -- do not cache instances. @@ -74,14 +77,12 @@ impl TerminalView { .await; }); - let mut blink = BlinkManager::new(); - blink.enable(cx); - + // Blink starts inert — render() enables it once focus is confirmed. Self { terminal, focus_handle, _event_task: event_task, - blink, + blink: BlinkManager::new(), mouse_state: MouseState { position: None, cmd_held: false, @@ -127,14 +128,11 @@ impl TerminalView { .await; }); - let mut blink = BlinkManager::new(); - blink.enable(cx); - Self { terminal, focus_handle, _event_task: event_task, - blink, + blink: BlinkManager::new(), mouse_state: MouseState { position: None, cmd_held: false, @@ -202,7 +200,7 @@ impl TerminalView { } fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { - self.blink.pause(cx); + self.blink.reset(cx); let key = event.keystroke.key.as_str(); let cmd = event.keystroke.modifiers.platform; @@ -298,6 +296,14 @@ impl Render for TerminalView { let resize_handle = self.terminal.resize_handle(); let error = self.terminal.error_message(); + // Drive blink lifecycle from focus state. Gaining focus starts the + // timer; losing focus stops it and holds the cursor visible. + if has_focus && !self.blink.is_enabled() { + self.blink.enable(cx); + } else if !has_focus && self.blink.is_enabled() { + self.blink.disable(); + } + let mut container = div() .track_focus(&self.focus_handle) .on_key_down(cx.listener(Self::on_key_down)) @@ -323,7 +329,8 @@ impl Render for TerminalView { ); } - // Only blink when focused — unfocused terminals show a static hollow block. + // Blink state only applies when focused. Unfocused terminals always + // show the cursor (prepaint renders it as a half-opacity hollow block). let cursor_visible = !has_focus || self.blink.visible(); container.child(TerminalElement::new(