Skip to main content

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
417    #[test]
418    fn emulated_save_overwrites_previous_position() {
419        let mut manager = CursorManager::new(CursorSaveStrategy::Emulated);
420        let mut output = Vec::new();
421
422        manager.save(&mut output, (1, 2)).unwrap();
423        assert_eq!(manager.saved_position(), Some((1, 2)));
424
425        manager.save(&mut output, (30, 40)).unwrap();
426        assert_eq!(manager.saved_position(), Some((30, 40)));
427
428        manager.restore(&mut output).unwrap();
429        assert_eq!(output, b"\x1b[41;31H");
430    }
431
432    #[test]
433    fn cursor_save_strategy_default_is_dec() {
434        let strategy = CursorSaveStrategy::default();
435        assert_eq!(strategy, CursorSaveStrategy::Dec);
436    }
437
438    #[test]
439    fn cursor_manager_clone_preserves_saved_position() {
440        let mut manager = CursorManager::new(CursorSaveStrategy::Emulated);
441        let mut output = Vec::new();
442        manager.save(&mut output, (7, 13)).unwrap();
443
444        let cloned = manager.clone();
445        assert_eq!(cloned.saved_position(), Some((7, 13)));
446        assert_eq!(cloned.strategy(), CursorSaveStrategy::Emulated);
447    }
448}