oxi_tui/pipeline/
cursor.rs1use 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 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 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 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 } else if let Some(new) = want {
59 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 #[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 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(); term.backend_mut().commands.clear();
218
219 cursor.reconcile(None, &mut term).unwrap(); 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}