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, ¤t, &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 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 assert!(
186 s.starts_with("\x1b[2;3H"),
187 "expected MoveTo before char, got {s:?}"
188 );
189 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 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 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 #[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 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 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 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 #[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 #[test]
360 fn flush_re_emits_sgr_when_style_changes_mid_run() {
361 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 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 assert!(s.contains("\x1b[1;1HABC"), "got {s:?}");
400 }
401}