diff --git a/crates/kild-ui/src/terminal/blink.rs b/crates/kild-ui/src/terminal/blink.rs new file mode 100644 index 00000000..1b085038 --- /dev/null +++ b/crates/kild-ui/src/terminal/blink.rs @@ -0,0 +1,107 @@ +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. +/// +/// 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, + /// 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>, +} + +impl BlinkManager { + pub fn new() -> Self { + Self { + visible: true, + enabled: false, + epoch: 0, + _task: None, + } + } + + /// 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; + self.visible = true; + self.start_cycle(cx); + } + + /// 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 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; + } + self.visible = true; + 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; + + self._task = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + loop { + cx.background_executor().timer(BLINK_INTERVAL).await; + + 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 49820fe7..31e6bcee 100644 --- a/crates/kild-ui/src/terminal/mod.rs +++ b/crates/kild-ui/src/terminal/mod.rs @@ -1,3 +1,4 @@ +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..5537d2b4 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; @@ -18,11 +19,16 @@ 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. mouse_state: MouseState, @@ -71,10 +77,12 @@ impl TerminalView { .await; }); + // Blink starts inert — render() enables it once focus is confirmed. Self { terminal, focus_handle, _event_task: event_task, + blink: BlinkManager::new(), mouse_state: MouseState { position: None, cmd_held: false, @@ -124,6 +132,7 @@ impl TerminalView { terminal, focus_handle, _event_task: event_task, + blink: BlinkManager::new(), mouse_state: MouseState { position: None, cmd_held: false, @@ -191,6 +200,8 @@ impl TerminalView { } fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + self.blink.reset(cx); + let key = event.keystroke.key.as_str(); let cmd = event.keystroke.modifiers.platform; @@ -285,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)) @@ -310,12 +329,16 @@ impl Render for TerminalView { ); } + // 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( content, term, has_focus, resize_handle, - true, + cursor_visible, MouseState { position: self.mouse_state.position, cmd_held: self.mouse_state.cmd_held,