fix: stabilize windows cursor redraws and settle deadlines (#4389)

* fix: prevent cursor jumps during synchronized redraws

refs #4303

* fix: settle cursor positions across delayed windows redraws

refs #4303

* fix(windows): hold jump-shaped cursor moves through the max window

* fix: bound windows cursor settling and repaint at its deadline

refs #4303

---------

Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Co-authored-by: JJLiebig <jj@liebig.gg>
Co-authored-by: Axel Karlsson <axelalbertkarlsson@outlook.com>
This commit is contained in:
JJ Liebig
2026-09-19 20:21:46 +02:00
committed by GitHub
co-authored by Ogulcan Celik JJLiebig Axel Karlsson
parent e0507237ad
commit 856b64b9bf
2 changed files with 321 additions and 43 deletions
+201 -24
View File
@@ -90,55 +90,59 @@ pub(crate) struct CursorPositionSettleState {
settled: Option<TerminalCursorState>,
candidate: Option<TerminalCursorState>,
pending_since: Option<Instant>,
candidate_since: Option<Instant>,
/// True when this candidate jumped away from the settled caret (a different
/// row, or a large same-row column move).
///
/// Those are the shape of a redraw parking the cursor on a temporary cell,
/// so they are held for the max window before being shown. Ordinary caret
/// steps are small and same-row, and settle on the normal window.
candidate_jump: bool,
}
impl CursorPositionSettleState {
pub(crate) fn observe(&mut self, current: Option<TerminalCursorState>, now: Instant) {
// A candidate that stayed quiet for its hold window is real, so preserve
// it before considering the first (possibly temporary) position of a
// later redraw.
if let (Some(candidate), Some(since)) = (self.candidate, self.candidate_since) {
if now.duration_since(since) >= self.candidate_hold() {
self.settle(Some(candidate));
}
}
let Some(current) = current else {
self.settled = None;
self.candidate = None;
self.pending_since = None;
self.settle(None);
return;
};
if !current.visible {
self.settled = Some(current);
self.candidate = None;
self.pending_since = None;
self.settle(Some(current));
return;
}
let Some(settled) = self.settled else {
self.settled = Some(current);
self.candidate = None;
self.pending_since = None;
self.settle(Some(current));
return;
};
if same_cursor_position(settled, current) && settled.visible {
self.settled = Some(current);
self.candidate = None;
self.pending_since = None;
self.settle(Some(current));
return;
}
let Some(candidate) = self.candidate else {
self.candidate = Some(current);
self.pending_since = Some(now);
self.candidate_since = Some(now);
self.candidate_jump = is_jump(settled, current);
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);
}
self.settle(Some(current));
} else {
if !same_cursor_position(candidate, current) {
self.candidate_since = Some(now);
self.candidate_jump = is_jump(settled, current);
}
self.candidate = Some(current);
}
}
@@ -152,8 +156,14 @@ impl CursorPositionSettleState {
let Some(candidate) = self.candidate else {
return Some(current);
};
let candidate_since = self.candidate_since.unwrap_or(now);
let pending_since = self.pending_since.unwrap_or(now);
if now.duration_since(pending_since) >= CURSOR_POSITION_SETTLE {
// A jump-shaped candidate is treated as a redraw park until proven
// otherwise, so it waits for the max window. An ordinary caret step only
// waits the normal settle window; the max hold bounds either case.
if now.duration_since(candidate_since) >= self.candidate_hold()
|| now.duration_since(pending_since) >= CURSOR_POSITION_MAX_HOLD
{
return Some(TerminalCursorState {
visible: current.visible && candidate.visible,
shape: current.shape,
@@ -176,12 +186,37 @@ impl CursorPositionSettleState {
pub(crate) fn pending(&self) -> bool {
self.candidate.is_some()
}
pub(crate) fn render_delay(&self) -> Option<Duration> {
self.pending().then(|| self.candidate_hold())
}
fn candidate_hold(&self) -> Duration {
if self.candidate_jump {
CURSOR_POSITION_MAX_HOLD
} else {
CURSOR_POSITION_SETTLE
}
}
fn settle(&mut self, cursor: Option<TerminalCursorState>) {
*self = Self {
settled: cursor,
..Self::default()
};
}
}
fn same_cursor_position(left: TerminalCursorState, right: TerminalCursorState) -> bool {
left.x == right.x && left.y == right.y
}
/// A cursor move that changes row, or jumps more than a couple of columns, is
/// the shape of a redraw parking the cursor rather than an ordinary caret step.
fn is_jump(settled: TerminalCursorState, current: TerminalCursorState) -> bool {
current.y != settled.y || current.x.abs_diff(settled.x) > 2
}
#[cfg(test)]
mod tests {
use super::*;
@@ -226,12 +261,61 @@ mod tests {
assert_eq!((reported.x, reported.y), (2, 0));
}
#[test]
fn cursor_settle_keeps_previous_caret_during_next_system_conpty_redraw() {
let now = Instant::now();
let mut settle = CursorPositionSettleState::default();
let caret = cursor(2, 12, true, 0);
let typed_caret = cursor(3, 12, true, 0);
let repair = cursor(0, 10, true, 0);
settle.observe(Some(caret), now);
settle.observe(Some(typed_caret), now + Duration::from_millis(1));
// System ConPTY closes the next frame at the repair cell, then emits
// the caret restoration separately about 10 ms later.
settle.observe(Some(repair), now + Duration::from_millis(160));
assert_eq!(
settle.reported_cursor(Some(repair), now + Duration::from_millis(161)),
Some(typed_caret)
);
settle.observe(Some(typed_caret), now + Duration::from_millis(170));
assert_eq!(
settle.reported_cursor(Some(typed_caret), now + Duration::from_millis(171)),
Some(typed_caret)
);
}
#[test]
fn cursor_settle_restarts_quiet_window_when_candidate_moves() {
let now = Instant::now();
let mut settle = CursorPositionSettleState::default();
let initial = cursor(1, 0, true, 0);
let latest = cursor(3, 0, true, 0);
settle.observe(Some(initial), now);
settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1));
settle.observe(Some(latest), now + Duration::from_millis(19));
assert_eq!(
settle.reported_cursor(Some(latest), now + Duration::from_millis(22)),
Some(initial)
);
assert_eq!(
settle.reported_cursor(Some(latest), now + Duration::from_millis(39)),
Some(latest)
);
}
#[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));
for ms in (11..=91).step_by(10) {
settle.observe(
Some(cursor(ms as u16, 0, true, 0)),
now + Duration::from_millis(ms),
);
}
settle.observe(
Some(cursor(3, 0, true, 0)),
now + CURSOR_POSITION_MAX_HOLD + Duration::from_millis(1),
@@ -247,6 +331,57 @@ mod tests {
);
}
#[test]
fn cursor_settle_caps_hold_even_without_another_observation() {
let now = Instant::now();
for step in [10, 30] {
let mut settle = CursorPositionSettleState::default();
settle.observe(Some(cursor(0, 0, true, 0)), now);
for ms in (1..=91).step_by(step) {
settle.observe(
Some(cursor(ms as u16, 1, true, 0)),
now + Duration::from_millis(ms),
);
}
assert_eq!(
settle.reported_cursor(
Some(cursor(91, 1, true, 0)),
now + Duration::from_millis(101)
),
Some(cursor(91, 1, true, 0))
);
}
}
#[test]
fn cursor_settle_repeated_position_does_not_restart_quiet_window() {
let now = Instant::now();
let mut settle = CursorPositionSettleState::default();
let next = cursor(2, 0, true, 0);
settle.observe(Some(cursor(1, 0, true, 0)), now);
settle.observe(Some(next), now + Duration::from_millis(1));
settle.observe(Some(next), now + Duration::from_millis(19));
assert_eq!(
settle.reported_cursor(Some(next), now + Duration::from_millis(21)),
Some(next)
);
}
#[test]
fn cursor_settle_repeated_jump_position_does_not_starve() {
let now = Instant::now();
let mut settle = CursorPositionSettleState::default();
let next = cursor(2, 1, true, 0);
settle.observe(Some(cursor(2, 0, true, 0)), now);
for ms in [1, 31, 61, 91] {
settle.observe(Some(next), now + Duration::from_millis(ms));
}
assert_eq!(
settle.reported_cursor(Some(next), now + Duration::from_millis(101)),
Some(next)
);
}
#[test]
fn cursor_settle_keeps_render_read_pure() {
let now = Instant::now();
@@ -312,4 +447,46 @@ mod tests {
Some(cursor(1, 0, false, 0))
);
}
#[test]
fn cursor_settle_does_not_publish_a_jump_before_the_max_hold() {
let now = Instant::now();
let mut settle = CursorPositionSettleState::default();
let caret = cursor(2, 26, true, 0);
let park = cursor(0, 25, true, 0);
settle.observe(Some(caret), now);
// A transition redraw parks the cursor on a cell above the composer and
// restores the caret in a later write, more than one settle window away.
// The park must not be published in the meantime.
settle.observe(Some(park), now + Duration::from_millis(1));
assert_eq!(
settle.reported_cursor(
Some(park),
now + CURSOR_POSITION_SETTLE + Duration::from_millis(1)
),
Some(caret)
);
assert_eq!(
settle.reported_cursor(Some(park), now + Duration::from_millis(70)),
Some(caret)
);
// An ordinary same-row caret step still settles on the normal window.
settle.observe(
Some(cursor(3, 26, true, 0)),
now + Duration::from_millis(80),
);
settle.observe(
Some(cursor(4, 26, true, 0)),
now + Duration::from_millis(81),
);
assert_eq!(
settle.reported_cursor(
Some(cursor(4, 26, true, 0)),
now + CURSOR_POSITION_SETTLE + Duration::from_millis(82)
),
Some(cursor(4, 26, true, 0))
);
}
}
+120 -19
View File
@@ -21,7 +21,9 @@ mod migration_tests;
#[cfg(windows)]
mod windows_recent_fallback;
use super::cursor::{CursorPositionSettleState, DecscusrTracker, CURSOR_POSITION_SETTLE};
#[cfg(test)]
use super::cursor::CURSOR_POSITION_SETTLE;
use super::cursor::{CursorPositionSettleState, DecscusrTracker};
use super::{
input::{
ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal,
@@ -1445,7 +1447,8 @@ impl GhosttyPaneTerminal {
.terminal
.mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT)
.unwrap_or(false);
if CURSOR_POSITION_SETTLE_ENABLED {
// Intermediate synchronized-frame positions must not become settled cursors.
if CURSOR_POSITION_SETTLE_ENABLED && !synchronized_output {
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);
@@ -1463,7 +1466,7 @@ impl GhosttyPaneTerminal {
let render_delay = render_delay_after_pty_write(
synchronized_output,
has_kitty_graphics_sequence,
cursor_position_settle_pending(&core),
core.cursor_settle_state.render_delay(),
CURSOR_POSITION_SETTLE_ENABLED,
);
if request_render {
@@ -2476,10 +2479,6 @@ fn encoded_key_preserves_event_kind(
})
}
fn cursor_position_settle_pending(core: &GhosttyPaneCore) -> bool {
core.cursor_settle_state.pending()
}
fn effective_cursor_state(
core: &mut GhosttyPaneCore,
current: Option<TerminalCursorState>,
@@ -2494,17 +2493,16 @@ fn effective_cursor_state(
fn render_delay_after_pty_write(
synchronized_output: bool,
has_kitty_graphics_sequence: bool,
cursor_position_settle_pending: bool,
cursor_position_settle_delay: Option<Duration>,
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
let cursor_delay = cursor_position_settle_enabled
.then_some(cursor_position_settle_delay)
.flatten();
cursor_delay.max(has_kitty_graphics_sequence.then_some(KITTY_GRAPHICS_REDRAW_SETTLE))
}
}
@@ -4374,12 +4372,109 @@ mod tests {
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!(result.render_delay, Some(Duration::from_millis(100)));
assert_eq!(
pane.cursor_state()
.map(|cursor| (cursor.x, cursor.y, cursor.visible)),
Some((1, 0, true))
);
// If output stops here, the scheduled repaint must be late enough to
// publish this cursor without relying on an unrelated later redraw.
let mut core = pane.core.lock().unwrap();
let current = current_cursor_state(&mut core);
assert_eq!(
core.cursor_settle_state
.reported_cursor(current, Instant::now() + result.render_delay.unwrap()),
current
);
}
#[test]
#[cfg(windows)]
fn cursor_settle_ignores_intermediate_synchronized_frame_positions() {
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[15;4H", &tx);
let previous = TerminalCursorState {
x: 3,
y: 14,
visible: true,
shape: 0,
};
{
let mut core = pane.core.lock().unwrap();
let now = Instant::now();
core.cursor_settle_state = CursorPositionSettleState::default();
core.cursor_settle_state.observe(
Some(TerminalCursorState { x: 2, ..previous }),
now - Duration::from_millis(300),
);
// Seed a pending hold whose deadline has passed, without wall-clock sleeps.
core.cursor_settle_state
.observe(Some(previous), now - Duration::from_millis(200));
}
for bytes in [
b"\x1b[?2026h\x1b[15;4Hx\x1b[13;1H".as_slice(),
b"\x1b[0 q\x1b[13;1H \x1b[15;5H",
b"\x1b[?25h",
] {
let result = pane.process_pty_bytes(pane_id, 0, bytes, &tx);
assert!(!result.request_render);
assert_eq!(result.render_delay, None);
assert_eq!(pane.cursor_state(), Some(previous));
assert!(pane.core.lock().unwrap().cursor_settle_state.pending());
}
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[?2026l", &tx);
assert!(result.request_render);
assert_eq!(result.render_delay, Some(CURSOR_POSITION_SETTLE));
assert!(pane.core.lock().unwrap().cursor_settle_state.pending());
assert_eq!(pane.cursor_state(), Some(previous));
// ConPTY may restore the real caret after the synchronized frame closes.
let mut core = pane.core.lock().unwrap();
let current = current_cursor_state(&mut core);
assert_eq!(
core.cursor_settle_state
.reported_cursor(current, Instant::now() + CURSOR_POSITION_SETTLE),
Some(TerminalCursorState { x: 4, ..previous })
);
}
#[test]
#[cfg(windows)]
fn cursor_settle_preserves_final_visibility_and_shape_across_split_sync_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[15;4H", &tx);
for bytes in [
b"\x1b[?202".as_slice(),
b"6h\x1b[13;1H",
b"\x1b[6 q\x1b[15;5H\x1b[?25l\x1b[?20",
] {
pane.process_pty_bytes(pane_id, 0, bytes, &tx);
assert!(!pane.core.lock().unwrap().cursor_settle_state.pending());
}
let result = pane.process_pty_bytes(pane_id, 0, b"26l", &tx);
assert!(result.request_render);
assert_eq!(result.render_delay, None);
assert_eq!(
pane.cursor_state(),
Some(TerminalCursorState {
x: 4,
y: 14,
visible: false,
shape: 6,
})
);
}
#[test]
@@ -4403,19 +4498,25 @@ mod tests {
#[test]
fn cursor_settle_policy_controls_render_delay() {
let delay = Some(CURSOR_POSITION_SETTLE);
assert_eq!(
render_delay_after_pty_write(false, false, true, true),
Some(CURSOR_POSITION_SETTLE)
render_delay_after_pty_write(false, false, delay, true),
delay
);
assert_eq!(
render_delay_after_pty_write(false, false, true, false),
render_delay_after_pty_write(false, false, delay, false),
None
);
assert_eq!(
render_delay_after_pty_write(false, true, true, false),
render_delay_after_pty_write(false, true, delay, false),
Some(KITTY_GRAPHICS_REDRAW_SETTLE)
);
assert_eq!(render_delay_after_pty_write(true, false, true, true), None);
assert_eq!(render_delay_after_pty_write(true, false, delay, true), None);
let jump_delay = Some(Duration::from_millis(100));
assert_eq!(
render_delay_after_pty_write(false, true, jump_delay, true),
jump_delay
);
}
#[test]