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