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}