Skip to main content

uncurses/text/
display.rs

1//! Serialize a [`Surface`] back into escape sequences and text.
2//!
3//! This is the inverse of [`Painter`](super::Painter). Where a painter parses
4//! a string of text and escape sequences *onto* a surface, [`Encode`] walks a
5//! surface row by row and emits its styled cells *back out* as a stream of SGR
6//! and OSC 8 sequences interleaved with grapheme-cluster content.
7//!
8//! Each terminal row becomes one output line, rows are separated by CRLF
9//! (`\r\n`) so the result reproduces the surface when written to a terminal,
10//! and every row begins and ends in the default style. Style transitions
11//! between adjacent cells are minimized the same way the renderer minimizes
12//! them, so a run of identically styled cells emits a single opener.
13//!
14//! ```
15//! use uncurses::buffer::{Buffer, SurfaceMut};
16//! use uncurses::cell::Cell;
17//! use uncurses::style::Style;
18//! use uncurses::text::Encode;
19//!
20//! let mut buf = Buffer::new(2, 1);
21//! buf.set_cell((0, 0).into(), &Cell::new("h", 1).style(Style::new().bold()));
22//! buf.set_cell((1, 0).into(), &Cell::new("i", 1).style(Style::new().bold()));
23//!
24//! // Render into a String via the Display adapter.
25//! let s = buf.display().to_string();
26//! assert!(s.contains("hi"));
27//! ```
28
29use std::fmt;
30use std::io::{self, Write};
31
32use crate::buffer::Surface;
33use crate::color::Profile;
34use crate::layout::Position;
35use crate::style::Style;
36use crate::style::diff::{convert_style, write_style_diff};
37
38/// Serialize a [`Surface`] into escape sequences and text.
39///
40/// This extension trait is implemented for every [`Surface`], so any cell
41/// grid (a [`Buffer`](crate::buffer::Buffer),
42/// [`Window`](crate::buffer::Window), [`TextBuffer`](crate::buffer::TextBuffer), or
43/// [`Screen`](crate::screen::Screen)) can be rendered back to its escape-code
44/// form.
45///
46/// Use [`encode`](Self::encode) to stream straight into an [`io::Write`], or
47/// [`display`](Self::display) to borrow the surface as a
48/// [`Display`](fmt::Display) for `format!`, `to_string`, and the `write!`
49/// macros. The [`encode_with`](Self::encode_with) and
50/// [`display_with`](Self::display_with) variants downsample every cell's
51/// colors to a [`Profile`] first, the same way the renderer adapts output to a
52/// terminal's color capability.
53pub trait Encode: Surface {
54    /// Write the surface to `w` as escape sequences and text.
55    ///
56    /// One terminal row is written per line, separated by CRLF (`\r\n`) with
57    /// no trailing newline after the final row. Each row starts and ends in
58    /// the default style: any open SGR state or hyperlink is reset at the end
59    /// of the row.
60    ///
61    /// Trailing unstyled blank cells are trimmed from each row, so a row with
62    /// nothing but default spaces emits an empty line. A blank cell still
63    /// counts as visible when it carries a style (for example a space with a
64    /// background color), so styled trailing space is preserved.
65    ///
66    /// Wide-cell continuation placeholders are skipped because the wide
67    /// primary already carries the full grapheme cluster. Colors are written
68    /// as stored; use [`encode_with`](Self::encode_with) to downsample them to
69    /// a color [`Profile`].
70    ///
71    /// # Errors
72    ///
73    /// Propagates any [`io::Error`] from `w`.
74    fn encode<W: Write>(&self, w: &mut W) -> io::Result<()> {
75        encode_surface(self, w, Profile::TrueColor)
76    }
77
78    /// Write the surface to `w`, downsampling colors to `profile`.
79    ///
80    /// Each cell's style is converted for `profile` before it is emitted, so
81    /// the output matches what the renderer would produce for a terminal with
82    /// that color capability: [`Profile::Ansi`] and [`Profile::Ansi256`]
83    /// quantize to the nearest palette color, [`Profile::Ascii`] drops colors
84    /// but keeps attributes, and [`Profile::Disabled`] drops all styling
85    /// (including hyperlinks). [`Profile::TrueColor`] is identical to
86    /// [`encode`](Self::encode).
87    ///
88    /// # Errors
89    ///
90    /// Propagates any [`io::Error`] from `w`.
91    fn encode_with<W: Write>(&self, w: &mut W, profile: Profile) -> io::Result<()> {
92        encode_surface(self, w, profile)
93    }
94
95    /// Borrow the surface as a [`Display`](fmt::Display) adapter.
96    ///
97    /// The returned value renders the same bytes as [`encode`](Self::encode)
98    /// when formatted, so `surface.display().to_string()` produces the encoded
99    /// string and `write!(w, "{}", surface.display())` writes it to any
100    /// [`io::Write`] or [`fmt::Write`] sink.
101    fn display(&self) -> SurfaceDisplay<'_, Self> {
102        SurfaceDisplay {
103            surface: self,
104            profile: Profile::TrueColor,
105        }
106    }
107
108    /// Borrow the surface as a [`Display`](fmt::Display) adapter that
109    /// downsamples colors to `profile`.
110    ///
111    /// Formatting it renders the same bytes as
112    /// [`encode_with`](Self::encode_with) with the same profile.
113    fn display_with(&self, profile: Profile) -> SurfaceDisplay<'_, Self> {
114        SurfaceDisplay {
115            surface: self,
116            profile,
117        }
118    }
119}
120
121impl<S: Surface + ?Sized> Encode for S {}
122
123/// A [`Display`](fmt::Display) adapter over a [`Surface`], returned by
124/// [`Encode::display`] and [`Encode::display_with`].
125///
126/// Formatting it emits the same bytes as [`Encode::encode_with`] with the
127/// adapter's profile.
128pub struct SurfaceDisplay<'a, S: Surface + ?Sized> {
129    surface: &'a S,
130    profile: Profile,
131}
132
133impl<S: Surface + ?Sized> fmt::Display for SurfaceDisplay<'_, S> {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        // SGR/OSC sequences are ASCII and cell content is UTF-8, so the
136        // encoded bytes are always valid UTF-8.
137        let mut buf = Vec::new();
138        encode_surface(self.surface, &mut buf, self.profile).map_err(|_| fmt::Error)?;
139        let s = std::str::from_utf8(&buf).map_err(|_| fmt::Error)?;
140        f.write_str(s)
141    }
142}
143
144/// Encode `surface` into `w`, row by row, downsampling colors to `profile`.
145fn encode_surface<S: Surface + ?Sized, W: Write>(
146    surface: &S,
147    w: &mut W,
148    profile: Profile,
149) -> io::Result<()> {
150    let bounds = surface.bounds();
151    let default = Style::default();
152    let x_end = bounds.x.saturating_add(bounds.width);
153    let y_end = bounds.y.saturating_add(bounds.height);
154
155    for (row, y) in (bounds.y..y_end).enumerate() {
156        if row > 0 {
157            w.write_all(b"\r\n")?;
158        }
159
160        // Trim trailing unstyled blank space: find the last column that is
161        // visible, i.e. has real content or a non-empty style after
162        // downsampling. A blank cell whose style is empty (a styled space with
163        // a background still counts as visible) contributes nothing once the
164        // row resets to default, so everything past `last_visible` is dropped.
165        let last_visible = (bounds.x..x_end).rev().find(|&x| {
166            surface.cell(Position::new(x, y)).is_some_and(|cell| {
167                !cell.is_blank() || !convert_style(&cell.style, profile).is_empty()
168            })
169        });
170        let Some(last_visible) = last_visible else {
171            // Entirely blank row: emit nothing between the separators.
172            continue;
173        };
174
175        // The pen starts each row in the default style with no open link.
176        let mut pen = default.clone();
177        for x in bounds.x..=last_visible {
178            let Some(cell) = surface.cell(Position::new(x, y)) else {
179                continue;
180            };
181            // Wide continuations carry no content of their own; the primary
182            // cell already emitted the whole grapheme cluster.
183            if cell.is_continuation() {
184                continue;
185            }
186
187            // Downsample to the target profile, then emit the SGR and OSC 8
188            // hyperlink delta from the current pen.
189            let to = convert_style(&cell.style, profile);
190            write_style_diff(w, &pen, &to)?;
191            pen = to;
192            w.write_all(cell.draw_bytes())?;
193        }
194
195        // Return the row to the default style, closing any open SGR state and
196        // hyperlink so the next row starts clean.
197        write_style_diff(w, &pen, &default)?;
198    }
199
200    Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::buffer::{Buffer, SurfaceMut};
207    use crate::cell::Cell;
208    use crate::style::{RESET, Style};
209
210    fn reset() -> &'static str {
211        std::str::from_utf8(RESET).unwrap()
212    }
213
214    #[test]
215    fn default_surface_trims_to_empty_rows() {
216        let buf = Buffer::new(3, 2);
217        // Every cell is an unstyled blank space, so each row trims to nothing,
218        // leaving two empty rows separated by CRLF.
219        assert_eq!(buf.display().to_string(), "\r\n");
220    }
221
222    #[test]
223    fn styled_run_emits_one_opener_and_a_trailing_reset() {
224        let mut buf = Buffer::new(2, 1);
225        let bold = Style::new().bold();
226        buf.set_cell((0, 0).into(), &Cell::new("h", 1).style(&bold));
227        buf.set_cell((1, 0).into(), &Cell::new("i", 1).style(&bold));
228
229        let out = buf.display().to_string();
230        // The run shares a style, so the opener appears once before the text
231        // and a single reset closes the row.
232        assert!(out.contains("hi"), "content present: {out:?}");
233        assert!(out.ends_with(reset()), "row reset: {out:?}");
234        assert_eq!(out.matches(reset()).count(), 1, "single reset: {out:?}");
235        assert_eq!(
236            out.matches("\x1b[").count(),
237            2,
238            "one opener + reset: {out:?}"
239        );
240    }
241
242    #[test]
243    fn a_cell_that_stores_nothing_is_blanked_across_its_whole_width() {
244        let mut buf = Buffer::new(5, 1);
245        buf.set_cell((0, 0).into(), &Cell::new("A", 1));
246        buf.set_cell((1, 0).into(), &Cell::new("", 3).style(Style::new().bold()));
247        buf.set_cell((4, 0).into(), &Cell::new("B", 1));
248
249        // The empty cell owns three columns, so it draws three blanks. Were
250        // its content written as it stands, the row would lose them and `B`
251        // would sit right beside `A`.
252        let out = buf.display().to_string();
253        assert!(out.contains("   "), "three blanks between: {out:?}");
254        assert!(out.starts_with('A'), "row starts at A: {out:?}");
255        assert!(out.ends_with(&format!("{}B", reset())), "B last: {out:?}");
256    }
257
258    #[test]
259    fn wide_continuation_is_skipped() {
260        let mut buf = Buffer::new(2, 1);
261        buf.set_cell((0, 0).into(), &Cell::new("世", 2));
262        // (1,0) is the continuation placeholder written by set_cell.
263        let out = buf.display().to_string();
264        assert!(out.starts_with("世"), "wide grapheme emitted once: {out:?}");
265        // No stray empty content or extra cell after the wide grapheme.
266        assert_eq!(out, "世");
267    }
268
269    #[test]
270    fn encode_matches_display() {
271        let mut buf = Buffer::new(2, 1);
272        buf.set_cell((0, 0).into(), &Cell::new("A", 1).style(Style::new().bold()));
273        let mut bytes = Vec::new();
274        buf.encode(&mut bytes).unwrap();
275        assert_eq!(String::from_utf8(bytes).unwrap(), buf.display().to_string());
276    }
277
278    #[test]
279    fn rows_are_crlf_separated_without_trailing_newline() {
280        let buf = Buffer::new(1, 3);
281        // Blank rows trim to empty, leaving just the CRLF separators.
282        let out = buf.display().to_string();
283        assert_eq!(out, "\r\n\r\n");
284    }
285
286    #[test]
287    fn trailing_unstyled_spaces_are_trimmed() {
288        let mut buf = Buffer::new(5, 1);
289        buf.set_cell((0, 0).into(), &Cell::new("h", 1));
290        buf.set_cell((1, 0).into(), &Cell::new("i", 1));
291        // Columns 2..5 stay blank and unstyled, so they are trimmed.
292        assert_eq!(buf.display().to_string(), "hi");
293    }
294
295    #[test]
296    fn a_trailing_column_holding_nothing_trims_like_one_holding_a_space() {
297        // Three cells that each put one space on the screen. `draw_bytes`
298        // gives `b" "` for all three, so the trim has to drop all three.
299        for tail in [Cell::BLANK, Cell::new(" ", 1), Cell::new("", 1)] {
300            let mut buf = Buffer::new(5, 1);
301            buf.set_cell((0, 0).into(), &Cell::new("h", 1));
302            buf.set_cell((1, 0).into(), &tail);
303            assert_eq!(buf.display().to_string(), "h", "tail {tail:?}");
304        }
305    }
306
307    #[test]
308    fn interior_blanks_are_kept_only_trailing_trimmed() {
309        let mut buf = Buffer::new(5, 1);
310        buf.set_cell((0, 0).into(), &Cell::new("a", 1));
311        buf.set_cell((2, 0).into(), &Cell::new("b", 1));
312        // The blank at column 1 is positional and kept; columns 3..5 trim.
313        assert_eq!(buf.display().to_string(), "a b");
314    }
315
316    #[test]
317    fn styled_trailing_space_is_not_trimmed() {
318        use crate::color::Color;
319        let mut buf = Buffer::new(3, 1);
320        buf.set_cell((0, 0).into(), &Cell::new("a", 1));
321        // A trailing space with a background is visible, so it survives.
322        let bg = Style::new().bg(Color::Red);
323        buf.set_cell((2, 0).into(), &Cell::new(" ", 1).style(&bg));
324        // "a", a positional blank, then the bg-styled space, then reset.
325        assert_eq!(buf.display().to_string(), "a \x1b[41m \x1b[m");
326    }
327
328    #[test]
329    fn disabled_profile_trims_styled_trailing_space() {
330        use crate::color::{Color, Profile};
331        let mut buf = Buffer::new(3, 1);
332        buf.set_cell((0, 0).into(), &Cell::new("a", 1));
333        let bg = Style::new().bg(Color::Red);
334        buf.set_cell((2, 0).into(), &Cell::new(" ", 1).style(&bg));
335        // Under Disabled the background is dropped, so the trailing space is
336        // unstyled and gets trimmed along with the interior blank.
337        assert_eq!(buf.display_with(Profile::Disabled).to_string(), "a");
338    }
339
340    #[test]
341    fn profile_disabled_drops_all_styling() {
342        use crate::color::{Color, Profile};
343        let mut buf = Buffer::new(2, 1);
344        let styled = Style::new().bold().fg(Color::Red);
345        buf.set_cell((0, 0).into(), &Cell::new("h", 1).style(&styled));
346        buf.set_cell((1, 0).into(), &Cell::new("i", 1).style(&styled));
347        // Disabled strips every escape: only the text remains.
348        assert_eq!(buf.display_with(Profile::Disabled).to_string(), "hi");
349    }
350
351    #[test]
352    fn profile_ansi_downsamples_truecolor_to_palette() {
353        use crate::color::{Color, Profile};
354        let mut buf = Buffer::new(1, 1);
355        // A pure-red 24-bit color quantizes to the nearest palette entry,
356        // xterm bright red (SGR 91), under Ansi.
357        let red = Style::new().fg(Color::Rgb(255, 0, 0));
358        buf.set_cell((0, 0).into(), &Cell::new("x", 1).style(&red));
359        let out = buf.display_with(Profile::Ansi).to_string();
360        assert_eq!(out, "\x1b[91mx\x1b[m");
361    }
362
363    #[test]
364    fn profile_ascii_keeps_attributes_but_drops_color() {
365        use crate::color::{Color, Profile};
366        let mut buf = Buffer::new(1, 1);
367        let styled = Style::new().bold().fg(Color::Rgb(10, 20, 30));
368        buf.set_cell((0, 0).into(), &Cell::new("x", 1).style(&styled));
369        // Bold (SGR 1) survives; the foreground color is dropped.
370        assert_eq!(
371            buf.display_with(Profile::Ascii).to_string(),
372            "\x1b[1mx\x1b[m"
373        );
374    }
375
376    #[test]
377    fn truecolor_profile_matches_default() {
378        use crate::color::{Color, Profile};
379        let mut buf = Buffer::new(2, 1);
380        let styled = Style::new().fg(Color::Rgb(1, 2, 3)).link("https://e.x", "");
381        buf.set_cell((0, 0).into(), &Cell::new("h", 1).style(&styled));
382        buf.set_cell((1, 0).into(), &Cell::new("i", 1).style(&styled));
383        assert_eq!(
384            buf.display_with(Profile::TrueColor).to_string(),
385            buf.display().to_string(),
386        );
387    }
388}