aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent6e7ce87bab6a5d4f277613658d0f19afb114128b (diff)
fix: stabilize windows pane cursor rendering
refs #556
Diffstat (limited to 'src')
-rw-r--r--src/client/mod.rs58
-rw-r--r--src/pane.rs1
-rw-r--r--src/pane/cursor.rs315
-rw-r--r--src/pane/terminal.rs223
-rw-r--r--src/protocol/render_ansi.rs131
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)],