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::{
166    collections::HashMap,
167    io,
168};
169
170macro_rules! try_io {
171    ($expr:expr) => {
172        match $expr {
173            | Ok(v) => v,
174            | Err(e) => return Err(e),
175        }
176    };
177}
178
179use crate::{
180    layout::Rect,
181    terminal::Terminal,
182};
183
184/// Extract the Kitty placement APC sequence from a full transmit-and-place
185/// payload. The placement command is always appended last by `encode_kitty`.
186fn extract_placement(data: &str) -> Option<String> {
187    data.rfind("\x1b_Ga=p")
188        .map(|start| data[start..].to_string())
189}
190
191fn append_images(
192    buffer: &mut String,
193    images: &[ImageCommand],
194    transmitted: &mut HashMap<u32, String>,
195) {
196    for image in images {
197        // Kitty's a=p command places the image at the current cursor position.
198        // Move the cursor to the cell where the component requested the image
199        // so placement stays stable across diff renders.
200        buffer.push_str(&format!("\x1b[{};{}H", image.row + 1, image.col + 1));
201        // Only transmit the payload once per id; subsequent frames just place
202        // the existing image. This avoids re-compositing artifacts that can
203        // make the display look washed out.
204        if let Some(placement) = transmitted.get(&image.id) {
205            buffer.push_str(placement);
206        } else {
207            buffer.push_str(&image.data);
208            if let Some(placement) = extract_placement(&image.data) {
209                transmitted.insert(image.id, placement);
210            }
211        }
212    }
213}
214
215impl Renderer {
216    /// Write the rendered output to the terminal using the current strategy.
217    ///
218    /// This implementation closely follows the original TypeScript TUI
219    /// renderer:
220    /// - FirstRender: outputs all lines without clearing (assumes clean
221    ///   alternate screen).
222    /// - FullRedraw: clears screen + scrollback, then outputs all lines.
223    /// - Diff: computes first/last changed line, moves cursor there, and only
224    ///   rewrites the changed region using `\x1b[2K` per line.
225    pub fn render(&mut self, term: &mut dyn Terminal, rendered: &Rendered) -> io::Result<()> {
226        match self.strategy {
227            | RenderStrategy::FirstRender => {
228                let mut buffer = String::from("\x1b[?2026h\x1b[22m\x1b[0m\x1b[2J\x1b[H");
229                for (i, line) in rendered.lines.iter().enumerate() {
230                    if i > 0 {
231                        buffer.push_str("\r\n");
232                    }
233                    buffer.push_str(line);
234                }
235                append_images(&mut buffer, &rendered.images, &mut self.transmitted_images);
236                buffer.push_str("\x1b[?2026l");
237                try_io!(term.write(&buffer));
238            },
239            | RenderStrategy::FullRedraw => {
240                // After a full redraw the terminal may have cleared image
241                // placements, so re-transmit on the next appearance.
242                self.transmitted_images.clear();
243                let mut buffer = String::from("\x1b[?2026h\x1b[22m\x1b[0m\x1b[2J\x1b[H\x1b[3J");
244                for (i, line) in rendered.lines.iter().enumerate() {
245                    if i > 0 {
246                        buffer.push_str("\r\n");
247                    }
248                    buffer.push_str(line);
249                }
250                append_images(&mut buffer, &rendered.images, &mut self.transmitted_images);
251                buffer.push_str("\x1b[?2026l");
252                try_io!(term.write(&buffer));
253            },
254            | RenderStrategy::Diff => {
255                if let Some(ref prev) = self.previous {
256                    let mut first_diff: Option<usize> = None;
257                    let mut last_diff: usize = 0;
258                    let max_lines = prev.lines.len().max(rendered.lines.len());
259                    for i in 0..max_lines {
260                        let old = prev.lines.get(i).map(|s| s.as_str()).unwrap_or("");
261                        let new = rendered.lines.get(i).map(|s| s.as_str()).unwrap_or("");
262                        if old != new {
263                            if first_diff.is_none() {
264                                first_diff = Some(i);
265                            }
266                            last_diff = i;
267                        }
268                    }
269                    let images_changed = prev.images != rendered.images;
270
271                    // All changes are in deleted lines (nothing new to render, just clear)
272                    if first_diff.is_some_and(|f| f >= rendered.lines.len()) {
273                        if prev.lines.len() > rendered.lines.len() {
274                            let mut buffer = String::from("\x1b[?2026h");
275                            let target_row = rendered.lines.len().saturating_sub(1);
276                            if target_row > 0 {
277                                buffer.push_str(&format!("\x1b[{};1H", target_row + 1));
278                            }
279                            buffer.push('\r');
280                            let extra = prev.lines.len() - rendered.lines.len();
281                            if extra > 0 {
282                                buffer.push_str("\x1b[1B");
283                            }
284                            for i in 0..extra {
285                                buffer.push_str("\r\x1b[22m\x1b[0m\x1b[2K");
286                                if i < extra - 1 {
287                                    buffer.push_str("\x1b[1B");
288                                }
289                            }
290                            if extra > 0 {
291                                buffer.push_str(&format!("\x1b[{}A", extra));
292                            }
293                            append_images(
294                                &mut buffer,
295                                &rendered.images,
296                                &mut self.transmitted_images,
297                            );
298                            buffer.push_str("\x1b[?2026l");
299                            try_io!(term.write(&buffer));
300                        }
301                    } else if let Some(start) = first_diff {
302                        let mut buffer = String::from("\x1b[?2026h");
303                        // Move cursor to first changed line (1-indexed row, col 1)
304                        buffer.push_str(&format!("\x1b[{};1H", start + 1));
305                        // Carriage return to column 0
306                        buffer.push('\r');
307
308                        let render_end = last_diff.min(rendered.lines.len().saturating_sub(1));
309                        for i in start..=render_end {
310                            if i > start {
311                                buffer.push_str("\r\n");
312                            }
313                            buffer.push_str("\x1b[22m\x1b[0m\x1b[2K");
314                            buffer.push_str(&rendered.lines[i]);
315                        }
316
317                        // Previous frame had more lines: clear the extra ones
318                        if prev.lines.len() > rendered.lines.len() {
319                            let extra = prev.lines.len() - rendered.lines.len();
320                            for _ in 0..extra {
321                                buffer.push_str("\r\n\x1b[22m\x1b[0m\x1b[2K");
322                            }
323                            // Move cursor back to end of new content
324                            if extra > 0 {
325                                buffer.push_str(&format!("\x1b[{}A", extra));
326                            }
327                        }
328
329                        append_images(&mut buffer, &rendered.images, &mut self.transmitted_images);
330                        buffer.push_str("\x1b[?2026l");
331                        try_io!(term.write(&buffer));
332                    } else if images_changed {
333                        let mut buffer = String::from("\x1b[?2026h");
334                        append_images(&mut buffer, &rendered.images, &mut self.transmitted_images);
335                        buffer.push_str("\x1b[?2026l");
336                        try_io!(term.write(&buffer));
337                    }
338                } else {
339                    // No previous frame but Diff strategy: treat as first render
340                    let mut buffer = String::from("\x1b[?2026h");
341                    for (i, line) in rendered.lines.iter().enumerate() {
342                        if i > 0 {
343                            buffer.push_str("\r\n");
344                        }
345                        buffer.push_str(line);
346                    }
347                    append_images(&mut buffer, &rendered.images, &mut self.transmitted_images);
348                    buffer.push_str("\x1b[?2026l");
349                    try_io!(term.write(&buffer));
350                }
351            },
352        }
353
354        if let Some((row, col)) = rendered.cursor {
355            try_io!(term.move_cursor(row as u16, col as u16));
356        }
357
358        self.previous = Some(rendered.clone());
359        self.strategy = RenderStrategy::Diff;
360        Ok(())
361    }
362}
363
364/// Strategy used by [`Renderer`] to draw a frame.
365#[derive(Default)]
366pub enum RenderStrategy {
367    /// Full draw with no previous state; clears and redraws everything.
368    #[default]
369    FirstRender,
370    /// Force a complete screen clear and redraw.
371    FullRedraw,
372    /// Compute a minimal diff against the previous frame and only redraw
373    /// changed lines.
374    Diff,
375}
376
377/// Differential terminal renderer.
378///
379/// Tracks the previous frame to enable efficient redrawing. The strategy
380/// is automatically reset to [`Diff`](RenderStrategy::Diff) after each render.
381#[derive(Default)]
382pub struct Renderer {
383    previous: Option<Rendered>,
384    strategy: RenderStrategy,
385    transmitted_images: HashMap<u32, String>,
386}
387
388impl Renderer {
389    /// Create a new renderer with no previous frame and
390    /// [`FirstRender`](RenderStrategy::FirstRender) strategy.
391    pub fn new() -> Self {
392        Self {
393            previous: None,
394            strategy: RenderStrategy::FirstRender,
395            transmitted_images: HashMap::new(),
396        }
397    }
398
399    /// Override the strategy for the next render call.
400    pub fn set_strategy(&mut self, strategy: RenderStrategy) {
401        self.strategy = strategy;
402    }
403
404    /// Access the previously rendered frame, if any.
405    pub fn previous(&self) -> Option<&Rendered> {
406        self.previous.as_ref()
407    }
408
409    /// Forget a previously-transmitted image id.
410    ///
411    /// Call this when the image is deleted from the terminal so that a future
412    /// image with the same id will be re-transmitted.
413    pub fn forget_image(&mut self, id: u32) {
414        self.transmitted_images.remove(&id);
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::terminal::TestTerminal;
422
423    #[test]
424    fn first_render_strategy() {
425        let mut term = TestTerminal::new(80, 24);
426        let mut renderer = Renderer::new();
427        let rendered = Rendered {
428            lines: vec!["hello".into()],
429            cursor: None,
430            images: vec![ImageCommand {
431                id: 1,
432                data: "img".into(),
433                row: 0,
434                col: 0,
435            }],
436        };
437        renderer.render(&mut term, &rendered).unwrap();
438        let written = term.written().join("");
439        assert!(written.contains("hello"));
440        assert!(written.contains("img"));
441        assert!(written.contains("\x1b[?2026h"));
442        // First render clears screen and homes cursor
443        assert!(written.contains("\x1b[H"));
444        assert!(written.contains("\x1b[2J"));
445        assert!(!written.contains("\x1b[2K"));
446    }
447
448    #[test]
449    fn full_redraw_clears_screen() {
450        let mut term = TestTerminal::new(80, 24);
451        let mut renderer = Renderer::new();
452        renderer.set_strategy(RenderStrategy::FullRedraw);
453        let rendered = Rendered {
454            lines: vec!["test".into()],
455            cursor: Some((0, 1)),
456            images: Vec::new(),
457        };
458        renderer.render(&mut term, &rendered).unwrap();
459        assert!(term.cursor_moves().contains(&(0, 1)));
460        let written = term.written().join("");
461        assert!(written.contains("\x1b[2J"));
462        assert!(written.contains("\x1b[3J"));
463    }
464
465    #[test]
466    fn diff_clears_changed_lines() {
467        let mut term = TestTerminal::new(80, 24);
468        let mut renderer = Renderer::new();
469
470        // First render
471        let frame1 = Rendered {
472            lines: vec!["long old line content".into()],
473            cursor: None,
474            images: Vec::new(),
475        };
476        renderer.render(&mut term, &frame1).unwrap();
477
478        // Second render with shorter line — diff must clear old trailing chars
479        renderer.set_strategy(RenderStrategy::Diff);
480        let frame2 = Rendered {
481            lines: vec!["short".into()],
482            cursor: None,
483            images: Vec::new(),
484        };
485        renderer.render(&mut term, &frame2).unwrap();
486
487        let written = term.written().join("");
488        assert!(
489            written.contains("\x1b[2K"),
490            "diff must clear each changed line"
491        );
492    }
493
494    #[test]
495    fn diff_skips_unchanged_lines() {
496        let mut term = TestTerminal::new(80, 24);
497        let mut renderer = Renderer::new();
498
499        let frame1 = Rendered {
500            lines: vec!["a".into(), "b".into(), "c".into()],
501            cursor: None,
502            images: Vec::new(),
503        };
504        renderer.render(&mut term, &frame1).unwrap();
505
506        renderer.set_strategy(RenderStrategy::Diff);
507        let frame2 = Rendered {
508            lines: vec!["a".into(), "B".into(), "c".into()],
509            cursor: None,
510            images: Vec::new(),
511        };
512        renderer.render(&mut term, &frame2).unwrap();
513
514        let written = term.written().join("");
515        // Should move cursor to line 2 and only rewrite from there
516        assert!(
517            written.contains("\x1b[2;1H"),
518            "cursor should jump to first changed line"
519        );
520        // Should use \r (not \r\n) after positioning
521        assert!(
522            written.contains("\x1b[2;1H\r\x1b[22m\x1b[0m\x1b[2K"),
523            "should use \\r after positioning"
524        );
525        // Should NOT rewrite line 3 (unchanged)
526        let after_line2 = written.split("\x1b[2;1H").nth(1).unwrap_or("");
527        assert!(
528            !after_line2.contains("\r\nc"),
529            "should not rewrite unchanged line 3"
530        );
531    }
532
533    #[test]
534    fn diff_no_previous_treats_as_first_render() {
535        let mut term = TestTerminal::new(80, 24);
536        let mut renderer = Renderer::new();
537        renderer.set_strategy(RenderStrategy::Diff);
538        let rendered = Rendered {
539            lines: vec!["test".into()],
540            cursor: None,
541            images: Vec::new(),
542        };
543        renderer.render(&mut term, &rendered).unwrap();
544        let written = term.written().join("");
545        // No previous frame: treat as first render, no screen clear
546        assert!(!written.contains("\x1b[2J"));
547        assert!(written.contains("test"));
548    }
549
550    #[test]
551    fn diff_clears_deleted_lines() {
552        let mut term = TestTerminal::new(80, 24);
553        let mut renderer = Renderer::new();
554
555        let frame1 = Rendered {
556            lines: vec!["a".into(), "b".into(), "c".into()],
557            cursor: None,
558            images: Vec::new(),
559        };
560        renderer.render(&mut term, &frame1).unwrap();
561
562        renderer.set_strategy(RenderStrategy::Diff);
563        let frame2 = Rendered {
564            lines: vec!["a".into()],
565            cursor: None,
566            images: Vec::new(),
567        };
568        renderer.render(&mut term, &frame2).unwrap();
569
570        let written = term.written().join("");
571        // Should clear the 2 extra lines from previous frame
572        assert!(written.contains("\x1b[2K"), "should clear deleted lines");
573    }
574
575    #[test]
576    fn diff_emits_image_commands() {
577        let mut term = TestTerminal::new(80, 24);
578        let mut renderer = Renderer::new();
579
580        let frame1 = Rendered {
581            lines: vec!["a".into()],
582            cursor: None,
583            images: Vec::new(),
584        };
585        renderer.render(&mut term, &frame1).unwrap();
586
587        renderer.set_strategy(RenderStrategy::Diff);
588        let frame2 = Rendered {
589            lines: vec!["a".into()],
590            cursor: None,
591            images: vec![ImageCommand {
592                id: 5,
593                data: "img".into(),
594                row: 0,
595                col: 0,
596            }],
597        };
598        renderer.render(&mut term, &frame2).unwrap();
599
600        let written = term.written().join("");
601        assert!(written.contains("img"), "diff must emit image commands");
602    }
603
604    #[test]
605    fn diff_reuses_transmitted_image_without_payload() {
606        let mut term = TestTerminal::new(80, 24);
607        let mut renderer = Renderer::new();
608
609        let payload = "\x1b_Ga=t,f=100,i=9,q=1,m=0;DATA\x1b\\\x1b_Ga=p,i=9,c=2,r=1,q=1\x1b\\";
610        let frame1 = Rendered {
611            lines: vec!["a".into()],
612            cursor: None,
613            images: vec![ImageCommand {
614                id: 9,
615                data: payload.into(),
616                row: 0,
617                col: 0,
618            }],
619        };
620        renderer.render(&mut term, &frame1).unwrap();
621        let first = term.written().last().map(String::as_str).unwrap_or("");
622        assert!(first.contains("a=t"));
623        assert!(first.contains("a=p"));
624
625        renderer.set_strategy(RenderStrategy::Diff);
626        let frame2 = Rendered {
627            lines: vec!["b".into()],
628            cursor: None,
629            images: vec![ImageCommand {
630                id: 9,
631                data: payload.into(),
632                row: 0,
633                col: 0,
634            }],
635        };
636        renderer.render(&mut term, &frame2).unwrap();
637        let second = term.written().last().map(String::as_str).unwrap_or("");
638        assert!(
639            !second.contains("a=t"),
640            "image payload should not be re-transmitted on subsequent frames"
641        );
642        assert!(
643            second.contains("a=p"),
644            "image placement should still be emitted"
645        );
646    }
647
648    #[test]
649    fn blit_onto_with_images() {
650        let mut target = Rendered {
651            lines: vec!["hello world".into()],
652            cursor: None,
653            images: Vec::new(),
654        };
655        let source = Rendered {
656            lines: vec!["XY".into()],
657            cursor: Some((0, 1)),
658            images: vec![ImageCommand {
659                id: 1,
660                data: "img".into(),
661                row: 0,
662                col: 0,
663            }],
664        };
665        source.blit_onto(&mut target, 0, 6);
666        assert_eq!(target.images.len(), 1);
667        assert_eq!(target.images[0].row, 0);
668        assert_eq!(target.images[0].col, 6);
669    }
670
671    #[test]
672    fn blit_into_rect_basic() {
673        let mut target = Rendered {
674            lines: vec!["hello world".into(), "second line".into()],
675            cursor: None,
676            images: Vec::new(),
677        };
678        let source = Rendered {
679            lines: vec!["XY".into(), "Z".into()],
680            cursor: Some((0, 1)),
681            images: vec![ImageCommand {
682                id: 1,
683                data: "img".into(),
684                row: 0,
685                col: 0,
686            }],
687        };
688        source.blit_into_rect(&mut target, Rect::new(6, 0, 10, 2));
689        assert_eq!(target.lines[0], "hello XYrld");
690        assert_eq!(target.lines[1], "secondZline");
691        assert_eq!(target.cursor, Some((0, 7)));
692        assert_eq!(target.images.len(), 1);
693        assert_eq!(target.images[0].row, 0);
694        assert_eq!(target.images[0].col, 6);
695    }
696
697    #[test]
698    fn blit_into_rect_clips_height() {
699        let mut target = Rendered {
700            lines: vec!["aaaaaaaaaa".into()],
701            cursor: None,
702            images: Vec::new(),
703        };
704        let source = Rendered {
705            lines: vec!["1".into(), "2".into(), "3".into()],
706            cursor: None,
707            images: Vec::new(),
708        };
709        source.blit_into_rect(&mut target, Rect::new(0, 0, 10, 1));
710        assert_eq!(target.lines[0], "1aaaaaaaaa");
711        assert_eq!(target.lines.len(), 1);
712    }
713
714    #[test]
715    fn blit_into_rect_clips_width() {
716        let mut target = Rendered {
717            lines: vec!["aaaaaaaaaa".into()],
718            cursor: None,
719            images: Vec::new(),
720        };
721        let source = Rendered {
722            lines: vec!["1234567890ABCDEF".into()],
723            cursor: None,
724            images: Vec::new(),
725        };
726        source.blit_into_rect(&mut target, Rect::new(0, 0, 5, 1));
727        assert_eq!(target.lines[0], "12345aaaaa");
728    }
729
730    #[test]
731    fn blit_into_rect_pads_short_target() {
732        let mut target = Rendered {
733            lines: vec!["hi".into()],
734            cursor: None,
735            images: Vec::new(),
736        };
737        let source = Rendered {
738            lines: vec!["XY".into()],
739            cursor: None,
740            images: Vec::new(),
741        };
742        source.blit_into_rect(&mut target, Rect::new(5, 0, 10, 1));
743        assert_eq!(target.lines[0], "hi   XY");
744    }
745
746    /// Regression: blit_into_rect must use visible width, not byte length,
747    /// so ANSI-coded lines aren't incorrectly truncated.
748    #[test]
749    fn blit_into_rect_preserves_ansi_reset() {
750        let mut target = Rendered::empty();
751        // 10 visible chars but 19 bytes (9 ANSI + 10 text + reset)
752        let source = Rendered {
753            lines: vec!["\x1b[44mhello     \x1b[0m".into()],
754            cursor: None,
755            images: Vec::new(),
756        };
757        source.blit_into_rect(&mut target, Rect::new(0, 0, 10, 1));
758        // Must NOT truncate the \x1b[0m reset
759        assert!(
760            target.lines[0].contains("\x1b[0m"),
761            "reset code should survive blit"
762        );
763        // Visible width should be exactly 10
764        assert_eq!(crate::utils::visible_width(&target.lines[0]), 10);
765    }
766
767    /// Regression: blit_into_rect must not panic when target contains ANSI
768    /// codes.
769    #[test]
770    fn blit_into_rect_ansi_target() {
771        let mut target = Rendered {
772            lines: vec!["\x1b[31mred text here\x1b[0m".into()],
773            cursor: None,
774            images: Vec::new(),
775        };
776        let source = Rendered {
777            lines: vec!["XY".into()],
778            cursor: None,
779            images: Vec::new(),
780        };
781        // Blit at visual position 4 — byte index would be inside the ANSI prefix
782        source.blit_into_rect(&mut target, Rect::new(4, 0, 10, 1));
783        assert!(target.lines[0].contains("XY"));
784        assert_eq!(crate::utils::visible_width(&target.lines[0]), 13);
785    }
786
787    /// Regression: blit_into_rect must preserve ANSI reset codes at the start
788    /// boundary so background colours don't bleed into adjacent components.
789    #[test]
790    fn blit_into_rect_preserves_ansi_reset_at_boundary() {
791        let mut target = Rendered::empty();
792        // Blue background spanning visual columns 0–7
793        let blue_box = Rendered {
794            lines: vec!["\x1b[44m        \x1b[0m".into()],
795            cursor: None,
796            images: Vec::new(),
797        };
798        blue_box.blit_into_rect(&mut target, Rect::new(0, 0, 8, 1));
799
800        // Plain text blitted immediately after the blue box (column 8)
801        let text = Rendered {
802            lines: vec!["hello".into()],
803            cursor: None,
804            images: Vec::new(),
805        };
806        text.blit_into_rect(&mut target, Rect::new(8, 0, 5, 1));
807
808        // The reset code must survive so "hello" doesn't pick up the blue bg
809        assert!(
810            target.lines[0].contains("\x1b[0mhello"),
811            "reset should be preserved before hello: {}",
812            target.lines[0]
813        );
814        assert_eq!(crate::utils::visible_width(&target.lines[0]), 13);
815    }
816
817    /// Regression: blit_onto must not panic when target contains ANSI codes.
818    #[test]
819    fn blit_onto_ansi_target() {
820        let mut target = Rendered {
821            lines: vec!["\x1b[31mred text\x1b[0m".into()],
822            cursor: None,
823            images: Vec::new(),
824        };
825        let source = Rendered {
826            lines: vec!["XY".into()],
827            cursor: None,
828            images: Vec::new(),
829        };
830        // Overlay at visual column 4 — byte index is inside ANSI prefix
831        source.blit_onto(&mut target, 0, 4);
832        assert!(target.lines[0].contains("XY"));
833        assert_eq!(crate::utils::visible_width(&target.lines[0]), 8);
834    }
835
836    /// Regression: diff mode must reset ANSI attributes before clearing lines.
837    #[test]
838    fn diff_resets_ansi_before_clear() {
839        let mut term = TestTerminal::new(80, 24);
840        let mut renderer = Renderer::new();
841
842        let frame1 = Rendered {
843            lines: vec!["\x1b[41mred bg\x1b[0m".into()],
844            cursor: None,
845            images: Vec::new(),
846        };
847        renderer.render(&mut term, &frame1).unwrap();
848
849        renderer.set_strategy(RenderStrategy::Diff);
850        let frame2 = Rendered {
851            lines: vec!["plain".into()],
852            cursor: None,
853            images: Vec::new(),
854        };
855        renderer.render(&mut term, &frame2).unwrap();
856
857        let written = term.written().join("");
858        // Every \x1b[2K must be preceded by \x1b[22m\x1b[0m
859        for chunk in written.split("\x1b[2K") {
860            if !chunk.is_empty() && chunk.contains("\x1b[") {
861                assert!(
862                    chunk.ends_with("\x1b[22m\x1b[0m") || !chunk.contains("\x1b[2K"),
863                    "clear must be preceded by reset: {}",
864                    chunk
865                );
866            }
867        }
868    }
869
870    /// Regression: FirstRender must reset ANSI attributes before clearing.
871    #[test]
872    fn first_render_resets_before_clear() {
873        let mut term = TestTerminal::new(80, 24);
874        let mut renderer = Renderer::new();
875        let rendered = Rendered {
876            lines: vec!["hello".into()],
877            cursor: None,
878            images: Vec::new(),
879        };
880        renderer.render(&mut term, &rendered).unwrap();
881        let written = term.written().join("");
882        assert!(
883            written.contains("\x1b[22m\x1b[0m\x1b[2J"),
884            "reset must precede screen clear"
885        );
886    }
887
888    /// Regression: FullRedraw must reset ANSI attributes before clearing.
889    #[test]
890    fn full_redraw_resets_before_clear() {
891        let mut term = TestTerminal::new(80, 24);
892        let mut renderer = Renderer::new();
893        renderer.set_strategy(RenderStrategy::FullRedraw);
894        let rendered = Rendered {
895            lines: vec!["hello".into()],
896            cursor: None,
897            images: Vec::new(),
898        };
899        renderer.render(&mut term, &rendered).unwrap();
900        let written = term.written().join("");
901        assert!(
902            written.contains("\x1b[22m\x1b[0m\x1b[2J"),
903            "reset must precede screen clear"
904        );
905    }
906}