Skip to main content

fresh/server/
capture_backend.rs

1//! Capturing backend for ratatui
2//!
3//! Instead of writing to a terminal, this backend captures all output
4//! to a buffer that can be sent to clients.
5
6use ratatui::backend::{Backend, ClearType, WindowSize};
7use ratatui::buffer::Cell;
8use ratatui::layout::{Position, Size};
9use ratatui::style::{Color, Modifier};
10use std::io::{self, Write};
11
12/// A backend that captures output to a buffer
13pub struct CaptureBackend {
14    /// Buffer holding the captured ANSI output
15    buffer: Vec<u8>,
16    /// Current terminal size
17    size: Size,
18    /// Current cursor position
19    cursor: Position,
20    /// Whether cursor is visible
21    cursor_visible: bool,
22    /// Current style state for diff optimization
23    current_fg: Color,
24    current_bg: Color,
25    current_modifiers: Modifier,
26}
27
28impl CaptureBackend {
29    /// Create a new capture backend with the given size
30    pub fn new(cols: u16, rows: u16) -> Self {
31        Self {
32            buffer: Vec::with_capacity(16 * 1024), // 16KB initial capacity
33            size: Size::new(cols, rows),
34            cursor: Position::new(0, 0),
35            cursor_visible: true,
36            current_fg: Color::Reset,
37            current_bg: Color::Reset,
38            current_modifiers: Modifier::empty(),
39        }
40    }
41
42    /// Take the captured output buffer, leaving an empty buffer
43    pub fn take_buffer(&mut self) -> Vec<u8> {
44        std::mem::take(&mut self.buffer)
45    }
46
47    /// Get a reference to the captured output
48    pub fn get_buffer(&self) -> &[u8] {
49        &self.buffer
50    }
51
52    /// Clear the buffer without returning it
53    pub fn clear_buffer(&mut self) {
54        self.buffer.clear();
55    }
56
57    /// Resize the backend
58    pub fn resize(&mut self, cols: u16, rows: u16) {
59        self.size = Size::new(cols, rows);
60    }
61
62    /// Reset style state to force full output on next draw
63    /// Call this when a new client connects to ensure they get a complete frame
64    pub fn reset_style_state(&mut self) {
65        self.current_fg = Color::Reset;
66        self.current_bg = Color::Reset;
67        self.current_modifiers = Modifier::empty();
68    }
69
70    /// Write ANSI escape sequence to move cursor
71    fn write_cursor_position(&mut self, x: u16, y: u16) {
72        // CSI row ; col H (1-based)
73        write!(self.buffer, "\x1b[{};{}H", y + 1, x + 1).unwrap();
74        self.cursor = Position::new(x, y);
75    }
76
77    /// Write ANSI escape sequence for style
78    fn write_style(&mut self, cell: &Cell) {
79        let mut needs_reset = false;
80        let mut sgr_params = Vec::new();
81
82        // Check if we need to reset
83        if cell.modifier != self.current_modifiers {
84            // Check for removed modifiers that require reset
85            let removed = self.current_modifiers - cell.modifier;
86            if !removed.is_empty() {
87                needs_reset = true;
88            }
89        }
90
91        if needs_reset {
92            sgr_params.push(0);
93            self.current_fg = Color::Reset;
94            self.current_bg = Color::Reset;
95            self.current_modifiers = Modifier::empty();
96        }
97
98        // Add modifiers
99        if cell.modifier.contains(Modifier::BOLD)
100            && !self.current_modifiers.contains(Modifier::BOLD)
101        {
102            sgr_params.push(1);
103        }
104        if cell.modifier.contains(Modifier::DIM) && !self.current_modifiers.contains(Modifier::DIM)
105        {
106            sgr_params.push(2);
107        }
108        if cell.modifier.contains(Modifier::ITALIC)
109            && !self.current_modifiers.contains(Modifier::ITALIC)
110        {
111            sgr_params.push(3);
112        }
113        if cell.modifier.contains(Modifier::UNDERLINED)
114            && !self.current_modifiers.contains(Modifier::UNDERLINED)
115        {
116            sgr_params.push(4);
117        }
118        if cell.modifier.contains(Modifier::SLOW_BLINK)
119            && !self.current_modifiers.contains(Modifier::SLOW_BLINK)
120        {
121            sgr_params.push(5);
122        }
123        if cell.modifier.contains(Modifier::RAPID_BLINK)
124            && !self.current_modifiers.contains(Modifier::RAPID_BLINK)
125        {
126            sgr_params.push(6);
127        }
128        if cell.modifier.contains(Modifier::REVERSED)
129            && !self.current_modifiers.contains(Modifier::REVERSED)
130        {
131            sgr_params.push(7);
132        }
133        if cell.modifier.contains(Modifier::HIDDEN)
134            && !self.current_modifiers.contains(Modifier::HIDDEN)
135        {
136            sgr_params.push(8);
137        }
138        if cell.modifier.contains(Modifier::CROSSED_OUT)
139            && !self.current_modifiers.contains(Modifier::CROSSED_OUT)
140        {
141            sgr_params.push(9);
142        }
143
144        // Foreground color
145        if cell.fg != self.current_fg {
146            self.write_color_params(&mut sgr_params, cell.fg, true);
147        }
148
149        // Background color
150        if cell.bg != self.current_bg {
151            self.write_color_params(&mut sgr_params, cell.bg, false);
152        }
153
154        // Write SGR sequence if needed
155        if !sgr_params.is_empty() {
156            self.buffer.extend_from_slice(b"\x1b[");
157            for (i, param) in sgr_params.iter().enumerate() {
158                if i > 0 {
159                    self.buffer.push(b';');
160                }
161                write!(self.buffer, "{}", param).unwrap();
162            }
163            self.buffer.push(b'm');
164        }
165
166        self.current_fg = cell.fg;
167        self.current_bg = cell.bg;
168        self.current_modifiers = cell.modifier;
169    }
170
171    /// Add color parameters to SGR sequence
172    fn write_color_params(&self, params: &mut Vec<u8>, color: Color, foreground: bool) {
173        let base = if foreground { 30 } else { 40 };
174
175        match color {
176            Color::Reset => params.push(if foreground { 39 } else { 49 }),
177            Color::Black => params.push(base),
178            Color::Red => params.push(base + 1),
179            Color::Green => params.push(base + 2),
180            Color::Yellow => params.push(base + 3),
181            Color::Blue => params.push(base + 4),
182            Color::Magenta => params.push(base + 5),
183            Color::Cyan => params.push(base + 6),
184            Color::Gray => params.push(base + 7),
185            Color::DarkGray => params.push(base + 60),
186            Color::LightRed => params.push(base + 61),
187            Color::LightGreen => params.push(base + 62),
188            Color::LightYellow => params.push(base + 63),
189            Color::LightBlue => params.push(base + 64),
190            Color::LightMagenta => params.push(base + 65),
191            Color::LightCyan => params.push(base + 66),
192            Color::White => params.push(base + 67),
193            Color::Indexed(i) => {
194                params.push(if foreground { 38 } else { 48 });
195                params.push(5);
196                params.push(i);
197            }
198            Color::Rgb(r, g, b) => {
199                params.push(if foreground { 38 } else { 48 });
200                params.push(2);
201                params.push(r);
202                params.push(g);
203                params.push(b);
204            }
205        }
206    }
207}
208
209impl Backend for CaptureBackend {
210    type Error = io::Error;
211
212    fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
213    where
214        I: Iterator<Item = (u16, u16, &'a Cell)>,
215    {
216        let mut last_pos: Option<(u16, u16)> = None;
217
218        for (x, y, cell) in content {
219            // Move cursor if not at expected position
220            let needs_move = match last_pos {
221                None => true,
222                Some((lx, ly)) => {
223                    // Check if this is the next position
224                    !(ly == y && lx + 1 == x)
225                }
226            };
227
228            if needs_move {
229                self.write_cursor_position(x, y);
230            }
231
232            // Write style changes
233            self.write_style(cell);
234
235            // Write the character
236            let symbol = cell.symbol();
237            self.buffer.extend_from_slice(symbol.as_bytes());
238
239            last_pos = Some((x, y));
240        }
241
242        Ok(())
243    }
244
245    fn hide_cursor(&mut self) -> io::Result<()> {
246        // Always emit hide cursor - don't optimize based on state
247        // This ensures client terminal always gets the correct state
248        self.buffer.extend_from_slice(b"\x1b[?25l");
249        self.cursor_visible = false;
250        Ok(())
251    }
252
253    fn show_cursor(&mut self) -> io::Result<()> {
254        // Always emit show cursor for hardware cursor visibility
255        self.buffer.extend_from_slice(b"\x1b[?25h");
256        self.cursor_visible = true;
257        Ok(())
258    }
259
260    fn get_cursor_position(&mut self) -> io::Result<Position> {
261        Ok(self.cursor)
262    }
263
264    fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
265        let pos = position.into();
266        self.write_cursor_position(pos.x, pos.y);
267        Ok(())
268    }
269
270    fn clear(&mut self) -> io::Result<()> {
271        // Clear entire screen
272        self.buffer.extend_from_slice(b"\x1b[2J");
273        // Move cursor to home
274        self.buffer.extend_from_slice(b"\x1b[H");
275        self.cursor = Position::new(0, 0);
276        Ok(())
277    }
278
279    fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
280        match clear_type {
281            ClearType::All => {
282                self.buffer.extend_from_slice(b"\x1b[2J");
283            }
284            ClearType::AfterCursor => {
285                self.buffer.extend_from_slice(b"\x1b[J");
286            }
287            ClearType::BeforeCursor => {
288                self.buffer.extend_from_slice(b"\x1b[1J");
289            }
290            ClearType::CurrentLine => {
291                self.buffer.extend_from_slice(b"\x1b[2K");
292            }
293            ClearType::UntilNewLine => {
294                self.buffer.extend_from_slice(b"\x1b[K");
295            }
296        }
297        Ok(())
298    }
299
300    fn append_lines(&mut self, n: u16) -> io::Result<()> {
301        // Scroll up by n lines
302        for _ in 0..n {
303            self.buffer.extend_from_slice(b"\x1b[S");
304        }
305        Ok(())
306    }
307
308    fn size(&self) -> io::Result<Size> {
309        Ok(self.size)
310    }
311
312    fn window_size(&mut self) -> io::Result<WindowSize> {
313        // We don't know pixel size, return a reasonable default
314        Ok(WindowSize {
315            columns_rows: self.size,
316            pixels: Size::new(self.size.width * 8, self.size.height * 16),
317        })
318    }
319
320    fn flush(&mut self) -> io::Result<()> {
321        // Nothing to flush - we're just capturing
322        Ok(())
323    }
324}
325
326/// Generate terminal setup sequences
327///
328/// Uses shared constants from `terminal_modes::sequences` to stay in sync
329/// with the direct-mode terminal setup in `TerminalModes::enable()`.
330pub fn terminal_setup_sequences() -> Vec<u8> {
331    use crate::services::terminal_modes::sequences as seq;
332
333    let mut buf = Vec::new();
334
335    // Enter alternate screen
336    buf.extend_from_slice(seq::ENTER_ALTERNATE_SCREEN);
337    // Enable mouse tracking (SGR format)
338    buf.extend_from_slice(seq::ENABLE_MOUSE_CLICK);
339    buf.extend_from_slice(seq::ENABLE_MOUSE_DRAG);
340    buf.extend_from_slice(seq::ENABLE_MOUSE_MOTION);
341    buf.extend_from_slice(seq::ENABLE_SGR_MOUSE);
342    // Enable focus events
343    buf.extend_from_slice(seq::ENABLE_FOCUS_EVENTS);
344    // Enable bracketed paste
345    buf.extend_from_slice(seq::ENABLE_BRACKETED_PASTE);
346    // Hide cursor initially
347    buf.extend_from_slice(seq::HIDE_CURSOR);
348
349    buf
350}
351
352/// Generate terminal teardown sequences
353///
354/// Uses shared constants from `terminal_modes::sequences` to stay in sync
355/// with the cleanup in `TerminalModes::undo()` and `emergency_cleanup()`.
356pub fn terminal_teardown_sequences() -> Vec<u8> {
357    use crate::services::terminal_modes::sequences as seq;
358
359    let mut buf = Vec::new();
360
361    // Show cursor
362    buf.extend_from_slice(seq::SHOW_CURSOR);
363    // Reset cursor style to default
364    buf.extend_from_slice(seq::RESET_CURSOR_STYLE);
365    // Disable bracketed paste
366    buf.extend_from_slice(seq::DISABLE_BRACKETED_PASTE);
367    // Disable focus events
368    buf.extend_from_slice(seq::DISABLE_FOCUS_EVENTS);
369    // Disable mouse tracking
370    buf.extend_from_slice(seq::DISABLE_SGR_MOUSE);
371    buf.extend_from_slice(seq::DISABLE_MOUSE_MOTION);
372    buf.extend_from_slice(seq::DISABLE_MOUSE_DRAG);
373    buf.extend_from_slice(seq::DISABLE_MOUSE_CLICK);
374    // Reset attributes
375    buf.extend_from_slice(seq::RESET_ATTRIBUTES);
376    // Leave alternate screen
377    buf.extend_from_slice(seq::LEAVE_ALTERNATE_SCREEN);
378
379    buf
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use ratatui::buffer::Buffer;
386    use ratatui::style::Style;
387
388    #[test]
389    fn test_size_tracks_dimensions() {
390        let mut backend = CaptureBackend::new(80, 24);
391        assert_eq!(backend.size().unwrap(), Size::new(80, 24));
392
393        backend.resize(120, 40);
394        assert_eq!(backend.size().unwrap(), Size::new(120, 40));
395    }
396
397    #[test]
398    fn test_clear_emits_ansi_clear_sequence() {
399        let mut backend = CaptureBackend::new(80, 24);
400        backend.clear().unwrap();
401
402        let output = backend.take_buffer();
403        // ED (Erase Display) sequence: CSI 2 J
404        assert!(output.starts_with(b"\x1b[2J"));
405    }
406
407    #[test]
408    fn test_draw_outputs_cell_content() {
409        let mut backend = CaptureBackend::new(80, 24);
410
411        let mut buffer = Buffer::empty(ratatui::layout::Rect::new(0, 0, 5, 1));
412        buffer.set_string(0, 0, "Hello", Style::default());
413
414        let area = buffer.area;
415        backend
416            .draw(buffer.content.iter().enumerate().map(|(i, cell)| {
417                let x = (i as u16) % area.width;
418                let y = (i as u16) / area.width;
419                (x + area.x, y + area.y, cell)
420            }))
421            .unwrap();
422
423        let buf = backend.take_buffer();
424        let output = String::from_utf8_lossy(&buf);
425        assert!(output.contains("Hello"));
426    }
427
428    #[test]
429    fn test_cursor_visibility_emits_correct_sequences() {
430        let mut backend = CaptureBackend::new(80, 24);
431
432        backend.hide_cursor().unwrap();
433        let output = backend.take_buffer();
434        assert_eq!(output, b"\x1b[?25l"); // DECTCEM hide
435
436        backend.show_cursor().unwrap();
437        let output = backend.take_buffer();
438        assert_eq!(output, b"\x1b[?25h"); // DECTCEM show
439    }
440
441    #[test]
442    fn test_cursor_visibility_always_emits_hide() {
443        let mut backend = CaptureBackend::new(80, 24);
444
445        backend.hide_cursor().unwrap();
446        backend.clear_buffer();
447
448        // Second hide should still emit (no optimization - ensures client sync)
449        backend.hide_cursor().unwrap();
450        assert_eq!(backend.take_buffer(), b"\x1b[?25l");
451    }
452
453    #[test]
454    fn test_take_buffer_clears_internal_buffer() {
455        let mut backend = CaptureBackend::new(80, 24);
456        backend.clear().unwrap();
457
458        let first = backend.take_buffer();
459        assert!(!first.is_empty());
460
461        let second = backend.take_buffer();
462        assert!(second.is_empty());
463    }
464
465    #[test]
466    fn test_setup_sequences_enable_features() {
467        let setup = terminal_setup_sequences();
468        let setup_str = String::from_utf8_lossy(&setup);
469
470        // Alternate screen
471        assert!(setup_str.contains("\x1b[?1049h"));
472        // Mouse tracking
473        assert!(setup_str.contains("\x1b[?1000h"));
474        // Focus events
475        assert!(setup_str.contains("\x1b[?1004h"));
476    }
477
478    #[test]
479    fn test_teardown_sequences_disable_features() {
480        let teardown = terminal_teardown_sequences();
481        let teardown_str = String::from_utf8_lossy(&teardown);
482
483        // Leave alternate screen
484        assert!(teardown_str.contains("\x1b[?1049l"));
485        // Reset attributes
486        assert!(teardown_str.contains("\x1b[0m"));
487    }
488
489    #[test]
490    fn test_clear_region_variants() {
491        let mut backend = CaptureBackend::new(80, 24);
492
493        backend.clear_region(ClearType::AfterCursor).unwrap();
494        assert!(backend.take_buffer().ends_with(b"\x1b[J"));
495
496        backend.clear_region(ClearType::CurrentLine).unwrap();
497        assert!(backend.take_buffer().ends_with(b"\x1b[2K"));
498    }
499}