Skip to main content

smelt_term/
flush.rs

1use super::grid::{to_crossterm_color, CellUpdate, Style};
2use crossterm::style::{
3    Attribute, Color, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
4};
5use crossterm::{cursor, QueueableCommand};
6use std::io::Write;
7
8pub fn flush_diff<'a, W: Write>(
9    w: &mut W,
10    updates: impl Iterator<Item = CellUpdate<'a>>,
11) -> std::io::Result<()> {
12    let mut current = Style::default();
13    let mut cursor_x: u16 = u16::MAX;
14    let mut cursor_y: u16 = u16::MAX;
15
16    for update in updates {
17        if update.y != cursor_y || update.x != cursor_x {
18            w.queue(cursor::MoveTo(update.x, update.y))?;
19        }
20        if update.cell.style != current {
21            emit_style_diff(w, &current, &update.cell.style)?;
22            current = update.cell.style;
23        }
24        let symbol = printable_symbol(update.cell.symbol);
25        let mut buf = [0u8; 4];
26        let s = symbol.encode_utf8(&mut buf);
27        w.write_all(s.as_bytes())?;
28        cursor_x = update.x + 1;
29        cursor_y = update.y;
30    }
31
32    if cursor_x != u16::MAX {
33        w.queue(SetAttribute(Attribute::Reset))?;
34        w.queue(ResetColor)?;
35    }
36
37    Ok(())
38}
39
40fn printable_symbol(symbol: char) -> char {
41    if symbol.is_control() {
42        ' '
43    } else {
44        symbol
45    }
46}
47
48fn emit_style_diff<W: Write>(w: &mut W, from: &Style, to: &Style) -> std::io::Result<()> {
49    let need_unbold = from.bold && !to.bold;
50    let need_undim = from.dim && !to.dim;
51    let need_unitalic = from.italic && !to.italic;
52    let need_uncrossed = from.crossedout && !to.crossedout;
53    let need_unreverse = from.reverse && !to.reverse;
54    let need_ununderline = from.underline && !to.underline;
55
56    let unsets = need_unbold as u8
57        + need_undim as u8
58        + need_unitalic as u8
59        + need_uncrossed as u8
60        + need_unreverse as u8
61        + need_ununderline as u8;
62    let intensity_conflict = (need_unbold && to.dim) || (need_undim && to.bold);
63
64    if unsets >= 2 || intensity_conflict {
65        w.queue(SetAttribute(Attribute::Reset))?;
66        w.queue(ResetColor)?;
67
68        if let Some(fg) = to.fg {
69            w.queue(SetForegroundColor(to_crossterm_color(fg)))?;
70        }
71        if let Some(bg) = to.bg {
72            w.queue(SetBackgroundColor(to_crossterm_color(bg)))?;
73        }
74        if to.bold {
75            w.queue(SetAttribute(Attribute::Bold))?;
76        }
77        if to.dim {
78            w.queue(SetAttribute(Attribute::Dim))?;
79        }
80        if to.italic {
81            w.queue(SetAttribute(Attribute::Italic))?;
82        }
83        if to.crossedout {
84            w.queue(SetAttribute(Attribute::CrossedOut))?;
85        }
86        if to.reverse {
87            w.queue(SetAttribute(Attribute::Reverse))?;
88        }
89        if to.underline {
90            w.queue(SetAttribute(Attribute::Underlined))?;
91        }
92        return Ok(());
93    }
94
95    if need_unbold || need_undim {
96        w.queue(SetAttribute(Attribute::NormalIntensity))?;
97        if need_unbold && to.dim {
98            w.queue(SetAttribute(Attribute::Dim))?;
99        }
100        if need_undim && to.bold {
101            w.queue(SetAttribute(Attribute::Bold))?;
102        }
103    }
104    if need_unitalic {
105        w.queue(SetAttribute(Attribute::NoItalic))?;
106    }
107    if need_uncrossed {
108        w.queue(SetAttribute(Attribute::NotCrossedOut))?;
109    }
110    if need_unreverse {
111        w.queue(SetAttribute(Attribute::NoReverse))?;
112    }
113    if need_ununderline {
114        w.queue(SetAttribute(Attribute::NoUnderline))?;
115    }
116
117    if !from.bold && to.bold {
118        w.queue(SetAttribute(Attribute::Bold))?;
119    }
120    if !from.dim && to.dim {
121        w.queue(SetAttribute(Attribute::Dim))?;
122    }
123    if !from.italic && to.italic {
124        w.queue(SetAttribute(Attribute::Italic))?;
125    }
126    if !from.crossedout && to.crossedout {
127        w.queue(SetAttribute(Attribute::CrossedOut))?;
128    }
129    if !from.reverse && to.reverse {
130        w.queue(SetAttribute(Attribute::Reverse))?;
131    }
132    if !from.underline && to.underline {
133        w.queue(SetAttribute(Attribute::Underlined))?;
134    }
135
136    if from.fg != to.fg {
137        if let Some(fg) = to.fg {
138            w.queue(SetForegroundColor(to_crossterm_color(fg)))?;
139        } else {
140            w.queue(SetForegroundColor(Color::Reset))?;
141        }
142    }
143    if from.bg != to.bg {
144        if let Some(bg) = to.bg {
145            w.queue(SetBackgroundColor(to_crossterm_color(bg)))?;
146        } else {
147            w.queue(SetBackgroundColor(Color::Reset))?;
148        }
149    }
150
151    Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::grid::Grid;
158    use smelt_style::style::Color;
159
160    #[test]
161    fn flush_empty_diff_produces_no_output() {
162        let a = Grid::new(5, 3);
163        let b = Grid::new(5, 3);
164        let mut out = Vec::new();
165        flush_diff(&mut out, a.diff(&b)).unwrap();
166        assert!(out.is_empty());
167    }
168
169    /// Run `flush_diff` over the diff between `curr` and `prev` and return the bytes
170    /// crossterm wrote. Tests assert on the exact byte sequence to pin SGR + cursor codes.
171    fn flush_to_string(curr: &Grid, prev: &Grid) -> String {
172        crossterm::style::force_color_output(true);
173        let mut out = Vec::new();
174        flush_diff(&mut out, curr.diff(prev)).unwrap();
175        String::from_utf8(out).unwrap()
176    }
177
178    #[test]
179    fn flush_single_cell_moves_cursor_and_writes_char() {
180        let prev = Grid::new(5, 3);
181        let mut curr = Grid::new(5, 3);
182        curr.set(2, 1, 'X', Style::default());
183        let s = flush_to_string(&curr, &prev);
184        // MoveTo(2, 1) emits "\x1b[2;3H" (1-based, row-first).
185        assert!(
186            s.starts_with("\x1b[2;3H"),
187            "expected MoveTo before char, got {s:?}"
188        );
189        // Char follows; final reset closes the run.
190        assert!(s.contains("\x1b[2;3HX"));
191        assert!(s.ends_with("\x1b[0m"));
192    }
193
194    #[test]
195    fn flush_styled_cell_emits_an_sgr_before_the_char() {
196        let prev = Grid::new(5, 1);
197        let mut curr = Grid::new(5, 1);
198        curr.set(0, 0, 'A', Style::new().fg(Color::Red));
199        let s = flush_to_string(&curr, &prev);
200        // We don't pin the exact bytes (crossterm picks the encoding for
201        // named colors). The contract: a styled cell emits *some* SGR
202        // sequence before the char, distinct from the default empty.
203        let a_pos = s.find('A').expect("A in output");
204        let before_a = &s[..a_pos];
205        assert!(
206            before_a.contains("\x1b[") && before_a.matches("\x1b[").count() >= 2,
207            "expected at least one SGR escape before A (after the MoveTo), got {before_a:?}"
208        );
209    }
210
211    #[test]
212    fn flush_emits_different_bytes_for_different_fg_colors() {
213        let render = |c: Color| {
214            let prev = Grid::new(3, 1);
215            let mut curr = Grid::new(3, 1);
216            curr.set(0, 0, 'A', Style::new().fg(c));
217            flush_to_string(&curr, &prev)
218        };
219        // Distinct named colors must not collapse to the same SGR.
220        assert_ne!(render(Color::Red), render(Color::Blue));
221        assert_ne!(render(Color::Green), render(Color::Yellow));
222    }
223
224    #[test]
225    fn flush_resets_style_at_end() {
226        let prev = Grid::new(3, 1);
227        let mut curr = Grid::new(3, 1);
228        curr.set(0, 0, 'A', Style::new().bold());
229        let mut out = Vec::new();
230        flush_diff(&mut out, curr.diff(&prev)).unwrap();
231        let s = String::from_utf8(out).unwrap();
232        assert!(s.ends_with("\x1b[0m"));
233    }
234
235    #[test]
236    fn flush_never_emits_cell_control_characters() {
237        let prev = Grid::new(4, 1);
238        let mut curr = Grid::new(4, 1);
239        curr.set(0, 0, 'A', Style::default());
240        curr.set(1, 0, '\r', Style::default());
241        curr.set(2, 0, '\x1b', Style::default());
242        curr.set(3, 0, 'B', Style::default());
243
244        let s = flush_to_string(&curr, &prev);
245        assert!(
246            !s.contains('\r'),
247            "carriage return leaked into flush bytes: {s:?}"
248        );
249        assert!(
250            s.contains("A  B"),
251            "control cells should render as spaces: {s:?}"
252        );
253    }
254
255    // ── Color encodings ───────────────────────────────────────────────────
256
257    #[test]
258    fn flush_emits_ansi_palette_color_code() {
259        let prev = Grid::new(3, 1);
260        let mut curr = Grid::new(3, 1);
261        curr.set(0, 0, 'A', Style::new().fg(Color::AnsiValue(208)));
262        let s = flush_to_string(&curr, &prev);
263        // Crossterm encodes ANSI palette fg as "\x1b[38;5;<n>m".
264        assert!(s.contains("\x1b[38;5;208m"), "got {s:?}");
265    }
266
267    #[test]
268    fn flush_emits_rgb_color_code() {
269        let prev = Grid::new(3, 1);
270        let mut curr = Grid::new(3, 1);
271        curr.set(
272            0,
273            0,
274            'A',
275            Style::new().fg(Color::Rgb {
276                r: 10,
277                g: 20,
278                b: 30,
279            }),
280        );
281        let s = flush_to_string(&curr, &prev);
282        // Crossterm encodes truecolor fg as "\x1b[38;2;r;g;bm".
283        assert!(s.contains("\x1b[38;2;10;20;30m"), "got {s:?}");
284    }
285
286    #[test]
287    fn flush_emits_distinct_sgr_for_fg_vs_bg() {
288        // A cell that only sets bg must produce different bytes than one
289        // that only sets fg with the same color - fg/bg can't collapse.
290        let render = |s: Style| {
291            let prev = Grid::new(3, 1);
292            let mut curr = Grid::new(3, 1);
293            curr.set(0, 0, 'A', s);
294            flush_to_string(&curr, &prev)
295        };
296        let fg_only = render(Style::new().fg(Color::Blue));
297        let bg_only = render(Style::new().bg(Color::Blue));
298        assert_ne!(fg_only, bg_only);
299    }
300
301    // ── Text attributes ──────────────────────────────────────────────────
302
303    #[test]
304    fn flush_emits_bold_attribute() {
305        let prev = Grid::new(3, 1);
306        let mut curr = Grid::new(3, 1);
307        curr.set(0, 0, 'A', Style::new().bold());
308        let s = flush_to_string(&curr, &prev);
309        assert!(s.contains("\x1b[1m"), "got {s:?}");
310    }
311
312    #[test]
313    fn flush_emits_dim_attribute() {
314        let prev = Grid::new(3, 1);
315        let mut curr = Grid::new(3, 1);
316        curr.set(0, 0, 'A', Style::new().dim());
317        let s = flush_to_string(&curr, &prev);
318        assert!(s.contains("\x1b[2m"), "got {s:?}");
319    }
320
321    #[test]
322    fn flush_emits_italic_attribute() {
323        let prev = Grid::new(3, 1);
324        let mut curr = Grid::new(3, 1);
325        curr.set(0, 0, 'A', Style::new().italic());
326        let s = flush_to_string(&curr, &prev);
327        assert!(s.contains("\x1b[3m"), "got {s:?}");
328    }
329
330    #[test]
331    fn flush_emits_underline_attribute() {
332        let prev = Grid::new(3, 1);
333        let mut curr = Grid::new(3, 1);
334        curr.set(0, 0, 'A', Style::new().underline());
335        let s = flush_to_string(&curr, &prev);
336        assert!(s.contains("\x1b[4m"), "got {s:?}");
337    }
338
339    #[test]
340    fn flush_emits_crossedout_attribute() {
341        let prev = Grid::new(3, 1);
342        let mut curr = Grid::new(3, 1);
343        curr.set(0, 0, 'A', Style::new().crossedout());
344        let s = flush_to_string(&curr, &prev);
345        assert!(s.contains("\x1b[9m"), "got {s:?}");
346    }
347
348    #[test]
349    fn flush_emits_reverse_attribute() {
350        let prev = Grid::new(3, 1);
351        let mut curr = Grid::new(3, 1);
352        curr.set(0, 0, 'A', Style::new().reverse());
353        let s = flush_to_string(&curr, &prev);
354        assert!(s.contains("\x1b[7m"), "got {s:?}");
355    }
356
357    // ── Transitions & cursor ─────────────────────────────────────────────
358
359    #[test]
360    fn flush_re_emits_sgr_when_style_changes_mid_run() {
361        // Two adjacent cells with different styles must not share one SGR
362        // run - there must be an escape between them.
363        let prev = Grid::new(3, 1);
364        let mut curr = Grid::new(3, 1);
365        curr.set(0, 0, 'A', Style::default());
366        curr.set(1, 0, 'B', Style::new().fg(Color::Red));
367        let s = flush_to_string(&curr, &prev);
368        let a_pos = s.find('A').expect("A in output");
369        let b_pos = s.find('B').expect("B in output");
370        let between = &s[a_pos + 1..b_pos];
371        assert!(
372            between.contains("\x1b["),
373            "expected an SGR escape between unstyled A and styled B, got {between:?}"
374        );
375    }
376
377    #[test]
378    fn flush_noncontiguous_cells_emit_moveto_between() {
379        let prev = Grid::new(10, 2);
380        let mut curr = Grid::new(10, 2);
381        curr.set(0, 0, 'A', Style::default());
382        curr.set(5, 1, 'B', Style::default());
383        let s = flush_to_string(&curr, &prev);
384        // 'A' at (0,0) → MoveTo(0,0) = "\x1b[1;1H". Then 'B' at (5,1) →
385        // MoveTo(5,1) = "\x1b[2;6H".
386        assert!(s.contains("\x1b[1;1HA"), "got {s:?}");
387        assert!(s.contains("\x1b[2;6HB"), "got {s:?}");
388    }
389
390    #[test]
391    fn flush_contiguous_cells_share_one_moveto() {
392        let prev = Grid::new(10, 1);
393        let mut curr = Grid::new(10, 1);
394        curr.set(0, 0, 'A', Style::default());
395        curr.set(1, 0, 'B', Style::default());
396        curr.set(2, 0, 'C', Style::default());
397        let s = flush_to_string(&curr, &prev);
398        // Only one MoveTo at the start; the rest stream after.
399        assert!(s.contains("\x1b[1;1HABC"), "got {s:?}");
400    }
401}