Skip to main content

oxi_tui/pipeline/
cursor.rs

1//! Cursor state with dedup — the core of cursor blink preservation.
2//!
3//! `reconcile()` is called every frame with the desired cursor state.
4//! It emits cursor escape sequences to the terminal ONLY when something
5//! actually changed:
6//! - Visibility transition (Hide↔Show): emit `Hide`/`Show`
7//! - Position change while visible: emit `MoveTo`
8//! - Same position while visible: **emit nothing** ← this is the blink fix
9//!
10//! Contrast with ratatui's `apply_buffer_with_cursor` (render.rs:288-320)
11//! which emits unconditionally based on whether `set_cursor_position` was
12//! called in the render callback, regardless of whether anything moved.
13
14use ratatui::Terminal;
15use ratatui::backend::Backend;
16use ratatui::layout::Position;
17
18#[derive(Debug, Clone, Default)]
19pub struct CursorState {
20    last_pos: Option<Position>,
21    visible: bool,
22}
23
24impl CursorState {
25    #[must_use]
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Apply this frame's cursor request to the terminal.
31    /// Emits zero bytes if nothing changed (same visibility AND same position).
32    pub fn reconcile<B: Backend>(
33        &mut self,
34        want: Option<Position>,
35        term: &mut Terminal<B>,
36    ) -> Result<(), B::Error> {
37        let new_visible = want.is_some();
38
39        // Visibility transition (rare): emit Show or Hide
40        if new_visible != self.visible {
41            if new_visible {
42                term.show_cursor()?;
43                self.visible = true;
44            } else {
45                term.hide_cursor()?;
46                self.visible = false;
47                self.last_pos = None;
48            }
49        }
50
51        // Position change while visible: emit MoveTo. Same position → 0 bytes.
52        if let (Some(new), Some(prev)) = (want, self.last_pos) {
53            if new != prev {
54                term.set_cursor_position(new)?;
55                self.last_pos = Some(new);
56            }
57            // ★ new == prev: 0 bytes — blink timer preserved (core optimization)
58        } else if let Some(new) = want {
59            // Visibility just transitioned to Show — set initial position
60            term.set_cursor_position(new)?;
61            self.last_pos = Some(new);
62        }
63
64        Ok(())
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use ratatui::Terminal;
72
73    /// `TestBackend` records cursor commands? Actually `TestBackend` doesn't record
74    /// byte-level output. We test via observable state transitions on `CursorState`
75    /// and a custom recording backend for byte-level tests.
76
77    #[derive(Default)]
78    struct RecordingBackend {
79        commands: Vec<String>,
80        size: ratatui::layout::Size,
81    }
82
83    impl Backend for RecordingBackend {
84        type Error = std::convert::Infallible;
85
86        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
87        where
88            I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
89        {
90            Ok(())
91        }
92
93        fn hide_cursor(&mut self) -> Result<(), Self::Error> {
94            self.commands.push("hide".into());
95            Ok(())
96        }
97
98        fn show_cursor(&mut self) -> Result<(), Self::Error> {
99            self.commands.push("show".into());
100            Ok(())
101        }
102
103        fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
104            Ok(Position { x: 0, y: 0 })
105        }
106
107        fn set_cursor_position<P: Into<ratatui::layout::Position>>(
108            &mut self,
109            position: P,
110        ) -> Result<(), Self::Error> {
111            let p: Position = position.into();
112            self.commands.push(format!("moveto({},{})", p.x, p.y));
113            Ok(())
114        }
115
116        fn clear(&mut self) -> Result<(), Self::Error> {
117            Ok(())
118        }
119
120        fn clear_region(
121            &mut self,
122            _clear_type: ratatui::backend::ClearType,
123        ) -> Result<(), Self::Error> {
124            Ok(())
125        }
126
127        fn size(&self) -> Result<ratatui::layout::Size, Self::Error> {
128            Ok(self.size)
129        }
130
131        fn window_size(&mut self) -> Result<ratatui::backend::WindowSize, Self::Error> {
132            Ok(ratatui::backend::WindowSize {
133                columns_rows: ratatui::layout::Size {
134                    width: self.size.width,
135                    height: self.size.height,
136                },
137                pixels: ratatui::layout::Size {
138                    width: 0,
139                    height: 0,
140                },
141            })
142        }
143
144        fn flush(&mut self) -> Result<(), Self::Error> {
145            Ok(())
146        }
147    }
148
149    fn make_terminal() -> Terminal<RecordingBackend> {
150        Terminal::new(RecordingBackend {
151            commands: Vec::new(),
152            size: ratatui::layout::Size {
153                width: 80,
154                height: 24,
155            },
156        })
157        .unwrap()
158    }
159
160    const P1: Position = Position { x: 5, y: 10 };
161    const P1_AGAIN: Position = Position { x: 5, y: 10 };
162    const P2: Position = Position { x: 7, y: 12 };
163
164    #[test]
165    fn first_show_emits_show_and_moveto() {
166        let mut term = make_terminal();
167        let mut cursor = CursorState::new();
168        cursor.reconcile(Some(P1), &mut term).unwrap();
169        assert_eq!(
170            term.backend().commands,
171            vec!["show".to_string(), "moveto(5,10)".to_string()]
172        );
173    }
174
175    #[test]
176    fn same_position_second_frame_emits_zero_bytes() {
177        // ★ THE blink-preservation test
178        let mut term = make_terminal();
179        let mut cursor = CursorState::new();
180        cursor.reconcile(Some(P1), &mut term).unwrap();
181        term.backend_mut().commands.clear();
182
183        cursor.reconcile(Some(P1_AGAIN), &mut term).unwrap();
184        assert!(
185            term.backend().commands.is_empty(),
186            "second frame at same position must emit zero cursor bytes"
187        );
188    }
189
190    #[test]
191    fn position_change_emits_only_moveto() {
192        let mut term = make_terminal();
193        let mut cursor = CursorState::new();
194        cursor.reconcile(Some(P1), &mut term).unwrap();
195        term.backend_mut().commands.clear();
196
197        cursor.reconcile(Some(P2), &mut term).unwrap();
198        assert_eq!(term.backend().commands, vec!["moveto(7,12)".to_string()]);
199    }
200
201    #[test]
202    fn hide_emits_hide() {
203        let mut term = make_terminal();
204        let mut cursor = CursorState::new();
205        cursor.reconcile(Some(P1), &mut term).unwrap();
206        term.backend_mut().commands.clear();
207
208        cursor.reconcile(None, &mut term).unwrap();
209        assert_eq!(term.backend().commands, vec!["hide".to_string()]);
210    }
211
212    #[test]
213    fn hide_then_hide_again_emits_zero_bytes() {
214        let mut term = make_terminal();
215        let mut cursor = CursorState::new();
216        cursor.reconcile(None, &mut term).unwrap(); // first hide
217        term.backend_mut().commands.clear();
218
219        cursor.reconcile(None, &mut term).unwrap(); // second hide — should be no-op
220        assert!(term.backend().commands.is_empty());
221    }
222
223    #[test]
224    fn show_after_hide_emits_show_and_moveto() {
225        let mut term = make_terminal();
226        let mut cursor = CursorState::new();
227        cursor.reconcile(Some(P1), &mut term).unwrap();
228        cursor.reconcile(None, &mut term).unwrap();
229        term.backend_mut().commands.clear();
230
231        cursor.reconcile(Some(P2), &mut term).unwrap();
232        assert_eq!(
233            term.backend().commands,
234            vec!["show".to_string(), "moveto(7,12)".to_string()]
235        );
236    }
237}