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::narrow("h").style(Style::new().bold()));
22//! buf.set_cell((1, 0).into(), &Cell::narrow("i").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.content().as_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::narrow("h").style(&bold));
227        buf.set_cell((1, 0).into(), &Cell::narrow("i").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 wide_continuation_is_skipped() {
244        let mut buf = Buffer::new(2, 1);
245        buf.set_cell((0, 0).into(), &Cell::wide("世"));
246        // (1,0) is the continuation placeholder written by set_cell.
247        let out = buf.display().to_string();
248        assert!(out.starts_with("世"), "wide grapheme emitted once: {out:?}");
249        // No stray empty content or extra cell after the wide grapheme.
250        assert_eq!(out, "世");
251    }
252
253    #[test]
254    fn encode_matches_display() {
255        let mut buf = Buffer::new(2, 1);
256        buf.set_cell((0, 0).into(), &Cell::narrow("A").style(Style::new().bold()));
257        let mut bytes = Vec::new();
258        buf.encode(&mut bytes).unwrap();
259        assert_eq!(String::from_utf8(bytes).unwrap(), buf.display().to_string());
260    }
261
262    #[test]
263    fn rows_are_crlf_separated_without_trailing_newline() {
264        let buf = Buffer::new(1, 3);
265        // Blank rows trim to empty, leaving just the CRLF separators.
266        let out = buf.display().to_string();
267        assert_eq!(out, "\r\n\r\n");
268    }
269
270    #[test]
271    fn trailing_unstyled_spaces_are_trimmed() {
272        let mut buf = Buffer::new(5, 1);
273        buf.set_cell((0, 0).into(), &Cell::narrow("h"));
274        buf.set_cell((1, 0).into(), &Cell::narrow("i"));
275        // Columns 2..5 stay blank and unstyled, so they are trimmed.
276        assert_eq!(buf.display().to_string(), "hi");
277    }
278
279    #[test]
280    fn interior_blanks_are_kept_only_trailing_trimmed() {
281        let mut buf = Buffer::new(5, 1);
282        buf.set_cell((0, 0).into(), &Cell::narrow("a"));
283        buf.set_cell((2, 0).into(), &Cell::narrow("b"));
284        // The blank at column 1 is positional and kept; columns 3..5 trim.
285        assert_eq!(buf.display().to_string(), "a b");
286    }
287
288    #[test]
289    fn styled_trailing_space_is_not_trimmed() {
290        use crate::color::Color;
291        let mut buf = Buffer::new(3, 1);
292        buf.set_cell((0, 0).into(), &Cell::narrow("a"));
293        // A trailing space with a background is visible, so it survives.
294        let bg = Style::new().bg(Color::Red);
295        buf.set_cell((2, 0).into(), &Cell::narrow(" ").style(&bg));
296        // "a", a positional blank, then the bg-styled space, then reset.
297        assert_eq!(buf.display().to_string(), "a \x1b[41m \x1b[m");
298    }
299
300    #[test]
301    fn disabled_profile_trims_styled_trailing_space() {
302        use crate::color::{Color, Profile};
303        let mut buf = Buffer::new(3, 1);
304        buf.set_cell((0, 0).into(), &Cell::narrow("a"));
305        let bg = Style::new().bg(Color::Red);
306        buf.set_cell((2, 0).into(), &Cell::narrow(" ").style(&bg));
307        // Under Disabled the background is dropped, so the trailing space is
308        // unstyled and gets trimmed along with the interior blank.
309        assert_eq!(buf.display_with(Profile::Disabled).to_string(), "a");
310    }
311
312    #[test]
313    fn profile_disabled_drops_all_styling() {
314        use crate::color::{Color, Profile};
315        let mut buf = Buffer::new(2, 1);
316        let styled = Style::new().bold().fg(Color::Red);
317        buf.set_cell((0, 0).into(), &Cell::narrow("h").style(&styled));
318        buf.set_cell((1, 0).into(), &Cell::narrow("i").style(&styled));
319        // Disabled strips every escape: only the text remains.
320        assert_eq!(buf.display_with(Profile::Disabled).to_string(), "hi");
321    }
322
323    #[test]
324    fn profile_ansi_downsamples_truecolor_to_palette() {
325        use crate::color::{Color, Profile};
326        let mut buf = Buffer::new(1, 1);
327        // A pure-red 24-bit color quantizes to the nearest palette entry,
328        // xterm bright red (SGR 91), under Ansi.
329        let red = Style::new().fg(Color::Rgb(255, 0, 0));
330        buf.set_cell((0, 0).into(), &Cell::narrow("x").style(&red));
331        let out = buf.display_with(Profile::Ansi).to_string();
332        assert_eq!(out, "\x1b[91mx\x1b[m");
333    }
334
335    #[test]
336    fn profile_ascii_keeps_attributes_but_drops_color() {
337        use crate::color::{Color, Profile};
338        let mut buf = Buffer::new(1, 1);
339        let styled = Style::new().bold().fg(Color::Rgb(10, 20, 30));
340        buf.set_cell((0, 0).into(), &Cell::narrow("x").style(&styled));
341        // Bold (SGR 1) survives; the foreground color is dropped.
342        assert_eq!(
343            buf.display_with(Profile::Ascii).to_string(),
344            "\x1b[1mx\x1b[m"
345        );
346    }
347
348    #[test]
349    fn truecolor_profile_matches_default() {
350        use crate::color::{Color, Profile};
351        let mut buf = Buffer::new(2, 1);
352        let styled = Style::new().fg(Color::Rgb(1, 2, 3)).link("https://e.x", "");
353        buf.set_cell((0, 0).into(), &Cell::narrow("h").style(&styled));
354        buf.set_cell((1, 0).into(), &Cell::narrow("i").style(&styled));
355        assert_eq!(
356            buf.display_with(Profile::TrueColor).to_string(),
357            buf.display().to_string(),
358        );
359    }
360}