Skip to main content

photon_ui/
renderer.rs

1use thiserror::Error;
2
3/// Error type returned when a component produces invalid output.
4#[derive(Error, Debug, Clone, PartialEq)]
5pub enum RenderError {
6    /// A rendered line exceeds the allowed width.
7    #[error("width overflow: line width {actual} exceeds {width}")]
8    WidthOverflow {
9        /// The offending line content.
10        line: String,
11        /// The maximum allowed width.
12        width: u16,
13        /// The measured width of the line.
14        actual: usize,
15    },
16}
17
18/// Result of handling an input event.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum InputResult {
21    /// The event was consumed and handled by this component.
22    Handled,
23    /// The event was not relevant; the framework may propagate it.
24    Ignored,
25    /// The event was handled and the component requests an immediate re-render.
26    RequestRender,
27}
28
29/// The output of a component's [`render`](crate::Component::render) call.
30///
31/// Contains the text lines to display, an optional cursor position, and
32/// any terminal image commands that should be emitted.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Rendered {
35    /// Lines of text, each guaranteed to fit within the requested width.
36    pub lines: Vec<String>,
37    /// Optional cursor position as `(row, col)` in screen coordinates.
38    pub cursor: Option<(usize, usize)>,
39    /// Terminal image commands (Kitty / iTerm2 protocols) to emit.
40    pub images: Vec<ImageCommand>,
41}
42
43/// A command to display an image in the terminal.
44///
45/// Images are identified by an `id` so the renderer can track which images
46/// are still visible and delete stale ones.
47#[derive(Debug, Clone, PartialEq)]
48pub struct ImageCommand {
49    /// Unique identifier for this image.
50    pub id: u32,
51    /// Raw image data or protocol-specific payload.
52    pub data: String,
53}
54
55impl Rendered {
56    /// Create an empty rendered frame with no lines, cursor, or images.
57    pub fn empty() -> Self {
58        Self {
59            lines: Vec::new(),
60            cursor: None,
61            images: Vec::new(),
62        }
63    }
64
65    /// Composite this rendered content onto a target at the given offset.
66    ///
67    /// Lines are overwritten starting at `row` / `col`. The cursor and image
68    /// commands are translated and appended to the target.
69    pub fn blit_onto(&self, target: &mut Rendered, row: u16, col: u16) {
70        for (i, line) in self.lines.iter().enumerate() {
71            let target_row = row as usize + i;
72            if target_row >= target.lines.len() {
73                break;
74            }
75            let col_usize = col as usize;
76            let target_vw = crate::utils::visible_width(&target.lines[target_row]);
77            // Pad target line so the overlay has something to overwrite.
78            if target_vw < col_usize {
79                target.lines[target_row].push_str(&" ".repeat(col_usize - target_vw));
80            }
81            let source_vw = crate::utils::visible_width(line);
82            let end = col_usize + source_vw;
83            let target_vw_after = crate::utils::visible_width(&target.lines[target_row]);
84            if end > target_vw_after {
85                target.lines[target_row].push_str(&" ".repeat(end - target_vw_after));
86            }
87            let start_byte =
88                crate::utils::byte_index_at_visual_pos(&target.lines[target_row], col_usize);
89            let end_byte = crate::utils::byte_index_at_visual_pos(&target.lines[target_row], end);
90            target.lines[target_row].replace_range(start_byte..end_byte, line);
91        }
92        if let Some((r, c)) = self.cursor {
93            target.cursor = Some((row as usize + r, col as usize + c));
94        }
95        target.images.extend(self.images.clone());
96    }
97
98    /// Composite this rendered content into a target at the given rect.
99    ///
100    /// Lines are clipped to `rect.height`. Each line is inserted at `rect.x`
101    /// and truncated to `rect.width`. The cursor and images are translated.
102    pub fn blit_into_rect(&self, target: &mut Rendered, rect: Rect) {
103        for (i, line) in self.lines.iter().enumerate().take(rect.height as usize) {
104            let target_row = rect.y as usize + i;
105            if target_row >= target.lines.len() {
106                while target.lines.len() <= target_row {
107                    target.lines.push(String::new());
108                }
109            }
110            let col = rect.x as usize;
111            let target_line = &mut target.lines[target_row];
112            let target_vw = crate::utils::visible_width(target_line);
113            if target_vw < col {
114                target_line.push_str(&" ".repeat(col - target_vw));
115            }
116            let truncated = if crate::utils::visible_width(line) > rect.width as usize {
117                Some(crate::utils::truncate_to_width(line, rect.width, ""))
118            } else {
119                None
120            };
121            let source = truncated.as_deref().unwrap_or(line);
122            let vw = crate::utils::visible_width(source);
123            let end = col + vw;
124            let target_vw_after = crate::utils::visible_width(target_line);
125            if end > target_vw_after {
126                target_line.push_str(&" ".repeat(end - target_vw_after));
127            }
128            let mut start_byte = crate::utils::byte_index_at_visual_pos(target_line, col);
129            let end_byte = crate::utils::byte_index_at_visual_pos(target_line, end);
130            // Preserve ANSI reset codes (\x1b[0m) at the start boundary so
131            // background colours don't bleed into the next component.
132            if target_line.as_bytes().get(start_byte) == Some(&b'\x1b') &&
133                target_line[start_byte..].starts_with("\x1b[0m")
134            {
135                start_byte = (start_byte + "\x1b[0m".len()).min(end_byte);
136            }
137            target_line.replace_range(start_byte..end_byte, source);
138        }
139        if let Some((r, c)) = self.cursor {
140            target.cursor = Some((rect.y as usize + r, rect.x as usize + c));
141        }
142        target.images.extend(self.images.clone());
143    }
144}
145
146use std::io;
147
148use crate::{
149    layout::Rect,
150    terminal::Terminal,
151};
152
153impl Renderer {
154    /// Write the rendered output to the terminal using the current strategy.
155    ///
156    /// This implementation closely follows the original TypeScript TUI
157    /// renderer:
158    /// - FirstRender: outputs all lines without clearing (assumes clean
159    ///   alternate screen).
160    /// - FullRedraw: clears screen + scrollback, then outputs all lines.
161    /// - Diff: computes first/last changed line, moves cursor there, and only
162    ///   rewrites the changed region using `\x1b[2K` per line.
163    pub fn render(&mut self, term: &mut dyn Terminal, rendered: &Rendered) -> io::Result<()> {
164        match self.strategy {
165            | RenderStrategy::FirstRender => {
166                let mut buffer = String::from("\x1b[?2026h\x1b[0m\x1b[2J\x1b[H");
167                for (i, line) in rendered.lines.iter().enumerate() {
168                    if i > 0 {
169                        buffer.push_str("\r\n");
170                    }
171                    buffer.push_str(line);
172                }
173                buffer.push_str("\x1b[?2026l");
174                term.write(&buffer)?;
175            },
176            | RenderStrategy::FullRedraw => {
177                let mut buffer = String::from("\x1b[?2026h\x1b[0m\x1b[2J\x1b[H\x1b[3J");
178                for (i, line) in rendered.lines.iter().enumerate() {
179                    if i > 0 {
180                        buffer.push_str("\r\n");
181                    }
182                    buffer.push_str(line);
183                }
184                buffer.push_str("\x1b[?2026l");
185                term.write(&buffer)?;
186            },
187            | RenderStrategy::Diff => {
188                if let Some(ref prev) = self.previous {
189                    let mut first_diff: Option<usize> = None;
190                    let mut last_diff: usize = 0;
191                    let max_lines = prev.lines.len().max(rendered.lines.len());
192                    for i in 0..max_lines {
193                        let old = prev.lines.get(i).map(|s| s.as_str()).unwrap_or("");
194                        let new = rendered.lines.get(i).map(|s| s.as_str()).unwrap_or("");
195                        if old != new {
196                            if first_diff.is_none() {
197                                first_diff = Some(i);
198                            }
199                            last_diff = i;
200                        }
201                    }
202
203                    // All changes are in deleted lines (nothing new to render, just clear)
204                    if first_diff.map_or(false, |f| f >= rendered.lines.len()) {
205                        if prev.lines.len() > rendered.lines.len() {
206                            let mut buffer = String::from("\x1b[?2026h");
207                            let target_row = rendered.lines.len().saturating_sub(1);
208                            if target_row > 0 {
209                                buffer.push_str(&format!("\x1b[{};1H", target_row + 1));
210                            }
211                            buffer.push('\r');
212                            let extra = prev.lines.len() - rendered.lines.len();
213                            if extra > 0 {
214                                buffer.push_str("\x1b[1B");
215                            }
216                            for i in 0..extra {
217                                buffer.push_str("\r\x1b[0m\x1b[2K");
218                                if i < extra - 1 {
219                                    buffer.push_str("\x1b[1B");
220                                }
221                            }
222                            if extra > 0 {
223                                buffer.push_str(&format!("\x1b[{}A", extra));
224                            }
225                            buffer.push_str("\x1b[?2026l");
226                            term.write(&buffer)?;
227                        }
228                    } else if let Some(start) = first_diff {
229                        let mut buffer = String::from("\x1b[?2026h");
230                        // Move cursor to first changed line (1-indexed row, col 1)
231                        buffer.push_str(&format!("\x1b[{};1H", start + 1));
232                        // Carriage return to column 0
233                        buffer.push('\r');
234
235                        let render_end = last_diff.min(rendered.lines.len().saturating_sub(1));
236                        for i in start..=render_end {
237                            if i > start {
238                                buffer.push_str("\r\n");
239                            }
240                            buffer.push_str("\x1b[0m\x1b[2K");
241                            buffer.push_str(&rendered.lines[i]);
242                        }
243
244                        // Previous frame had more lines: clear the extra ones
245                        if prev.lines.len() > rendered.lines.len() {
246                            let extra = prev.lines.len() - rendered.lines.len();
247                            for _ in 0..extra {
248                                buffer.push_str("\r\n\x1b[0m\x1b[2K");
249                            }
250                            // Move cursor back to end of new content
251                            if extra > 0 {
252                                buffer.push_str(&format!("\x1b[{}A", extra));
253                            }
254                        }
255
256                        buffer.push_str("\x1b[?2026l");
257                        term.write(&buffer)?;
258                    }
259                } else {
260                    // No previous frame but Diff strategy: treat as first render
261                    let mut buffer = String::from("\x1b[?2026h");
262                    for (i, line) in rendered.lines.iter().enumerate() {
263                        if i > 0 {
264                            buffer.push_str("\r\n");
265                        }
266                        buffer.push_str(line);
267                    }
268                    buffer.push_str("\x1b[?2026l");
269                    term.write(&buffer)?;
270                }
271            },
272        }
273
274        if let Some((row, col)) = rendered.cursor {
275            term.move_cursor(row as u16, col as u16)?;
276        }
277
278        self.previous = Some(rendered.clone());
279        self.strategy = RenderStrategy::Diff;
280        Ok(())
281    }
282}
283
284/// Strategy used by [`Renderer`] to draw a frame.
285pub enum RenderStrategy {
286    /// Full draw with no previous state; clears and redraws everything.
287    FirstRender,
288    /// Force a complete screen clear and redraw.
289    FullRedraw,
290    /// Compute a minimal diff against the previous frame and only redraw
291    /// changed lines.
292    Diff,
293}
294
295/// Differential terminal renderer.
296///
297/// Tracks the previous frame to enable efficient redrawing. The strategy
298/// is automatically reset to [`Diff`](RenderStrategy::Diff) after each render.
299pub struct Renderer {
300    previous: Option<Rendered>,
301    strategy: RenderStrategy,
302}
303
304impl Renderer {
305    /// Create a new renderer with no previous frame and
306    /// [`FirstRender`](RenderStrategy::FirstRender) strategy.
307    pub fn new() -> Self {
308        Self {
309            previous: None,
310            strategy: RenderStrategy::FirstRender,
311        }
312    }
313
314    /// Override the strategy for the next render call.
315    pub fn set_strategy(&mut self, strategy: RenderStrategy) {
316        self.strategy = strategy;
317    }
318
319    /// Access the previously rendered frame, if any.
320    pub fn previous(&self) -> Option<&Rendered> {
321        self.previous.as_ref()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::terminal::TestTerminal;
329
330    #[test]
331    fn first_render_strategy() {
332        let mut term = TestTerminal::new(80, 24);
333        let mut renderer = Renderer::new();
334        let rendered = Rendered {
335            lines: vec!["hello".into()],
336            cursor: None,
337            images: vec![ImageCommand {
338                id: 1,
339                data: "img".into(),
340            }],
341        };
342        renderer.render(&mut term, &rendered).unwrap();
343        let written = term.written().join("");
344        assert!(written.contains("hello"));
345        assert!(written.contains("\x1b[?2026h"));
346        // First render clears screen and homes cursor
347        assert!(written.contains("\x1b[H"));
348        assert!(written.contains("\x1b[2J"));
349        assert!(!written.contains("\x1b[2K"));
350    }
351
352    #[test]
353    fn full_redraw_clears_screen() {
354        let mut term = TestTerminal::new(80, 24);
355        let mut renderer = Renderer::new();
356        renderer.set_strategy(RenderStrategy::FullRedraw);
357        let rendered = Rendered {
358            lines: vec!["test".into()],
359            cursor: Some((0, 1)),
360            images: Vec::new(),
361        };
362        renderer.render(&mut term, &rendered).unwrap();
363        assert!(term.cursor_moves().contains(&(0, 1)));
364        let written = term.written().join("");
365        assert!(written.contains("\x1b[2J"));
366        assert!(written.contains("\x1b[3J"));
367    }
368
369    #[test]
370    fn diff_clears_changed_lines() {
371        let mut term = TestTerminal::new(80, 24);
372        let mut renderer = Renderer::new();
373
374        // First render
375        let frame1 = Rendered {
376            lines: vec!["long old line content".into()],
377            cursor: None,
378            images: Vec::new(),
379        };
380        renderer.render(&mut term, &frame1).unwrap();
381
382        // Second render with shorter line — diff must clear old trailing chars
383        renderer.set_strategy(RenderStrategy::Diff);
384        let frame2 = Rendered {
385            lines: vec!["short".into()],
386            cursor: None,
387            images: Vec::new(),
388        };
389        renderer.render(&mut term, &frame2).unwrap();
390
391        let written = term.written().join("");
392        assert!(
393            written.contains("\x1b[2K"),
394            "diff must clear each changed line"
395        );
396    }
397
398    #[test]
399    fn diff_skips_unchanged_lines() {
400        let mut term = TestTerminal::new(80, 24);
401        let mut renderer = Renderer::new();
402
403        let frame1 = Rendered {
404            lines: vec!["a".into(), "b".into(), "c".into()],
405            cursor: None,
406            images: Vec::new(),
407        };
408        renderer.render(&mut term, &frame1).unwrap();
409
410        renderer.set_strategy(RenderStrategy::Diff);
411        let frame2 = Rendered {
412            lines: vec!["a".into(), "B".into(), "c".into()],
413            cursor: None,
414            images: Vec::new(),
415        };
416        renderer.render(&mut term, &frame2).unwrap();
417
418        let written = term.written().join("");
419        // Should move cursor to line 2 and only rewrite from there
420        assert!(
421            written.contains("\x1b[2;1H"),
422            "cursor should jump to first changed line"
423        );
424        // Should use \r (not \r\n) after positioning
425        assert!(
426            written.contains("\x1b[2;1H\r\x1b[0m\x1b[2K"),
427            "should use \\r after positioning"
428        );
429        // Should NOT rewrite line 3 (unchanged)
430        let after_line2 = written.split("\x1b[2;1H").nth(1).unwrap_or("");
431        assert!(
432            !after_line2.contains("\r\nc"),
433            "should not rewrite unchanged line 3"
434        );
435    }
436
437    #[test]
438    fn diff_no_previous_treats_as_first_render() {
439        let mut term = TestTerminal::new(80, 24);
440        let mut renderer = Renderer::new();
441        renderer.set_strategy(RenderStrategy::Diff);
442        let rendered = Rendered {
443            lines: vec!["test".into()],
444            cursor: None,
445            images: Vec::new(),
446        };
447        renderer.render(&mut term, &rendered).unwrap();
448        let written = term.written().join("");
449        // No previous frame: treat as first render, no screen clear
450        assert!(!written.contains("\x1b[2J"));
451        assert!(written.contains("test"));
452    }
453
454    #[test]
455    fn diff_clears_deleted_lines() {
456        let mut term = TestTerminal::new(80, 24);
457        let mut renderer = Renderer::new();
458
459        let frame1 = Rendered {
460            lines: vec!["a".into(), "b".into(), "c".into()],
461            cursor: None,
462            images: Vec::new(),
463        };
464        renderer.render(&mut term, &frame1).unwrap();
465
466        renderer.set_strategy(RenderStrategy::Diff);
467        let frame2 = Rendered {
468            lines: vec!["a".into()],
469            cursor: None,
470            images: Vec::new(),
471        };
472        renderer.render(&mut term, &frame2).unwrap();
473
474        let written = term.written().join("");
475        // Should clear the 2 extra lines from previous frame
476        assert!(written.contains("\x1b[2K"), "should clear deleted lines");
477    }
478
479    #[test]
480    fn blit_onto_with_images() {
481        let mut target = Rendered {
482            lines: vec!["hello world".into()],
483            cursor: None,
484            images: Vec::new(),
485        };
486        let source = Rendered {
487            lines: vec!["XY".into()],
488            cursor: Some((0, 1)),
489            images: vec![ImageCommand {
490                id: 1,
491                data: "img".into(),
492            }],
493        };
494        source.blit_onto(&mut target, 0, 6);
495        assert_eq!(target.images.len(), 1);
496    }
497
498    #[test]
499    fn blit_into_rect_basic() {
500        let mut target = Rendered {
501            lines: vec!["hello world".into(), "second line".into()],
502            cursor: None,
503            images: Vec::new(),
504        };
505        let source = Rendered {
506            lines: vec!["XY".into(), "Z".into()],
507            cursor: Some((0, 1)),
508            images: vec![ImageCommand {
509                id: 1,
510                data: "img".into(),
511            }],
512        };
513        source.blit_into_rect(&mut target, Rect::new(6, 0, 10, 2));
514        assert_eq!(target.lines[0], "hello XYrld");
515        assert_eq!(target.lines[1], "secondZline");
516        assert_eq!(target.cursor, Some((0, 7)));
517        assert_eq!(target.images.len(), 1);
518    }
519
520    #[test]
521    fn blit_into_rect_clips_height() {
522        let mut target = Rendered {
523            lines: vec!["aaaaaaaaaa".into()],
524            cursor: None,
525            images: Vec::new(),
526        };
527        let source = Rendered {
528            lines: vec!["1".into(), "2".into(), "3".into()],
529            cursor: None,
530            images: Vec::new(),
531        };
532        source.blit_into_rect(&mut target, Rect::new(0, 0, 10, 1));
533        assert_eq!(target.lines[0], "1aaaaaaaaa");
534        assert_eq!(target.lines.len(), 1);
535    }
536
537    #[test]
538    fn blit_into_rect_clips_width() {
539        let mut target = Rendered {
540            lines: vec!["aaaaaaaaaa".into()],
541            cursor: None,
542            images: Vec::new(),
543        };
544        let source = Rendered {
545            lines: vec!["1234567890ABCDEF".into()],
546            cursor: None,
547            images: Vec::new(),
548        };
549        source.blit_into_rect(&mut target, Rect::new(0, 0, 5, 1));
550        assert_eq!(target.lines[0], "12345aaaaa");
551    }
552
553    #[test]
554    fn blit_into_rect_pads_short_target() {
555        let mut target = Rendered {
556            lines: vec!["hi".into()],
557            cursor: None,
558            images: Vec::new(),
559        };
560        let source = Rendered {
561            lines: vec!["XY".into()],
562            cursor: None,
563            images: Vec::new(),
564        };
565        source.blit_into_rect(&mut target, Rect::new(5, 0, 10, 1));
566        assert_eq!(target.lines[0], "hi   XY");
567    }
568
569    /// Regression: blit_into_rect must use visible width, not byte length,
570    /// so ANSI-coded lines aren't incorrectly truncated.
571    #[test]
572    fn blit_into_rect_preserves_ansi_reset() {
573        let mut target = Rendered::empty();
574        // 10 visible chars but 19 bytes (9 ANSI + 10 text + reset)
575        let source = Rendered {
576            lines: vec!["\x1b[44mhello     \x1b[0m".into()],
577            cursor: None,
578            images: Vec::new(),
579        };
580        source.blit_into_rect(&mut target, Rect::new(0, 0, 10, 1));
581        // Must NOT truncate the \x1b[0m reset
582        assert!(
583            target.lines[0].contains("\x1b[0m"),
584            "reset code should survive blit"
585        );
586        // Visible width should be exactly 10
587        assert_eq!(crate::utils::visible_width(&target.lines[0]), 10);
588    }
589
590    /// Regression: blit_into_rect must not panic when target contains ANSI
591    /// codes.
592    #[test]
593    fn blit_into_rect_ansi_target() {
594        let mut target = Rendered {
595            lines: vec!["\x1b[31mred text here\x1b[0m".into()],
596            cursor: None,
597            images: Vec::new(),
598        };
599        let source = Rendered {
600            lines: vec!["XY".into()],
601            cursor: None,
602            images: Vec::new(),
603        };
604        // Blit at visual position 4 — byte index would be inside the ANSI prefix
605        source.blit_into_rect(&mut target, Rect::new(4, 0, 10, 1));
606        assert!(target.lines[0].contains("XY"));
607        assert_eq!(crate::utils::visible_width(&target.lines[0]), 13);
608    }
609
610    /// Regression: blit_into_rect must preserve ANSI reset codes at the start
611    /// boundary so background colours don't bleed into adjacent components.
612    #[test]
613    fn blit_into_rect_preserves_ansi_reset_at_boundary() {
614        let mut target = Rendered::empty();
615        // Blue background spanning visual columns 0–7
616        let blue_box = Rendered {
617            lines: vec!["\x1b[44m        \x1b[0m".into()],
618            cursor: None,
619            images: Vec::new(),
620        };
621        blue_box.blit_into_rect(&mut target, Rect::new(0, 0, 8, 1));
622
623        // Plain text blitted immediately after the blue box (column 8)
624        let text = Rendered {
625            lines: vec!["hello".into()],
626            cursor: None,
627            images: Vec::new(),
628        };
629        text.blit_into_rect(&mut target, Rect::new(8, 0, 5, 1));
630
631        // The reset code must survive so "hello" doesn't pick up the blue bg
632        assert!(
633            target.lines[0].contains("\x1b[0mhello"),
634            "reset should be preserved before hello: {}",
635            target.lines[0]
636        );
637        assert_eq!(crate::utils::visible_width(&target.lines[0]), 13);
638    }
639
640    /// Regression: blit_onto must not panic when target contains ANSI codes.
641    #[test]
642    fn blit_onto_ansi_target() {
643        let mut target = Rendered {
644            lines: vec!["\x1b[31mred text\x1b[0m".into()],
645            cursor: None,
646            images: Vec::new(),
647        };
648        let source = Rendered {
649            lines: vec!["XY".into()],
650            cursor: None,
651            images: Vec::new(),
652        };
653        // Overlay at visual column 4 — byte index is inside ANSI prefix
654        source.blit_onto(&mut target, 0, 4);
655        assert!(target.lines[0].contains("XY"));
656        assert_eq!(crate::utils::visible_width(&target.lines[0]), 8);
657    }
658
659    /// Regression: diff mode must reset ANSI attributes before clearing lines.
660    #[test]
661    fn diff_resets_ansi_before_clear() {
662        let mut term = TestTerminal::new(80, 24);
663        let mut renderer = Renderer::new();
664
665        let frame1 = Rendered {
666            lines: vec!["\x1b[41mred bg\x1b[0m".into()],
667            cursor: None,
668            images: Vec::new(),
669        };
670        renderer.render(&mut term, &frame1).unwrap();
671
672        renderer.set_strategy(RenderStrategy::Diff);
673        let frame2 = Rendered {
674            lines: vec!["plain".into()],
675            cursor: None,
676            images: Vec::new(),
677        };
678        renderer.render(&mut term, &frame2).unwrap();
679
680        let written = term.written().join("");
681        // Every \x1b[2K must be preceded by \x1b[0m
682        for chunk in written.split("\x1b[2K") {
683            if !chunk.is_empty() && chunk.contains("\x1b[") {
684                assert!(
685                    chunk.ends_with("\x1b[0m") || !chunk.contains("\x1b[2K"),
686                    "clear must be preceded by reset: {}",
687                    chunk
688                );
689            }
690        }
691    }
692
693    /// Regression: FirstRender must reset ANSI attributes before clearing.
694    #[test]
695    fn first_render_resets_before_clear() {
696        let mut term = TestTerminal::new(80, 24);
697        let mut renderer = Renderer::new();
698        let rendered = Rendered {
699            lines: vec!["hello".into()],
700            cursor: None,
701            images: Vec::new(),
702        };
703        renderer.render(&mut term, &rendered).unwrap();
704        let written = term.written().join("");
705        assert!(
706            written.contains("\x1b[0m\x1b[2J"),
707            "reset must precede screen clear"
708        );
709    }
710
711    /// Regression: FullRedraw must reset ANSI attributes before clearing.
712    #[test]
713    fn full_redraw_resets_before_clear() {
714        let mut term = TestTerminal::new(80, 24);
715        let mut renderer = Renderer::new();
716        renderer.set_strategy(RenderStrategy::FullRedraw);
717        let rendered = Rendered {
718            lines: vec!["hello".into()],
719            cursor: None,
720            images: Vec::new(),
721        };
722        renderer.render(&mut term, &rendered).unwrap();
723        let written = term.written().join("");
724        assert!(
725            written.contains("\x1b[0m\x1b[2J"),
726            "reset must precede screen clear"
727        );
728    }
729}