1use thiserror::Error;
2
3#[derive(Error, Debug, Clone, PartialEq)]
5pub enum RenderError {
6 #[error("width overflow: line width {actual} exceeds {width}")]
8 WidthOverflow {
9 line: String,
11 width: u16,
13 actual: usize,
15 },
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum InputResult {
21 Handled,
23 Ignored,
25 RequestRender,
27}
28
29#[derive(Debug, Clone, PartialEq)]
34pub struct Rendered {
35 pub lines: Vec<String>,
37 pub cursor: Option<(usize, usize)>,
39 pub images: Vec<ImageCommand>,
41}
42
43#[derive(Debug, Clone, PartialEq)]
49pub struct ImageCommand {
50 pub id: u32,
52 pub data: String,
54 pub row: u16,
56 pub col: u16,
58}
59
60impl Rendered {
61 pub fn empty() -> Self {
63 Self {
64 lines: Vec::new(),
65 cursor: None,
66 images: Vec::new(),
67 }
68 }
69
70 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 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 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 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 buffer.push_str(&format!("\x1b[{};{}H", image.row + 1, image.col + 1));
187 buffer.push_str(&image.data);
188 }
189}
190
191impl Renderer {
192 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 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 buffer.push_str(&format!("\x1b[{};1H", start + 1));
274 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 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 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 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#[derive(Default)]
335pub enum RenderStrategy {
336 #[default]
338 FirstRender,
339 FullRedraw,
341 Diff,
344}
345
346#[derive(Default)]
351pub struct Renderer {
352 previous: Option<Rendered>,
353 strategy: RenderStrategy,
354}
355
356impl Renderer {
357 pub fn new() -> Self {
360 Self {
361 previous: None,
362 strategy: RenderStrategy::FirstRender,
363 }
364 }
365
366 pub fn set_strategy(&mut self, strategy: RenderStrategy) {
368 self.strategy = strategy;
369 }
370
371 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 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 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 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 assert!(
476 written.contains("\x1b[2;1H"),
477 "cursor should jump to first changed line"
478 );
479 assert!(
481 written.contains("\x1b[2;1H\r\x1b[0m\x1b[2K"),
482 "should use \\r after positioning"
483 );
484 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 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 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 #[test]
664 fn blit_into_rect_preserves_ansi_reset() {
665 let mut target = Rendered::empty();
666 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 assert!(
675 target.lines[0].contains("\x1b[0m"),
676 "reset code should survive blit"
677 );
678 assert_eq!(crate::utils::visible_width(&target.lines[0]), 10);
680 }
681
682 #[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 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 #[test]
705 fn blit_into_rect_preserves_ansi_reset_at_boundary() {
706 let mut target = Rendered::empty();
707 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 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 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 #[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 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 #[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 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 #[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 #[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}