aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-12 03:36:41 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-12 03:37:03 +0300
commit7ba4bfe90e7a17b11979e729d29d82723bb3b0bf (patch)
treea76257e9e3273e79c9de61711186e2287d778e3a /src/pane
parent6e7ce87bab6a5d4f277613658d0f19afb114128b (diff)
fix: stabilize windows pane cursor rendering
refs #556
Diffstat (limited to 'src/pane')
-rw-r--r--src/pane/cursor.rs315
-rw-r--r--src/pane/terminal.rs223
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();