Skip to main content

tui_lipan/
capture.rs

1//! Public frame-capture types for snapshot and visual tests.
2
3use std::fmt::Write;
4#[cfg(feature = "ui-snapshot-png")]
5use std::path::PathBuf;
6#[cfg(feature = "ui-snapshot-png")]
7use std::sync::Arc;
8
9use crate::style::ansi::write_cell_style_sgr;
10use crate::style::{Color, Rect, Style};
11
12#[cfg(feature = "ui-snapshot-png")]
13mod png;
14
15/// Captured terminal cell data converted to crate-owned style primitives.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct CapturedCell {
18    /// Rendered symbol at this cell.
19    pub symbol: String,
20    /// Foreground color.
21    pub fg: Color,
22    /// Background color.
23    pub bg: Color,
24    /// Underline color.
25    pub underline_color: Color,
26    /// Text modifiers active on this cell.
27    pub modifiers: CellModifiers,
28}
29
30/// Boolean modifier flags extracted from a rendered terminal cell.
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct CellModifiers {
33    /// Bold text.
34    pub bold: bool,
35    /// Dim text.
36    pub dim: bool,
37    /// Italic text.
38    pub italic: bool,
39    /// Underlined text.
40    pub underline: bool,
41    /// Reverse-video text.
42    pub reverse: bool,
43    /// Strikethrough text.
44    pub strikethrough: bool,
45}
46
47/// Cursor metadata captured from a rendered frame.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct CursorState {
50    /// Cursor column.
51    pub x: u16,
52    /// Cursor row.
53    pub y: u16,
54    /// Whether the cursor should be shown.
55    pub visible: bool,
56}
57
58/// Complete frame snapshot produced by [`crate::TestBackend::capture_frame`].
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct CapturedFrame {
61    /// Viewport used for render/layout.
62    pub viewport: Rect,
63    /// Captured frame width.
64    pub width: u16,
65    /// Captured frame height.
66    pub height: u16,
67    /// Flattened row-major cell buffer (`width * height`).
68    pub cells: Vec<CapturedCell>,
69    /// Cursor state, when a widget requested cursor placement.
70    pub cursor: Option<CursorState>,
71}
72
73/// Options for rendering a [`CapturedFrame`] as PNG bytes.
74#[cfg(feature = "ui-snapshot-png")]
75#[derive(Clone, Debug)]
76pub struct PngOptions {
77    /// Base width of each terminal cell before applying [`Self::scale`].
78    ///
79    /// Defaults to `8`; zero is clamped to `1` while rendering.
80    pub cell_width: u16,
81    /// Base height of each terminal cell before applying [`Self::scale`].
82    ///
83    /// Defaults to `16`; zero is clamped to `1` while rendering.
84    pub cell_height: u16,
85    /// Multiplier applied to cell dimensions while rendering.
86    ///
87    /// Defaults to `2`; zero is clamped to `1` while rendering.
88    pub scale: u16,
89    /// Foreground color used when a cell foreground resolves to reset or transparent.
90    pub default_fg: Color,
91    /// Background color used when a cell background resolves to reset, transparent, or backdrop.
92    pub default_bg: Color,
93    /// Whether to draw a cursor outline when the captured frame contains a visible cursor.
94    ///
95    /// Defaults to `true`.
96    pub render_cursor: bool,
97    /// Text renderer used for non-box/block glyphs.
98    ///
99    /// Defaults to [`PngTextRenderer::Auto`], which tries a system font and falls back to bitmap.
100    pub text_renderer: PngTextRenderer,
101    /// Preferred system font family for font-backed rendering.
102    pub font_family: Option<Arc<str>>,
103    /// Explicit font file path for font-backed rendering.
104    pub font_path: Option<PathBuf>,
105}
106
107/// Text renderer used when converting a captured frame to PNG bytes.
108#[cfg(feature = "ui-snapshot-png")]
109#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
110pub enum PngTextRenderer {
111    /// Try font-backed rendering and fall back to bitmap rendering when no font is available.
112    #[default]
113    Auto,
114    /// Try font-backed rendering and fall back to bitmap rendering when loading or glyph lookup fails.
115    Font,
116    /// Always use the built-in bitmap font renderer.
117    Bitmap,
118}
119
120#[cfg(feature = "ui-snapshot-png")]
121impl Default for PngOptions {
122    fn default() -> Self {
123        Self {
124            cell_width: 8,
125            cell_height: 16,
126            scale: 2,
127            default_fg: Color::White,
128            default_bg: Color::Black,
129            render_cursor: true,
130            text_renderer: PngTextRenderer::Auto,
131            font_family: None,
132            font_path: None,
133        }
134    }
135}
136
137impl CapturedCell {
138    fn style(&self) -> Style {
139        Style {
140            fg: Some(self.fg.into()),
141            bg: Some(self.bg.into()),
142            fg_transform: None,
143            bg_transform: None,
144            contrast_policy: None,
145            bold: Some(self.modifiers.bold),
146            dim: Some(self.modifiers.dim),
147            italic: Some(self.modifiers.italic),
148            underline: Some(self.modifiers.underline),
149            reverse: Some(self.modifiers.reverse),
150            strikethrough: Some(self.modifiers.strikethrough),
151            underline_color: Some(self.underline_color.into()),
152            dim_amount: None,
153            tint: None,
154        }
155    }
156
157    /// Write ANSI SGR sequences for this cell's style into `out`.
158    pub fn write_ansi_style(&self, out: &mut String) {
159        let m = &self.modifiers;
160        write_cell_style_sgr(
161            out,
162            crate::style::ansi::CellStyleSgr {
163                fg: self.fg,
164                bg: self.bg,
165                underline_color: self.underline_color,
166                bold: m.bold,
167                dim: m.dim,
168                italic: m.italic,
169                underline: m.underline,
170                reverse: m.reverse,
171                strikethrough: m.strikethrough,
172            },
173        );
174    }
175
176    /// Returns true when this cell's visual style matches `other`.
177    pub fn ansi_style_matches(&self, other: &CapturedCell) -> bool {
178        self.fg == other.fg
179            && self.bg == other.bg
180            && self.underline_color == other.underline_color
181            && self.modifiers == other.modifiers
182    }
183}
184
185impl CapturedFrame {
186    /// Encode this frame as a PNG byte buffer.
187    ///
188    /// With [`PngTextRenderer::Auto`], text is rendered with a discovered system
189    /// font when available and falls back to the built-in bitmap renderer.
190    ///
191    /// If PNG encoding fails, returns an empty buffer. Use [`Self::try_to_png`] when the
192    /// encoding error should be surfaced to the caller.
193    #[cfg(feature = "ui-snapshot-png")]
194    pub fn to_png(&self, options: &PngOptions) -> Vec<u8> {
195        png::encode_frame(self, options)
196    }
197
198    /// Encode this frame as PNG bytes and return any encoder error.
199    #[cfg(feature = "ui-snapshot-png")]
200    pub fn try_to_png(&self, options: &PngOptions) -> crate::Result<Vec<u8>> {
201        png::try_encode_frame(self, options)
202            .map_err(|err| std::io::Error::other(err.to_string()).into())
203    }
204
205    /// Returns captured text as newline-joined rows with trailing spaces trimmed.
206    pub fn plain_text(&self) -> String {
207        self.to_lines().join("\n")
208    }
209
210    /// Returns captured rows at full viewport width without trimming trailing spaces.
211    pub fn to_fixed_grid(&self) -> String {
212        self.to_fixed_grid_lines().join("\n")
213    }
214
215    /// Returns captured rows at full viewport width without trimming trailing spaces.
216    pub fn to_fixed_grid_lines(&self) -> Vec<String> {
217        (0..self.height)
218            .map(|y| {
219                let mut line = String::new();
220                for cell in self.row(y) {
221                    line.push_str(&cell.symbol);
222                }
223                line
224            })
225            .collect()
226    }
227
228    /// Returns the frame rendered as a static ANSI string (full terminal repaint prelude).
229    pub fn to_ansi(&self) -> String {
230        self.to_ansi_diff(None)
231    }
232
233    /// Returns an ANSI string updating `prev` to this frame.
234    ///
235    /// When `prev` is `None` or dimensions differ, emits a full clear + home cursor prelude
236    /// suitable for terminal backends (including xterm.js).
237    pub fn to_ansi_diff(&self, prev: Option<&CapturedFrame>) -> String {
238        let full_repaint = prev
239            .map(|p| p.width != self.width || p.height != self.height)
240            .unwrap_or(true);
241        let mut out = String::with_capacity(if full_repaint {
242            usize::from(self.width) * usize::from(self.height) * 12 + 64
243        } else {
244            usize::from(self.width) * usize::from(self.height) * 3 + 512
245        });
246
247        if full_repaint {
248            out.push_str("\x1b[2J\x1b[3J\x1b[H\x1b[?25l");
249        }
250
251        let w = usize::from(self.width);
252        let mut last_written: Option<(u16, u16)> = None;
253        let mut last_styled_cell: Option<&CapturedCell> = None;
254        for y in 0..self.height {
255            for x in 0..self.width {
256                let idx = usize::from(y) * w + usize::from(x);
257                let cell = &self.cells[idx];
258                if !full_repaint
259                    && let Some(prev_frame) = prev
260                    && prev_frame.cells[idx] == *cell
261                {
262                    continue;
263                }
264                let contiguous = matches!(last_written, Some((lx, ly)) if ly == y && lx + 1 == x);
265                if !contiguous {
266                    let _ = write!(out, "\x1b[{};{}H", y + 1, x + 1);
267                }
268                if !(contiguous
269                    && last_styled_cell.is_some_and(|prev_cell| prev_cell.ansi_style_matches(cell)))
270                {
271                    cell.write_ansi_style(&mut out);
272                    last_styled_cell = Some(cell);
273                }
274                out.push_str(&cell.symbol);
275                last_written = Some((x, y));
276            }
277        }
278        if let Some(c) = self.cursor.as_ref().filter(|c| c.visible) {
279            let _ = write!(out, "\x1b[{};{}H\x1b[?25h", c.y + 1, c.x + 1);
280        } else {
281            out.push_str("\x1b[?25l");
282        }
283        out
284    }
285
286    /// Returns captured rows with trailing spaces trimmed per row.
287    pub fn to_lines(&self) -> Vec<String> {
288        (0..self.height)
289            .map(|y| {
290                let mut line = String::new();
291                for cell in self.row(y) {
292                    line.push_str(&cell.symbol);
293                }
294                line.trim_end_matches(' ').to_owned()
295            })
296            .collect()
297    }
298
299    /// Returns all cells for row `y`.
300    ///
301    /// Panics if `y >= self.height`.
302    pub fn row(&self, y: u16) -> &[CapturedCell] {
303        assert!(y < self.height, "row y out of bounds");
304        let start = usize::from(y) * usize::from(self.width);
305        let end = start + usize::from(self.width);
306        &self.cells[start..end]
307    }
308
309    /// Returns a single cell at `(x, y)`.
310    ///
311    /// Panics if `x >= self.width` or `y >= self.height`.
312    pub fn cell(&self, x: u16, y: u16) -> &CapturedCell {
313        assert!(x < self.width, "cell x out of bounds");
314        &self.row(y)[usize::from(x)]
315    }
316
317    /// Returns each row grouped into contiguous `(text, style)` runs.
318    pub fn styled_lines(&self) -> Vec<Vec<(String, Style)>> {
319        (0..self.height)
320            .map(|y| {
321                let mut runs: Vec<(String, Style)> = Vec::new();
322
323                for cell in self.row(y) {
324                    let style = cell.style();
325                    if let Some((text, run_style)) = runs.last_mut()
326                        && *run_style == style
327                    {
328                        text.push_str(&cell.symbol);
329                    } else {
330                        runs.push((cell.symbol.clone(), style));
331                    }
332                }
333
334                runs
335            })
336            .collect()
337    }
338}