diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-12 03:36:41 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-12 03:37:03 +0300 |
| commit | 7ba4bfe90e7a17b11979e729d29d82723bb3b0bf (patch) | |
| tree | a76257e9e3273e79c9de61711186e2287d778e3a /src/pane | |
| parent | 6e7ce87bab6a5d4f277613658d0f19afb114128b (diff) | |
fix: stabilize windows pane cursor rendering
refs #556
Diffstat (limited to 'src/pane')
| -rw-r--r-- | src/pane/cursor.rs | 315 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 223 |
2 files changed, 514 insertions, 24 deletions
diff --git a/src/pane/cursor.rs b/src/pane/cursor.rs new file mode 100644 index 00000000..c72f697a --- /dev/null +++ b/src/pane/cursor.rs @@ -0,0 +1,315 @@ +use std::time::{Duration, Instant}; + +use super::terminal::TerminalCursorState; + +pub(crate) const CURSOR_POSITION_SETTLE: Duration = Duration::from_millis(20); +const CURSOR_POSITION_MAX_HOLD: Duration = Duration::from_millis(100); + +#[derive(Debug, Default)] +pub(crate) struct DecscusrTracker { + state: DecscusrParseState, + cursor_shape_overridden: bool, +} + +#[derive(Debug, Default)] +enum DecscusrParseState { + #[default] + Ground, + Escape, + Csi { + first_param: Option<u16>, + collecting_first_param: bool, + has_space_intermediate: bool, + }, +} + +impl DecscusrTracker { + pub(crate) fn observe(&mut self, bytes: &[u8]) { + for &byte in bytes { + self.observe_byte(byte); + } + } + + fn observe_byte(&mut self, byte: u8) { + match &mut self.state { + DecscusrParseState::Ground => { + if byte == 0x1b { + self.state = DecscusrParseState::Escape; + } + } + DecscusrParseState::Escape => { + self.state = if byte == b'[' { + DecscusrParseState::Csi { + first_param: None, + collecting_first_param: true, + has_space_intermediate: false, + } + } else if byte == 0x1b { + DecscusrParseState::Escape + } else { + DecscusrParseState::Ground + }; + } + DecscusrParseState::Csi { + first_param, + collecting_first_param, + has_space_intermediate, + } => { + if byte == 0x1b { + self.state = DecscusrParseState::Escape; + } else if byte.is_ascii_digit() && *collecting_first_param { + let digit = u16::from(byte - b'0'); + *first_param = Some(first_param.unwrap_or(0).saturating_mul(10) + digit); + } else if byte == b';' || byte == b':' { + *collecting_first_param = false; + } else if byte == b' ' { + *has_space_intermediate = true; + *collecting_first_param = false; + } else if (0x40..=0x7e).contains(&byte) { + if byte == b'q' && *has_space_intermediate { + let param = first_param.unwrap_or(0); + if param <= 6 { + self.cursor_shape_overridden = param != 0; + } + } + self.state = DecscusrParseState::Ground; + } else if !(0x20..=0x3f).contains(&byte) { + self.state = DecscusrParseState::Ground; + } + } + } + } + + pub(crate) fn cursor_shape_overridden(&self) -> bool { + self.cursor_shape_overridden + } +} + +#[derive(Debug, Default)] +pub(crate) struct CursorPositionSettleState { + settled: Option<TerminalCursorState>, + candidate: Option<TerminalCursorState>, + pending_since: Option<Instant>, +} + +impl CursorPositionSettleState { + pub(crate) fn observe(&mut self, current: Option<TerminalCursorState>, now: Instant) { + let Some(current) = current else { + self.settled = None; + self.candidate = None; + self.pending_since = None; + return; + }; + if !current.visible { + self.settled = Some(current); + self.candidate = None; + self.pending_since = None; + return; + } + let Some(settled) = self.settled else { + self.settled = Some(current); + self.candidate = None; + self.pending_since = None; + return; + }; + if same_cursor_position(settled, current) && settled.visible { + self.settled = Some(current); + self.candidate = None; + self.pending_since = None; + return; + } + + let Some(candidate) = self.candidate else { + self.candidate = Some(current); + self.pending_since = Some(now); + return; + }; + + let pending_since = self.pending_since.unwrap_or(now); + if now.duration_since(pending_since) >= CURSOR_POSITION_MAX_HOLD { + self.settled = Some(current); + self.candidate = None; + self.pending_since = None; + } else if same_cursor_position(candidate, current) { + if now.duration_since(pending_since) >= CURSOR_POSITION_SETTLE { + self.settled = Some(current); + self.candidate = None; + self.pending_since = None; + } else { + self.candidate = Some(current); + } + } else { + self.candidate = Some(current); + } + } + + pub(crate) fn reported_cursor( + &self, + current: Option<TerminalCursorState>, + now: Instant, + ) -> Option<TerminalCursorState> { + let current = current?; + let Some(candidate) = self.candidate else { + return Some(current); + }; + let pending_since = self.pending_since.unwrap_or(now); + if now.duration_since(pending_since) >= CURSOR_POSITION_SETTLE { + return Some(TerminalCursorState { + visible: current.visible && candidate.visible, + shape: current.shape, + ..candidate + }); + } + self.settled + .map(|settled| TerminalCursorState { + visible: current.visible && settled.visible, + shape: current.shape, + ..settled + }) + .or(Some(TerminalCursorState { + visible: false, + shape: current.shape, + ..candidate + })) + } + + pub(crate) fn pending(&self) -> bool { + self.candidate.is_some() + } +} + +fn same_cursor_position(left: TerminalCursorState, right: TerminalCursorState) -> bool { + left.x == right.x && left.y == right.y +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cursor(x: u16, y: u16, visible: bool, shape: u8) -> TerminalCursorState { + TerminalCursorState { + x, + y, + visible, + shape, + } + } + + #[test] + fn cursor_settle_holds_position_change_until_quiet_window() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(cursor(1, 0, true, 0)), now); + settle.observe(Some(cursor(20, 5, true, 0)), now + Duration::from_millis(1)); + + let reported = settle + .reported_cursor(Some(cursor(20, 5, true, 0)), now + Duration::from_millis(2)) + .unwrap(); + + assert_eq!((reported.x, reported.y), (1, 0)); + } + + #[test] + fn cursor_settle_adopts_position_change_after_quiet_window() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(cursor(1, 0, true, 0)), now); + settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1)); + + let reported = settle + .reported_cursor( + Some(cursor(2, 0, true, 0)), + now + CURSOR_POSITION_SETTLE + Duration::from_millis(1), + ) + .unwrap(); + + assert_eq!((reported.x, reported.y), (2, 0)); + } + + #[test] + fn cursor_settle_caps_continuous_position_changes_from_first_pending_time() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(cursor(1, 0, true, 0)), now); + settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1)); + settle.observe( + Some(cursor(3, 0, true, 0)), + now + CURSOR_POSITION_MAX_HOLD + Duration::from_millis(1), + ); + + assert!(!settle.pending()); + assert_eq!( + settle.reported_cursor( + Some(cursor(3, 0, true, 0)), + now + CURSOR_POSITION_MAX_HOLD + Duration::from_millis(2), + ), + Some(cursor(3, 0, true, 0)) + ); + } + + #[test] + fn cursor_settle_keeps_render_read_pure() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(cursor(1, 0, true, 0)), now); + settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1)); + + assert!(settle.pending()); + let _ = settle.reported_cursor( + Some(cursor(2, 0, true, 0)), + now + CURSOR_POSITION_SETTLE + Duration::from_millis(1), + ); + + assert!(settle.pending()); + } + + #[test] + fn cursor_settle_passes_shape_through_while_position_is_held() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(cursor(1, 0, true, 2)), now); + settle.observe(Some(cursor(2, 0, true, 6)), now + Duration::from_millis(1)); + + let reported = settle + .reported_cursor(Some(cursor(2, 0, true, 6)), now + Duration::from_millis(2)) + .unwrap(); + + assert_eq!((reported.x, reported.y, reported.shape), (1, 0, 6)); + } + + #[test] + fn cursor_settle_passes_shape_through_after_quiet_window() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(cursor(1, 0, true, 2)), now); + settle.observe(Some(cursor(2, 0, true, 2)), now + Duration::from_millis(1)); + + let reported = settle + .reported_cursor( + Some(cursor(2, 0, true, 6)), + now + CURSOR_POSITION_SETTLE + Duration::from_millis(1), + ) + .unwrap(); + + assert_eq!((reported.x, reported.y, reported.shape), (2, 0, 6)); + } + + #[test] + fn cursor_settle_hides_immediately_and_waits_to_reveal() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(cursor(1, 0, true, 0)), now); + settle.observe(Some(cursor(1, 0, false, 0)), now + Duration::from_millis(1)); + + assert_eq!( + settle.reported_cursor(Some(cursor(1, 0, false, 0)), now + Duration::from_millis(2)), + Some(cursor(1, 0, false, 0)) + ); + + settle.observe(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(3)); + assert_eq!( + settle.reported_cursor(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(4)), + Some(cursor(1, 0, false, 0)) + ); + } +} diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 9caa3dba..eca56975 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use bytes::Bytes; use ratatui::style::{Color, Modifier, Style}; @@ -13,6 +13,7 @@ use unicode_width::UnicodeWidthStr; use crate::layout::PaneId; use crate::protocol::CellData; +use super::cursor::{CursorPositionSettleState, DecscusrTracker, CURSOR_POSITION_SETTLE}; use super::{ input::{ ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal, @@ -32,6 +33,7 @@ use super::{ const DEFAULT_DETECTION_ROWS: usize = 24; const KITTY_GRAPHICS_REDRAW_SETTLE: Duration = Duration::from_millis(20); +const CURSOR_POSITION_SETTLE_ENABLED: bool = cfg!(windows); const MODE_MOUSE_X10: u16 = 9; const MODE_MOUSE_PRESS_RELEASE: u16 = 1000; const MODE_MOUSE_BUTTON_MOTION: u16 = 1002; @@ -129,6 +131,8 @@ pub(crate) struct GhosttyPaneCore { pub osc_debug_tracker: OscDebugTracker, pub agent_osc_state: AgentOscStateTracker, pub xtgettcap_query_tracker: XtgettcapQueryTracker, + decscusr_tracker: DecscusrTracker, + cursor_settle_state: CursorPositionSettleState, } pub(crate) struct PaneTerminal { @@ -385,6 +389,8 @@ impl GhosttyPaneTerminal { osc_debug_tracker: OscDebugTracker::default(), agent_osc_state: AgentOscStateTracker::default(), xtgettcap_query_tracker: XtgettcapQueryTracker::default(), + decscusr_tracker: DecscusrTracker::default(), + cursor_settle_state: CursorPositionSettleState::default(), }), key_encoder: Mutex::new(key_encoder), pending_pty_responses, @@ -540,6 +546,7 @@ impl GhosttyPaneTerminal { .observe(filtered_bytes.as_ref()); core.xtgettcap_query_tracker .observe(filtered_bytes.as_ref()); + core.decscusr_tracker.observe(filtered_bytes.as_ref()); let default_color_events = core.default_color_event_tracker.drain_pending(); let xtgettcap_responses = core.xtgettcap_query_tracker.drain_pending(); let write_started = crate::render_prof::timer(); @@ -564,9 +571,20 @@ impl GhosttyPaneTerminal { .terminal .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) .unwrap_or(false); + if CURSOR_POSITION_SETTLE_ENABLED { + let cursor_started = crate::render_prof::timer(); + let cursor_after_write = current_cursor_state(&mut core); + crate::render_prof::duration_since("pty.cursor_state_update", cursor_started); + core.cursor_settle_state + .observe(cursor_after_write, Instant::now()); + } let request_render = !synchronized_output && !has_kitty_graphics_sequence; - let render_delay = (!synchronized_output && has_kitty_graphics_sequence) - .then_some(KITTY_GRAPHICS_REDRAW_SETTLE); + let render_delay = render_delay_after_pty_write( + synchronized_output, + has_kitty_graphics_sequence, + cursor_position_settle_pending(&core), + CURSOR_POSITION_SETTLE_ENABLED, + ); if request_render { crate::render_prof::event("pty.request_render"); } @@ -957,25 +975,8 @@ impl GhosttyPaneTerminal { pub fn cursor_state(&self) -> Option<TerminalCursorState> { let mut core = self.core.lock().ok()?; - let GhosttyPaneCore { - terminal, - render_state, - .. - } = &mut *core; - render_state.update(terminal).ok()?; - let cursor = render_state.cursor_viewport().ok()??; - let shape = render_state - .cursor_visual_style() - .ok() - .zip(render_state.cursor_blinking().ok()) - .map(|(style, blinking)| decscusr_cursor_shape(style, blinking)) - .unwrap_or(0); - Some(TerminalCursorState { - x: cursor.x, - y: cursor.y, - visible: render_state.cursor_visible().ok()?, - shape, - }) + let current = current_cursor_state(&mut core); + effective_cursor_state(&mut core, current) } pub fn synchronized_output_active(&self) -> bool { @@ -1168,6 +1169,7 @@ impl GhosttyPaneTerminal { let GhosttyPaneCore { terminal, render_state, + decscusr_tracker, .. } = &mut *core; if render_state.update(terminal).is_err() { @@ -1253,8 +1255,11 @@ impl GhosttyPaneTerminal { ghostty_clear_render_dirty(render_state, area.height); - if show_cursor && render_state.cursor_visible().ok() == Some(true) { - if let Ok(Some(cursor)) = render_state.cursor_viewport() { + let current_cursor = cursor_state_from_render_state(render_state, decscusr_tracker); + if show_cursor { + if let Some(cursor) = + effective_cursor_state(&mut core, current_cursor).filter(|cursor| cursor.visible) + { if cursor.x < area.width && cursor.y < area.height { frame.set_cursor_position((area.x + cursor.x, area.y + cursor.y)); } @@ -1275,6 +1280,72 @@ impl GhosttyPaneTerminal { } } +fn cursor_position_settle_pending(core: &GhosttyPaneCore) -> bool { + core.cursor_settle_state.pending() +} + +fn effective_cursor_state( + core: &mut GhosttyPaneCore, + current: Option<TerminalCursorState>, +) -> Option<TerminalCursorState> { + if !CURSOR_POSITION_SETTLE_ENABLED { + return current; + } + core.cursor_settle_state + .reported_cursor(current, Instant::now()) +} + +fn render_delay_after_pty_write( + synchronized_output: bool, + has_kitty_graphics_sequence: bool, + cursor_position_settle_pending: bool, + cursor_position_settle_enabled: bool, +) -> Option<Duration> { + if synchronized_output { + None + } else if has_kitty_graphics_sequence { + Some(KITTY_GRAPHICS_REDRAW_SETTLE) + } else if cursor_position_settle_enabled && cursor_position_settle_pending { + Some(CURSOR_POSITION_SETTLE) + } else { + None + } +} + +fn current_cursor_state(core: &mut GhosttyPaneCore) -> Option<TerminalCursorState> { + let GhosttyPaneCore { + terminal, + render_state, + decscusr_tracker, + .. + } = core; + render_state.update(terminal).ok()?; + cursor_state_from_render_state(render_state, decscusr_tracker) +} + +fn cursor_state_from_render_state( + render_state: &mut crate::ghostty::RenderState, + decscusr_tracker: &DecscusrTracker, +) -> Option<TerminalCursorState> { + let cursor = render_state.cursor_viewport().ok()??; + let shape = if decscusr_tracker.cursor_shape_overridden() { + render_state + .cursor_visual_style() + .ok() + .zip(render_state.cursor_blinking().ok()) + .map(|(style, blinking)| decscusr_cursor_shape(style, blinking)) + .unwrap_or(0) + } else { + 0 + }; + Some(TerminalCursorState { + x: cursor.x, + y: cursor.y, + visible: render_state.cursor_visible().ok()?, + shape, + }) +} + type VisibleHyperlinks = Vec<((u16, u16), String, String)>; fn ghostty_clear_render_dirty(render_state: &mut crate::ghostty::RenderState, area_height: u16) { @@ -2099,6 +2170,110 @@ mod tests { } #[test] + fn cursor_state_uses_terminal_default_until_child_sets_shape() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + assert_eq!(pane.cursor_state().unwrap().shape, 0); + + pane.process_pty_bytes(pane_id, 0, b"\x1b[6 q", &tx); + + assert_eq!(pane.cursor_state().unwrap().shape, 6); + } + + #[test] + fn cursor_state_returns_terminal_default_after_decscusr_reset() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.process_pty_bytes(pane_id, 0, b"\x1b[2 q", &tx); + assert_eq!(pane.cursor_state().unwrap().shape, 2); + + pane.process_pty_bytes(pane_id, 0, b"\x1b[0 q", &tx); + + assert_eq!(pane.cursor_state().unwrap().shape, 0); + } + + #[test] + fn cursor_shape_tracker_handles_split_decscusr_sequences() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.process_pty_bytes(pane_id, 0, b"\x1b[", &tx); + pane.process_pty_bytes(pane_id, 0, b"5 ", &tx); + pane.process_pty_bytes(pane_id, 0, b"q", &tx); + + assert_eq!(pane.cursor_state().unwrap().shape, 5); + } + + #[test] + #[cfg(windows)] + fn cursor_state_holds_pty_position_change_until_settle_window() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.process_pty_bytes(pane_id, 0, b"x", &tx); + assert_eq!( + pane.cursor_state() + .map(|cursor| (cursor.x, cursor.y, cursor.visible)), + Some((1, 0, true)) + ); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[6;21H", &tx); + + assert_eq!(result.render_delay, Some(CURSOR_POSITION_SETTLE)); + assert_eq!( + pane.cursor_state() + .map(|cursor| (cursor.x, cursor.y, cursor.visible)), + Some((1, 0, true)) + ); + } + + #[test] + #[cfg(not(windows))] + fn cursor_state_uses_live_position_when_settle_policy_disabled() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.process_pty_bytes(pane_id, 0, b"x", &tx); + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[6;21H", &tx); + + assert_eq!(result.render_delay, None); + assert_eq!( + pane.cursor_state() + .map(|cursor| (cursor.x, cursor.y, cursor.visible)), + Some((20, 5, true)) + ); + } + + #[test] + fn cursor_settle_policy_controls_render_delay() { + assert_eq!( + render_delay_after_pty_write(false, false, true, true), + Some(CURSOR_POSITION_SETTLE) + ); + assert_eq!( + render_delay_after_pty_write(false, false, true, false), + None + ); + assert_eq!( + render_delay_after_pty_write(false, true, true, false), + Some(KITTY_GRAPHICS_REDRAW_SETTLE) + ); + assert_eq!(render_delay_after_pty_write(true, false, true, true), None); + } + + #[test] fn host_terminal_theme_restore_probe_skips_when_no_transient_override() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); |
