aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-23 20:39:01 +0200
committerGitHub <noreply@github.com>2026-09-23 20:39:01 +0200
commit21fd121a63db53ff44b947bbe38c47bbf35e632c (patch)
tree6e8ef9614afea9b86ac323f03c616f8fda6682f0 /src
parentc958833fa81333bf07a255f7d1325e6892daca1e (diff)
fix: prevent cursor flicker on status redraw (#4554)
* fix: prevent cursor flicker on status redraw * fix: avoid stale cursor during resize
Diffstat (limited to 'src')
-rw-r--r--src/pane/cursor.rs140
-rw-r--r--src/pane/terminal.rs30
2 files changed, 159 insertions, 11 deletions
diff --git a/src/pane/cursor.rs b/src/pane/cursor.rs
index a7886259..2ba94312 100644
--- a/src/pane/cursor.rs
+++ b/src/pane/cursor.rs
@@ -106,19 +106,20 @@ impl CursorPositionSettleState {
// when typing advanced its column. Otherwise the accumulated typing
// deadline can settle a later repair cell and anchor redraws there.
if let (Some(candidate), Some(since)) = (self.candidate, self.candidate_since) {
+ let expired = now.duration_since(since) >= self.candidate_hold();
let restored = self.settled.zip(current).is_some_and(|(settled, current)| {
settled.visible
+ && current.visible
+ && (candidate.visible || !expired)
&& (same_cursor_position(settled, current)
- || (candidate.y != settled.y
- && current.y == settled.y
- && now.duration_since(since) < self.candidate_hold()))
+ || (candidate.y != settled.y && current.y == settled.y && !expired))
});
if restored {
self.settle(current);
return;
}
// Preserve an eligible caret before a later redraw moves it away.
- if now.duration_since(since) >= self.candidate_hold() {
+ if expired {
self.settle(Some(candidate));
}
}
@@ -127,9 +128,29 @@ impl CursorPositionSettleState {
return;
};
if !current.visible {
+ // A PTY can briefly hide a stationary caret during a redraw.
+ // Keep the last visible cell until the existing max hold expires.
+ if self.candidate.is_some_and(|candidate| {
+ !candidate.visible && same_cursor_position(candidate, current)
+ }) {
+ return;
+ }
+ if self.candidate.is_none()
+ && self.settled.is_some_and(|settled| {
+ settled.visible && same_cursor_position(settled, current)
+ })
+ {
+ self.candidate = Some(current);
+ self.pending_since = Some(now);
+ self.candidate_since = Some(now);
+ return;
+ }
self.settle(Some(current));
return;
}
+ if self.candidate.is_some_and(|candidate| !candidate.visible) {
+ self.settle(self.settled);
+ }
let Some(settled) = self.settled else {
self.settle(Some(current));
return;
@@ -184,7 +205,9 @@ impl CursorPositionSettleState {
}
self.settled
.map(|settled| TerminalCursorState {
- visible: current.visible && settled.visible,
+ visible: settled.visible
+ && (current.visible
+ || (!candidate.visible && same_cursor_position(candidate, current))),
shape: current.shape,
..settled
})
@@ -204,7 +227,7 @@ impl CursorPositionSettleState {
}
fn candidate_hold(&self) -> Duration {
- if self.candidate_jump {
+ if self.candidate_jump || self.candidate.is_some_and(|candidate| !candidate.visible) {
CURSOR_POSITION_MAX_HOLD
} else {
CURSOR_POSITION_SETTLE
@@ -499,7 +522,7 @@ mod tests {
}
#[test]
- fn cursor_settle_hides_immediately_and_waits_to_reveal() {
+ fn cursor_settle_ignores_short_same_position_hides() {
let now = Instant::now();
let mut settle = CursorPositionSettleState::default();
settle.observe(Some(cursor(1, 0, true, 0)), now);
@@ -507,13 +530,108 @@ mod tests {
assert_eq!(
settle.reported_cursor(Some(cursor(1, 0, false, 0)), now + Duration::from_millis(2)),
- Some(cursor(1, 0, false, 0))
+ Some(cursor(1, 0, true, 0))
+ );
+ settle.observe(
+ Some(cursor(1, 0, false, 0)),
+ now + Duration::from_millis(50),
+ );
+ assert_eq!(
+ settle.reported_cursor(
+ Some(cursor(1, 0, false, 0)),
+ now + Duration::from_millis(90)
+ ),
+ Some(cursor(1, 0, true, 0))
+ );
+ assert!(
+ !settle
+ .reported_cursor(
+ Some(cursor(2, 0, false, 0)),
+ now + Duration::from_millis(90)
+ )
+ .unwrap()
+ .visible
+ );
+ settle.observe(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(91));
+ assert_eq!(
+ settle.reported_cursor(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(92)),
+ Some(cursor(1, 0, true, 0))
+ );
+ assert!(!settle.pending());
+ }
+
+ #[test]
+ fn cursor_settle_hides_after_deadline_and_waits_to_reveal() {
+ let now = Instant::now();
+ let mut settle = CursorPositionSettleState::default();
+ let visible = cursor(1, 0, true, 0);
+ let hidden = cursor(1, 0, false, 0);
+ settle.observe(Some(visible), now);
+ settle.observe(Some(hidden), now + Duration::from_millis(1));
+ assert_eq!(settle.render_delay(), Some(CURSOR_POSITION_MAX_HOLD));
+ assert_eq!(
+ settle.reported_cursor(Some(hidden), now + Duration::from_millis(100)),
+ Some(visible)
+ );
+ assert_eq!(
+ settle.reported_cursor(Some(hidden), now + Duration::from_millis(101)),
+ Some(hidden)
+ );
+
+ // The pure read above exposes expiry without changing the state.
+ settle.observe(Some(visible), now + Duration::from_millis(102));
+ assert_eq!(
+ settle.reported_cursor(Some(visible), now + Duration::from_millis(103)),
+ Some(hidden)
+ );
+ assert_eq!(
+ settle.reported_cursor(Some(visible), now + Duration::from_millis(122)),
+ Some(visible)
+ );
+ }
+
+ #[test]
+ fn cursor_settle_hides_immediately_outside_stationary_caret() {
+ let now = Instant::now();
+ let visible = cursor(1, 0, true, 0);
+ let hidden = cursor(2, 0, false, 0);
+ let mut settle = CursorPositionSettleState::default();
+ settle.observe(Some(visible), now);
+ settle.observe(Some(hidden), now + Duration::from_millis(1));
+ assert_eq!(
+ settle.reported_cursor(Some(hidden), now + Duration::from_millis(2)),
+ Some(hidden)
+ );
+
+ settle.observe(None, now + Duration::from_millis(3));
+ assert_eq!(
+ settle.reported_cursor(None, now + Duration::from_millis(4)),
+ None
);
- settle.observe(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(3));
+ for hide_at in [visible, cursor(2, 0, true, 0)] {
+ let mut settle = CursorPositionSettleState::default();
+ settle.observe(Some(visible), now);
+ settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1));
+ let hidden = TerminalCursorState {
+ visible: false,
+ ..hide_at
+ };
+ settle.observe(Some(hidden), now + Duration::from_millis(2));
+ assert_eq!(
+ settle.reported_cursor(Some(hidden), now + Duration::from_millis(3)),
+ Some(hidden)
+ );
+ }
+
+ // Once the pending move has expired, its destination is the caret to retain.
+ let mut settle = CursorPositionSettleState::default();
+ settle.observe(Some(visible), now);
+ settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1));
+ settle.observe(Some(hidden), now + Duration::from_millis(22));
assert_eq!(
- settle.reported_cursor(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(4)),
- Some(cursor(1, 0, false, 0))
+ settle.reported_cursor(Some(hidden), now + Duration::from_millis(23)),
+ Some(cursor(2, 0, true, 0))
);
}
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index e2005a8f..66c23c26 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -4416,6 +4416,36 @@ mod tests {
#[test]
#[cfg(windows)]
+ fn cursor_state_holds_brief_pty_hide_and_schedules_sustained_hide() {
+ 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 mut core = pane.core.lock().unwrap();
+ let current = current_cursor_state(&mut core);
+ core.cursor_settle_state = CursorPositionSettleState::default();
+ core.cursor_settle_state.observe(current, Instant::now());
+ }
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[?25l", &tx);
+ assert!(result.request_render);
+ assert_eq!(result.render_delay, Some(Duration::from_millis(100)));
+ assert!(pane.cursor_state().is_some_and(|cursor| cursor.visible));
+ let mut core = pane.core.lock().unwrap();
+ let current = current_cursor_state(&mut core);
+ assert!(
+ !core
+ .cursor_settle_state
+ .reported_cursor(current, Instant::now() + result.render_delay.unwrap())
+ .unwrap()
+ .visible
+ );
+ }
+
+ #[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();