aboutsummaryrefslogtreecommitdiff
path: root/src/pane/terminal.rs
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/terminal.rs
parent6e7ce87bab6a5d4f277613658d0f19afb114128b (diff)
fix: stabilize windows pane cursor rendering
refs #556
Diffstat (limited to 'src/pane/terminal.rs')
-rw-r--r--src/pane/terminal.rs223
1 files changed, 199 insertions, 24 deletions
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();