ftui_core/cursor.rs
1#![forbid(unsafe_code)]
2
3//! Cursor save/restore strategy for inline mode robustness.
4//!
5//! This module implements a layered cursor save/restore strategy to handle
6//! the variety of terminal behaviors. Inline mode requires saving cursor
7//! position before drawing UI and restoring after.
8//!
9//! # Strategy Layers
10//!
11//! 1. **DEC (preferred)**: `ESC 7` / `ESC 8` (DECSC/DECRC)
12//! - Most widely supported on modern terminals
13//! - Saves cursor position, attributes, and charset
14//! - Works in tmux/screen with passthrough
15//!
16//! 2. **ANSI (fallback)**: `CSI s` / `CSI u`
17//! - Alternative when DEC has issues
18//! - Only saves cursor position (not attributes)
19//! - May conflict with some terminal modes
20//!
21//! 3. **Emulated (last resort)**: Track position and use `CSI row;col H`
22//! - Works everywhere that supports CUP
23//! - Requires tracking cursor position throughout
24//! - More overhead but guaranteed to work
25//!
26//! # Example
27//!
28//! ```
29//! use ftui_core::cursor::{CursorManager, CursorSaveStrategy};
30//! use ftui_core::terminal_capabilities::TerminalCapabilities;
31//!
32//! let caps = TerminalCapabilities::detect();
33//! let mut cursor = CursorManager::new(CursorSaveStrategy::detect(&caps));
34//!
35//! // In your render loop:
36//! let mut output = Vec::new();
37//! cursor.save(&mut output, (10, 5))?; // Save at column 10, row 5
38//! // ... draw UI ...
39//! cursor.restore(&mut output)?;
40//! # Ok::<(), std::io::Error>(())
41//! ```
42
43use std::io::{self, Write};
44
45use crate::terminal_capabilities::TerminalCapabilities;
46
47/// DEC cursor save (DECSC): `ESC 7`
48///
49/// Saves cursor position, character attributes, character set, and origin mode.
50const DEC_SAVE: &[u8] = b"\x1b7";
51
52/// DEC cursor restore (DECRC): `ESC 8`
53///
54/// Restores cursor position and attributes saved by DECSC.
55const DEC_RESTORE: &[u8] = b"\x1b8";
56
57/// ANSI cursor save: `CSI s`
58///
59/// Saves cursor position only (not attributes).
60const ANSI_SAVE: &[u8] = b"\x1b[s";
61
62/// ANSI cursor restore: `CSI u`
63///
64/// Restores cursor position saved by `CSI s`.
65const ANSI_RESTORE: &[u8] = b"\x1b[u";
66
67/// Strategy for cursor save/restore operations.
68///
69/// Different terminals support different cursor save/restore mechanisms.
70/// This enum allows selecting the appropriate strategy based on terminal
71/// capabilities.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum CursorSaveStrategy {
74 /// DEC save/restore (`ESC 7` / `ESC 8`).
75 ///
76 /// The preferred strategy for most terminals. Saves cursor position,
77 /// attributes, and character set.
78 #[default]
79 Dec,
80
81 /// ANSI save/restore (`CSI s` / `CSI u`).
82 ///
83 /// Fallback for terminals where DEC sequences have issues.
84 /// Only saves cursor position, not attributes.
85 Ansi,
86
87 /// Emulated save/restore using position tracking and CUP.
88 ///
89 /// Last resort that works on any terminal supporting cursor positioning.
90 /// Requires the caller to provide current position when saving.
91 Emulated,
92}
93
94impl CursorSaveStrategy {
95 /// Detect the best strategy for the current environment.
96 ///
97 /// Uses terminal capabilities to choose the most reliable strategy.
98 #[must_use]
99 pub fn detect(caps: &TerminalCapabilities) -> Self {
100 // GNU screen has quirks with DEC save/restore in some configurations
101 if caps.in_screen {
102 return Self::Ansi;
103 }
104
105 // Most modern terminals support DEC sequences well
106 // tmux, zellij, and direct terminal all work with DEC
107 Self::Dec
108 }
109
110 /// Get the save escape sequence for this strategy.
111 ///
112 /// Returns `None` for `Emulated` strategy (no escape sequence needed).
113 #[must_use]
114 pub const fn save_sequence(&self) -> Option<&'static [u8]> {
115 match self {
116 Self::Dec => Some(DEC_SAVE),
117 Self::Ansi => Some(ANSI_SAVE),
118 Self::Emulated => None,
119 }
120 }
121
122 /// Get the restore escape sequence for this strategy.
123 ///
124 /// Returns `None` for `Emulated` strategy (uses CUP instead).
125 #[must_use]
126 pub const fn restore_sequence(&self) -> Option<&'static [u8]> {
127 match self {
128 Self::Dec => Some(DEC_RESTORE),
129 Self::Ansi => Some(ANSI_RESTORE),
130 Self::Emulated => None,
131 }
132 }
133}
134
135/// Manages cursor save/restore operations.
136///
137/// This struct handles the complexity of cursor save/restore across different
138/// strategies. It tracks the saved position for emulated mode and provides
139/// a unified interface regardless of the underlying mechanism.
140///
141/// # Contract
142///
143/// - `save()` must be called before `restore()`
144/// - Calling `restore()` without a prior `save()` is safe but may have no effect
145/// - Multiple `save()` calls overwrite the previous save (no nesting)
146#[derive(Debug, Clone)]
147pub struct CursorManager {
148 strategy: CursorSaveStrategy,
149 /// Saved cursor position for emulated mode: (column, row), 0-indexed.
150 saved_position: Option<(u16, u16)>,
151}
152
153impl CursorManager {
154 /// Create a new cursor manager with the specified strategy.
155 #[must_use]
156 pub const fn new(strategy: CursorSaveStrategy) -> Self {
157 Self {
158 strategy,
159 saved_position: None,
160 }
161 }
162
163 /// Create a cursor manager with auto-detected strategy.
164 #[must_use]
165 pub fn detect(caps: &TerminalCapabilities) -> Self {
166 Self::new(CursorSaveStrategy::detect(caps))
167 }
168
169 /// Get the current strategy.
170 #[must_use]
171 pub const fn strategy(&self) -> CursorSaveStrategy {
172 self.strategy
173 }
174
175 /// Save the cursor position.
176 ///
177 /// # Arguments
178 ///
179 /// * `writer` - The output writer (typically stdout)
180 /// * `current_pos` - Current cursor position (column, row), 0-indexed.
181 /// Required for emulated mode, ignored for DEC/ANSI modes.
182 ///
183 /// # Errors
184 ///
185 /// Returns an error if writing to the output fails.
186 pub fn save<W: Write>(&mut self, writer: &mut W, current_pos: (u16, u16)) -> io::Result<()> {
187 match self.strategy {
188 CursorSaveStrategy::Dec => writer.write_all(DEC_SAVE),
189 CursorSaveStrategy::Ansi => writer.write_all(ANSI_SAVE),
190 CursorSaveStrategy::Emulated => {
191 self.saved_position = Some(current_pos);
192 Ok(())
193 }
194 }
195 }
196
197 /// Restore the cursor position.
198 ///
199 /// # Errors
200 ///
201 /// Returns an error if writing to the output fails.
202 /// For emulated mode, does nothing if no position was saved.
203 pub fn restore<W: Write>(&self, writer: &mut W) -> io::Result<()> {
204 match self.strategy {
205 CursorSaveStrategy::Dec => writer.write_all(DEC_RESTORE),
206 CursorSaveStrategy::Ansi => writer.write_all(ANSI_RESTORE),
207 CursorSaveStrategy::Emulated => {
208 if let Some((col, row)) = self.saved_position {
209 // CUP uses 1-indexed coordinates
210 write!(writer, "\x1b[{};{}H", row + 1, col + 1)
211 } else {
212 Ok(())
213 }
214 }
215 }
216 }
217
218 /// Clear the saved position (for emulated mode).
219 ///
220 /// This has no effect on DEC/ANSI modes.
221 pub fn clear(&mut self) {
222 self.saved_position = None;
223 }
224
225 /// Get the saved position (for emulated mode).
226 ///
227 /// Returns `None` for DEC/ANSI modes or if no position was saved.
228 #[must_use]
229 pub const fn saved_position(&self) -> Option<(u16, u16)> {
230 self.saved_position
231 }
232}
233
234impl Default for CursorManager {
235 fn default() -> Self {
236 Self::new(CursorSaveStrategy::default())
237 }
238}
239
240/// Move cursor to a specific position.
241///
242/// Writes a CUP (Cursor Position) sequence to move the cursor.
243///
244/// # Arguments
245///
246/// * `writer` - The output writer
247/// * `col` - Column (0-indexed)
248/// * `row` - Row (0-indexed)
249///
250/// # Errors
251///
252/// Returns an error if writing to the output fails.
253pub fn move_to<W: Write>(writer: &mut W, col: u16, row: u16) -> io::Result<()> {
254 // CUP uses 1-indexed coordinates
255 write!(writer, "\x1b[{};{}H", row + 1, col + 1)
256}
257
258/// Hide the cursor.
259///
260/// Writes `CSI ? 25 l` to hide the cursor.
261pub fn hide<W: Write>(writer: &mut W) -> io::Result<()> {
262 writer.write_all(b"\x1b[?25l")
263}
264
265/// Show the cursor.
266///
267/// Writes `CSI ? 25 h` to show the cursor.
268pub fn show<W: Write>(writer: &mut W) -> io::Result<()> {
269 writer.write_all(b"\x1b[?25h")
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn dec_save_restore_sequences() {
278 let strategy = CursorSaveStrategy::Dec;
279 assert_eq!(strategy.save_sequence(), Some(b"\x1b7".as_slice()));
280 assert_eq!(strategy.restore_sequence(), Some(b"\x1b8".as_slice()));
281 }
282
283 #[test]
284 fn ansi_save_restore_sequences() {
285 let strategy = CursorSaveStrategy::Ansi;
286 assert_eq!(strategy.save_sequence(), Some(b"\x1b[s".as_slice()));
287 assert_eq!(strategy.restore_sequence(), Some(b"\x1b[u".as_slice()));
288 }
289
290 #[test]
291 fn emulated_has_no_sequences() {
292 let strategy = CursorSaveStrategy::Emulated;
293 assert_eq!(strategy.save_sequence(), None);
294 assert_eq!(strategy.restore_sequence(), None);
295 }
296
297 #[test]
298 fn detect_uses_dec_for_normal_terminal() {
299 let caps = TerminalCapabilities::basic();
300 let strategy = CursorSaveStrategy::detect(&caps);
301 assert_eq!(strategy, CursorSaveStrategy::Dec);
302 }
303
304 #[test]
305 fn detect_uses_ansi_for_screen() {
306 let mut caps = TerminalCapabilities::basic();
307 caps.in_screen = true;
308 let strategy = CursorSaveStrategy::detect(&caps);
309 assert_eq!(strategy, CursorSaveStrategy::Ansi);
310 }
311
312 #[test]
313 fn detect_uses_dec_for_tmux() {
314 let mut caps = TerminalCapabilities::basic();
315 caps.in_tmux = true;
316 let strategy = CursorSaveStrategy::detect(&caps);
317 assert_eq!(strategy, CursorSaveStrategy::Dec);
318 }
319
320 #[test]
321 fn cursor_manager_dec_save() {
322 let mut manager = CursorManager::new(CursorSaveStrategy::Dec);
323 let mut output = Vec::new();
324
325 manager.save(&mut output, (10, 5)).unwrap();
326 assert_eq!(output, b"\x1b7");
327 }
328
329 #[test]
330 fn cursor_manager_dec_restore() {
331 let manager = CursorManager::new(CursorSaveStrategy::Dec);
332 let mut output = Vec::new();
333
334 manager.restore(&mut output).unwrap();
335 assert_eq!(output, b"\x1b8");
336 }
337
338 #[test]
339 fn cursor_manager_ansi_save_restore() {
340 let mut manager = CursorManager::new(CursorSaveStrategy::Ansi);
341 let mut output = Vec::new();
342
343 manager.save(&mut output, (0, 0)).unwrap();
344 assert_eq!(output, b"\x1b[s");
345
346 output.clear();
347 manager.restore(&mut output).unwrap();
348 assert_eq!(output, b"\x1b[u");
349 }
350
351 #[test]
352 fn cursor_manager_emulated_save_restore() {
353 let mut manager = CursorManager::new(CursorSaveStrategy::Emulated);
354 let mut output = Vec::new();
355
356 // Save at column 10, row 5 (0-indexed)
357 manager.save(&mut output, (10, 5)).unwrap();
358 assert!(output.is_empty()); // No output for save
359 assert_eq!(manager.saved_position(), Some((10, 5)));
360
361 // Restore outputs CUP with 1-indexed coordinates
362 manager.restore(&mut output).unwrap();
363 assert_eq!(output, b"\x1b[6;11H"); // row=6, col=11 (1-indexed)
364 }
365
366 #[test]
367 fn cursor_manager_emulated_restore_without_save() {
368 let manager = CursorManager::new(CursorSaveStrategy::Emulated);
369 let mut output = Vec::new();
370
371 // Restore without save does nothing
372 manager.restore(&mut output).unwrap();
373 assert!(output.is_empty());
374 }
375
376 #[test]
377 fn cursor_manager_clear() {
378 let mut manager = CursorManager::new(CursorSaveStrategy::Emulated);
379 let mut output = Vec::new();
380
381 manager.save(&mut output, (5, 10)).unwrap();
382 assert_eq!(manager.saved_position(), Some((5, 10)));
383
384 manager.clear();
385 assert_eq!(manager.saved_position(), None);
386 }
387
388 #[test]
389 fn cursor_manager_default_uses_dec() {
390 let manager = CursorManager::default();
391 assert_eq!(manager.strategy(), CursorSaveStrategy::Dec);
392 }
393
394 #[test]
395 fn move_to_outputs_cup() {
396 let mut output = Vec::new();
397 move_to(&mut output, 0, 0).unwrap();
398 assert_eq!(output, b"\x1b[1;1H");
399
400 output.clear();
401 move_to(&mut output, 79, 23).unwrap();
402 assert_eq!(output, b"\x1b[24;80H");
403 }
404
405 #[test]
406 fn hide_and_show_cursor() {
407 let mut output = Vec::new();
408
409 hide(&mut output).unwrap();
410 assert_eq!(output, b"\x1b[?25l");
411
412 output.clear();
413 show(&mut output).unwrap();
414 assert_eq!(output, b"\x1b[?25h");
415 }
416}