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 | |
| parent | 6e7ce87bab6a5d4f277613658d0f19afb114128b (diff) | |
fix: stabilize windows pane cursor rendering
refs #556
| -rw-r--r-- | src/client/mod.rs | 58 | ||||
| -rw-r--r-- | src/pane.rs | 1 | ||||
| -rw-r--r-- | src/pane/cursor.rs | 315 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 223 | ||||
| -rw-r--r-- | src/protocol/render_ansi.rs | 131 |
5 files changed, 519 insertions, 209 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs index 1e46f190..e4daf82d 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -19,8 +19,6 @@ use std::io::{self, Write as _}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; -#[cfg(windows)] -use std::time::Instant; use crossterm::event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, @@ -70,21 +68,12 @@ struct ClientState { mouse_scroll_lines: usize, /// Whether outer focus gain should force a full host-terminal redraw. redraw_on_focus_gained: bool, - #[cfg(windows)] - pending_cursor_reveal: Option<PendingCursorReveal>, } #[derive(Debug, Default)] #[cfg(windows)] struct AttachEscapeState; -#[derive(Debug)] -#[cfg(windows)] -struct PendingCursorReveal { - due_at: Instant, - bytes: Vec<u8>, -} - #[derive(Debug, Default)] #[cfg(unix)] struct AttachEscapeState { @@ -212,37 +201,6 @@ fn attach_scroll_action( impl ClientState { fn request_full_redraw(&mut self) { self.blit_encoder = render_ansi::BlitEncoder::new(); - #[cfg(windows)] - { - self.pending_cursor_reveal = None; - } - } - - #[cfg(windows)] - fn update_pending_cursor_reveal(&mut self, reveal: Option<Vec<u8>>) { - const CURSOR_REVEAL_DEBOUNCE: Duration = Duration::from_millis(90); - self.pending_cursor_reveal = reveal.map(|bytes| PendingCursorReveal { - due_at: Instant::now() + CURSOR_REVEAL_DEBOUNCE, - bytes, - }); - } - - #[cfg(windows)] - fn flush_pending_cursor_reveal_if_due(&mut self) { - let Some(pending) = &self.pending_cursor_reveal else { - return; - }; - if pending.due_at > Instant::now() { - return; - } - - let pending = self - .pending_cursor_reveal - .take() - .expect("pending cursor reveal"); - let mut stdout = io::stdout(); - let _ = stdout.write_all(&pending.bytes); - let _ = stdout.flush(); } } @@ -813,8 +771,6 @@ async fn run_client_loop( #[cfg(unix)] mouse_scroll_lines, redraw_on_focus_gained, - #[cfg(windows)] - pending_cursor_reveal: None, }; debug!(?negotiated_encoding, "client render encoding active"); @@ -986,14 +942,7 @@ async fn run_client_loop( } ClientLoopEvent::ServerMessage(msg) => match msg { ServerMessage::Frame(frame_data) => { - #[cfg(windows)] - let encoded = state - .blit_encoder - .encode_with_deferred_cursor_reveal(&frame_data, false); - #[cfg(not(windows))] let encoded = state.blit_encoder.encode(&frame_data, false); - #[cfg(windows)] - let deferred_cursor_reveal = encoded.deferred_cursor_reveal.clone(); let mut stdout = io::stdout(); let graphics = if state.kitty_graphics_enabled { frame_data.graphics.as_slice() @@ -1004,8 +953,6 @@ async fn run_client_loop( write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); let _ = stdout.flush(); state.blit_encoder.commit(frame_data, encoded); - #[cfg(windows)] - state.update_pending_cursor_reveal(deferred_cursor_reveal); } ServerMessage::Terminal(frame) => { if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) { @@ -1060,10 +1007,7 @@ async fn run_client_loop( "server closed connection", ))); } - ClientLoopEvent::Timer => { - #[cfg(windows)] - state.flush_pending_cursor_reveal_if_due(); - } + ClientLoopEvent::Timer => {} } } diff --git a/src/pane.rs b/src/pane.rs index 900e32ec..64fbf32b 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -24,6 +24,7 @@ use crate::layout::PaneId; use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyIoActorHandle, PtyReadResult}; mod agent_detection; +mod cursor; mod input; mod kitty_keyboard; mod osc; 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(); diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index d8c92795..12606bff 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -39,9 +39,6 @@ pub(crate) struct EncodedBlit { pub(crate) bytes: Vec<u8>, /// Whether this frame was encoded as a full redraw. pub(crate) full: bool, - /// Cursor reveal bytes to write after a short quiet period. - #[cfg(any(windows, test))] - pub(crate) deferred_cursor_reveal: Option<Vec<u8>>, next_last_visible_cursor: Option<(u16, u16)>, next_last_cursor_shape: u8, } @@ -60,24 +57,10 @@ impl BlitEncoder { } pub(crate) fn encode(&self, frame: &FrameData, force_full: bool) -> EncodedBlit { - self.encode_inner(frame, force_full, false) + self.encode_inner(frame, force_full) } - #[cfg(any(windows, test))] - pub(crate) fn encode_with_deferred_cursor_reveal( - &self, - frame: &FrameData, - force_full: bool, - ) -> EncodedBlit { - self.encode_inner(frame, force_full, true) - } - - fn encode_inner( - &self, - frame: &FrameData, - force_full: bool, - defer_visible_cursor_during_content_changes: bool, - ) -> EncodedBlit { + fn encode_inner(&self, frame: &FrameData, force_full: bool) -> EncodedBlit { let prev = if force_full { None } else { @@ -92,19 +75,13 @@ impl BlitEncoder { let mut bytes = Vec::new(); let mut next_last_visible_cursor = self.last_visible_cursor; let mut next_last_cursor_shape = self.last_cursor_shape; - let suppress_visible_cursor = defer_visible_cursor_during_content_changes - && should_defer_visible_cursor(frame, prev, full); - #[cfg(any(windows, test))] - let deferred_cursor_reveal = suppress_visible_cursor - .then(|| encode_visible_cursor_reveal(frame)) - .flatten(); blit_frame_to_with_cursor_memory( &mut bytes, frame, prev, &mut next_last_visible_cursor, &mut next_last_cursor_shape, - suppress_visible_cursor, + false, ); if let Some(stats) = prof_stats { crate::render_prof::duration_since("ansi_encode.total", prof_started); @@ -121,8 +98,6 @@ impl BlitEncoder { EncodedBlit { bytes, full, - #[cfg(any(windows, test))] - deferred_cursor_reveal, next_last_visible_cursor, next_last_cursor_shape, } @@ -482,21 +457,6 @@ fn repeat_ime_anchor_after_sync() -> bool { true } -fn should_defer_visible_cursor(frame: &FrameData, prev: Option<&FrameData>, full: bool) -> bool { - let cursor_changed = prev.is_some_and(|prev| prev.cursor != frame.cursor); - frame_has_cell_changes(frame, prev, full) || cursor_changed -} - -fn frame_has_cell_changes(frame: &FrameData, prev: Option<&FrameData>, full: bool) -> bool { - if full { - return true; - } - let Some(prev) = prev else { - return true; - }; - compute_prof_blit_stats(frame, Some(prev), false).changed_cells > 0 -} - /// Writes all cells in the frame (full redraw). fn cell_width(cell: &CellData) -> usize { cell.symbol.width() @@ -593,22 +553,6 @@ fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorStat } } -#[cfg(any(windows, test))] -fn encode_visible_cursor_reveal(frame: &FrameData) -> Option<Vec<u8>> { - let cursor = frame.cursor.as_ref()?; - if !cursor.visible { - return None; - } - - let mut bytes = Vec::new(); - let position = clamp_cursor_position(frame, cursor.x, cursor.y); - write_cursor_position(&mut bytes, position); - let shape = normalize_cursor_shape(cursor.shape); - let _ = write!(bytes, "\x1b[{} q", shape); - let _ = bytes.write_all(b"\x1b[?25h"); - Some(bytes) -} - fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { let mut active_hyperlink = None; for row in 0..frame.height { @@ -1078,75 +1022,6 @@ mod tests { } #[test] - fn blit_encoder_can_defer_visible_cursor_reveal_for_content_changes() { - let frame = FrameData { - cells: vec![make_cell("A", 0, 0, 0); 9], - width: 3, - height: 3, - cursor: Some(CursorState { - x: 2, - y: 1, - visible: true, - shape: 6, - }), - hyperlinks: Vec::new(), - graphics: Vec::new(), - }; - let encoder = BlitEncoder::new(); - - let encoded = encoder.encode_with_deferred_cursor_reveal(&frame, false); - - let output_str = String::from_utf8(encoded.bytes).unwrap(); - assert!( - !output_str.contains("\x1b[?25h"), - "active repaint should keep the native cursor hidden" - ); - assert_eq!( - encoded.deferred_cursor_reveal.as_deref(), - Some(&b"\x1b[2;3H\x1b[6 q\x1b[?25h"[..]) - ); - } - - #[test] - fn blit_encoder_can_defer_visible_cursor_reveal_for_cursor_only_changes() { - let prev = FrameData { - cells: vec![make_cell("A", 0, 0, 0); 9], - width: 3, - height: 3, - cursor: Some(CursorState { - x: 0, - y: 0, - visible: true, - shape: 0, - }), - hyperlinks: Vec::new(), - graphics: Vec::new(), - }; - let mut curr = prev.clone(); - curr.cursor = Some(CursorState { - x: 2, - y: 1, - visible: true, - shape: 0, - }); - let mut encoder = BlitEncoder::new(); - let first = encoder.encode(&prev, false); - encoder.commit(prev, first); - - let encoded = encoder.encode_with_deferred_cursor_reveal(&curr, false); - - let output_str = String::from_utf8(encoded.bytes).unwrap(); - assert!( - !output_str.contains("\x1b[?25h"), - "cursor-only movement should remain hidden until the debounce fires" - ); - assert_eq!( - encoded.deferred_cursor_reveal.as_deref(), - Some(&b"\x1b[2;3H\x1b[0 q\x1b[?25h"[..]) - ); - } - - #[test] fn blit_frame_emits_cursor_shape_before_visibility_without_touching_ime_anchor() { let frame = FrameData { cells: vec![make_cell("A", 0, 0, 0)], |
