Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions crates/kild-ui/src/terminal/blink.rs
Original file line number Diff line number Diff line change
@@ -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<Task<()>>,
}

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<TerminalView>) {
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<TerminalView>) {
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<TerminalView>) -> bool {
if self.epoch != epoch {
return false;
}
self.visible = !self.visible;
cx.notify();
true
}

fn start_cycle(&mut self, cx: &mut Context<TerminalView>) {
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;
}
}
}));
}
}
1 change: 1 addition & 0 deletions crates/kild-ui/src/terminal/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod blink;
pub mod colors;
pub mod errors;
pub mod input;
Expand Down
25 changes: 24 additions & 1 deletion crates/kild-ui/src/terminal/terminal_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -124,6 +132,7 @@ impl TerminalView {
terminal,
focus_handle,
_event_task: event_task,
blink: BlinkManager::new(),
mouse_state: MouseState {
position: None,
cmd_held: false,
Expand Down Expand Up @@ -191,6 +200,8 @@ impl TerminalView {
}

fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
self.blink.reset(cx);

let key = event.keystroke.key.as_str();
let cmd = event.keystroke.modifiers.platform;

Expand Down Expand Up @@ -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))
Expand All @@ -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,
Expand Down