diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-23 20:39:01 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-23 20:39:01 +0200 |
| commit | 21fd121a63db53ff44b947bbe38c47bbf35e632c (patch) | |
| tree | 6e8ef9614afea9b86ac323f03c616f8fda6682f0 /src | |
| parent | c958833fa81333bf07a255f7d1325e6892daca1e (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.rs | 140 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 30 |
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(); |
