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