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)]
48pub struct ImageCommand {
49 pub id: u32,
51 pub data: String,
53}
54
55impl Rendered {
56 pub fn empty() -> Self {
58 Self {
59 lines: Vec::new(),
60 cursor: None,
61 images: Vec::new(),
62 }
63 }
64
65 pub fn blit_onto(&self, target: &mut Rendered, row: u16, col: u16) {
70 for (i, line) in self.lines.iter().enumerate() {
71 let target_row = row as usize + i;
72 if target_row >= target.lines.len() {
73 break;
74 }
75 let col_usize = col as usize;
76 let target_vw = crate::utils::visible_width(&target.lines[target_row]);
77 if target_vw < col_usize {
79 target.lines[target_row].push_str(&" ".repeat(col_usize - target_vw));
80 }
81 let source_vw = crate::utils::visible_width(line);
82 let end = col_usize + source_vw;
83 let target_vw_after = crate::utils::visible_width(&target.lines[target_row]);
84 if end > target_vw_after {
85 target.lines[target_row].push_str(&" ".repeat(end - target_vw_after));
86 }
87 let start_byte =
88 crate::utils::byte_index_at_visual_pos(&target.lines[target_row], col_usize);
89 let end_byte = crate::utils::byte_index_at_visual_pos(&target.lines[target_row], end);
90 target.lines[target_row].replace_range(start_byte..end_byte, line);
91 }
92 if let Some((r, c)) = self.cursor {
93 target.cursor = Some((row as usize + r, col as usize + c));
94 }
95 target.images.extend(self.images.clone());
96 }
97
98 pub fn blit_into_rect(&self, target: &mut Rendered, rect: Rect) {
103 for (i, line) in self.lines.iter().enumerate().take(rect.height as usize) {
104 let target_row = rect.y as usize + i;
105 if target_row >= target.lines.len() {
106 while target.lines.len() <= target_row {
107 target.lines.push(String::new());
108 }
109 }
110 let col = rect.x as usize;
111 let target_line = &mut target.lines[target_row];
112 let target_vw = crate::utils::visible_width(target_line);
113 if target_vw < col {
114 target_line.push_str(&" ".repeat(col - target_vw));
115 }
116 let truncated = if crate::utils::visible_width(line) > rect.width as usize {
117 Some(crate::utils::truncate_to_width(line, rect.width, ""))
118 } else {
119 None
120 };
121 let source = truncated.as_deref().unwrap_or(line);
122 let vw = crate::utils::visible_width(source);
123 let end = col + vw;
124 let target_vw_after = crate::utils::visible_width(target_line);
125 if end > target_vw_after {
126 target_line.push_str(&" ".repeat(end - target_vw_after));
127 }
128 let mut start_byte = crate::utils::byte_index_at_visual_pos(target_line, col);
129 let end_byte = crate::utils::byte_index_at_visual_pos(target_line, end);
130 if target_line.as_bytes().get(start_byte) == Some(&b'\x1b') &&
133 target_line[start_byte..].starts_with("\x1b[0m")
134 {
135 start_byte = (start_byte + "\x1b[0m".len()).min(end_byte);
136 }
137 target_line.replace_range(start_byte..end_byte, source);
138 }
139 if let Some((r, c)) = self.cursor {
140 target.cursor = Some((rect.y as usize + r, rect.x as usize + c));
141 }
142 target.images.extend(self.images.clone());
143 }
144}
145
146use std::io;
147
148use crate::{
149 layout::Rect,
150 terminal::Terminal,
151};
152
153impl Renderer {
154 pub fn render(&mut self, term: &mut dyn Terminal, rendered: &Rendered) -> io::Result<()> {
164 match self.strategy {
165 | RenderStrategy::FirstRender => {
166 let mut buffer = String::from("\x1b[?2026h\x1b[0m\x1b[2J\x1b[H");
167 for (i, line) in rendered.lines.iter().enumerate() {
168 if i > 0 {
169 buffer.push_str("\r\n");
170 }
171 buffer.push_str(line);
172 }
173 buffer.push_str("\x1b[?2026l");
174 term.write(&buffer)?;
175 },
176 | RenderStrategy::FullRedraw => {
177 let mut buffer = String::from("\x1b[?2026h\x1b[0m\x1b[2J\x1b[H\x1b[3J");
178 for (i, line) in rendered.lines.iter().enumerate() {
179 if i > 0 {
180 buffer.push_str("\r\n");
181 }
182 buffer.push_str(line);
183 }
184 buffer.push_str("\x1b[?2026l");
185 term.write(&buffer)?;
186 },
187 | RenderStrategy::Diff => {
188 if let Some(ref prev) = self.previous {
189 let mut first_diff: Option<usize> = None;
190 let mut last_diff: usize = 0;
191 let max_lines = prev.lines.len().max(rendered.lines.len());
192 for i in 0..max_lines {
193 let old = prev.lines.get(i).map(|s| s.as_str()).unwrap_or("");
194 let new = rendered.lines.get(i).map(|s| s.as_str()).unwrap_or("");
195 if old != new {
196 if first_diff.is_none() {
197 first_diff = Some(i);
198 }
199 last_diff = i;
200 }
201 }
202
203 if first_diff.map_or(false, |f| f >= rendered.lines.len()) {
205 if prev.lines.len() > rendered.lines.len() {
206 let mut buffer = String::from("\x1b[?2026h");
207 let target_row = rendered.lines.len().saturating_sub(1);
208 if target_row > 0 {
209 buffer.push_str(&format!("\x1b[{};1H", target_row + 1));
210 }
211 buffer.push('\r');
212 let extra = prev.lines.len() - rendered.lines.len();
213 if extra > 0 {
214 buffer.push_str("\x1b[1B");
215 }
216 for i in 0..extra {
217 buffer.push_str("\r\x1b[0m\x1b[2K");
218 if i < extra - 1 {
219 buffer.push_str("\x1b[1B");
220 }
221 }
222 if extra > 0 {
223 buffer.push_str(&format!("\x1b[{}A", extra));
224 }
225 buffer.push_str("\x1b[?2026l");
226 term.write(&buffer)?;
227 }
228 } else if let Some(start) = first_diff {
229 let mut buffer = String::from("\x1b[?2026h");
230 buffer.push_str(&format!("\x1b[{};1H", start + 1));
232 buffer.push('\r');
234
235 let render_end = last_diff.min(rendered.lines.len().saturating_sub(1));
236 for i in start..=render_end {
237 if i > start {
238 buffer.push_str("\r\n");
239 }
240 buffer.push_str("\x1b[0m\x1b[2K");
241 buffer.push_str(&rendered.lines[i]);
242 }
243
244 if prev.lines.len() > rendered.lines.len() {
246 let extra = prev.lines.len() - rendered.lines.len();
247 for _ in 0..extra {
248 buffer.push_str("\r\n\x1b[0m\x1b[2K");
249 }
250 if extra > 0 {
252 buffer.push_str(&format!("\x1b[{}A", extra));
253 }
254 }
255
256 buffer.push_str("\x1b[?2026l");
257 term.write(&buffer)?;
258 }
259 } else {
260 let mut buffer = String::from("\x1b[?2026h");
262 for (i, line) in rendered.lines.iter().enumerate() {
263 if i > 0 {
264 buffer.push_str("\r\n");
265 }
266 buffer.push_str(line);
267 }
268 buffer.push_str("\x1b[?2026l");
269 term.write(&buffer)?;
270 }
271 },
272 }
273
274 if let Some((row, col)) = rendered.cursor {
275 term.move_cursor(row as u16, col as u16)?;
276 }
277
278 self.previous = Some(rendered.clone());
279 self.strategy = RenderStrategy::Diff;
280 Ok(())
281 }
282}
283
284pub enum RenderStrategy {
286 FirstRender,
288 FullRedraw,
290 Diff,
293}
294
295pub struct Renderer {
300 previous: Option<Rendered>,
301 strategy: RenderStrategy,
302}
303
304impl Renderer {
305 pub fn new() -> Self {
308 Self {
309 previous: None,
310 strategy: RenderStrategy::FirstRender,
311 }
312 }
313
314 pub fn set_strategy(&mut self, strategy: RenderStrategy) {
316 self.strategy = strategy;
317 }
318
319 pub fn previous(&self) -> Option<&Rendered> {
321 self.previous.as_ref()
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::terminal::TestTerminal;
329
330 #[test]
331 fn first_render_strategy() {
332 let mut term = TestTerminal::new(80, 24);
333 let mut renderer = Renderer::new();
334 let rendered = Rendered {
335 lines: vec!["hello".into()],
336 cursor: None,
337 images: vec![ImageCommand {
338 id: 1,
339 data: "img".into(),
340 }],
341 };
342 renderer.render(&mut term, &rendered).unwrap();
343 let written = term.written().join("");
344 assert!(written.contains("hello"));
345 assert!(written.contains("\x1b[?2026h"));
346 assert!(written.contains("\x1b[H"));
348 assert!(written.contains("\x1b[2J"));
349 assert!(!written.contains("\x1b[2K"));
350 }
351
352 #[test]
353 fn full_redraw_clears_screen() {
354 let mut term = TestTerminal::new(80, 24);
355 let mut renderer = Renderer::new();
356 renderer.set_strategy(RenderStrategy::FullRedraw);
357 let rendered = Rendered {
358 lines: vec!["test".into()],
359 cursor: Some((0, 1)),
360 images: Vec::new(),
361 };
362 renderer.render(&mut term, &rendered).unwrap();
363 assert!(term.cursor_moves().contains(&(0, 1)));
364 let written = term.written().join("");
365 assert!(written.contains("\x1b[2J"));
366 assert!(written.contains("\x1b[3J"));
367 }
368
369 #[test]
370 fn diff_clears_changed_lines() {
371 let mut term = TestTerminal::new(80, 24);
372 let mut renderer = Renderer::new();
373
374 let frame1 = Rendered {
376 lines: vec!["long old line content".into()],
377 cursor: None,
378 images: Vec::new(),
379 };
380 renderer.render(&mut term, &frame1).unwrap();
381
382 renderer.set_strategy(RenderStrategy::Diff);
384 let frame2 = Rendered {
385 lines: vec!["short".into()],
386 cursor: None,
387 images: Vec::new(),
388 };
389 renderer.render(&mut term, &frame2).unwrap();
390
391 let written = term.written().join("");
392 assert!(
393 written.contains("\x1b[2K"),
394 "diff must clear each changed line"
395 );
396 }
397
398 #[test]
399 fn diff_skips_unchanged_lines() {
400 let mut term = TestTerminal::new(80, 24);
401 let mut renderer = Renderer::new();
402
403 let frame1 = Rendered {
404 lines: vec!["a".into(), "b".into(), "c".into()],
405 cursor: None,
406 images: Vec::new(),
407 };
408 renderer.render(&mut term, &frame1).unwrap();
409
410 renderer.set_strategy(RenderStrategy::Diff);
411 let frame2 = Rendered {
412 lines: vec!["a".into(), "B".into(), "c".into()],
413 cursor: None,
414 images: Vec::new(),
415 };
416 renderer.render(&mut term, &frame2).unwrap();
417
418 let written = term.written().join("");
419 assert!(
421 written.contains("\x1b[2;1H"),
422 "cursor should jump to first changed line"
423 );
424 assert!(
426 written.contains("\x1b[2;1H\r\x1b[0m\x1b[2K"),
427 "should use \\r after positioning"
428 );
429 let after_line2 = written.split("\x1b[2;1H").nth(1).unwrap_or("");
431 assert!(
432 !after_line2.contains("\r\nc"),
433 "should not rewrite unchanged line 3"
434 );
435 }
436
437 #[test]
438 fn diff_no_previous_treats_as_first_render() {
439 let mut term = TestTerminal::new(80, 24);
440 let mut renderer = Renderer::new();
441 renderer.set_strategy(RenderStrategy::Diff);
442 let rendered = Rendered {
443 lines: vec!["test".into()],
444 cursor: None,
445 images: Vec::new(),
446 };
447 renderer.render(&mut term, &rendered).unwrap();
448 let written = term.written().join("");
449 assert!(!written.contains("\x1b[2J"));
451 assert!(written.contains("test"));
452 }
453
454 #[test]
455 fn diff_clears_deleted_lines() {
456 let mut term = TestTerminal::new(80, 24);
457 let mut renderer = Renderer::new();
458
459 let frame1 = Rendered {
460 lines: vec!["a".into(), "b".into(), "c".into()],
461 cursor: None,
462 images: Vec::new(),
463 };
464 renderer.render(&mut term, &frame1).unwrap();
465
466 renderer.set_strategy(RenderStrategy::Diff);
467 let frame2 = Rendered {
468 lines: vec!["a".into()],
469 cursor: None,
470 images: Vec::new(),
471 };
472 renderer.render(&mut term, &frame2).unwrap();
473
474 let written = term.written().join("");
475 assert!(written.contains("\x1b[2K"), "should clear deleted lines");
477 }
478
479 #[test]
480 fn blit_onto_with_images() {
481 let mut target = Rendered {
482 lines: vec!["hello world".into()],
483 cursor: None,
484 images: Vec::new(),
485 };
486 let source = Rendered {
487 lines: vec!["XY".into()],
488 cursor: Some((0, 1)),
489 images: vec![ImageCommand {
490 id: 1,
491 data: "img".into(),
492 }],
493 };
494 source.blit_onto(&mut target, 0, 6);
495 assert_eq!(target.images.len(), 1);
496 }
497
498 #[test]
499 fn blit_into_rect_basic() {
500 let mut target = Rendered {
501 lines: vec!["hello world".into(), "second line".into()],
502 cursor: None,
503 images: Vec::new(),
504 };
505 let source = Rendered {
506 lines: vec!["XY".into(), "Z".into()],
507 cursor: Some((0, 1)),
508 images: vec![ImageCommand {
509 id: 1,
510 data: "img".into(),
511 }],
512 };
513 source.blit_into_rect(&mut target, Rect::new(6, 0, 10, 2));
514 assert_eq!(target.lines[0], "hello XYrld");
515 assert_eq!(target.lines[1], "secondZline");
516 assert_eq!(target.cursor, Some((0, 7)));
517 assert_eq!(target.images.len(), 1);
518 }
519
520 #[test]
521 fn blit_into_rect_clips_height() {
522 let mut target = Rendered {
523 lines: vec!["aaaaaaaaaa".into()],
524 cursor: None,
525 images: Vec::new(),
526 };
527 let source = Rendered {
528 lines: vec!["1".into(), "2".into(), "3".into()],
529 cursor: None,
530 images: Vec::new(),
531 };
532 source.blit_into_rect(&mut target, Rect::new(0, 0, 10, 1));
533 assert_eq!(target.lines[0], "1aaaaaaaaa");
534 assert_eq!(target.lines.len(), 1);
535 }
536
537 #[test]
538 fn blit_into_rect_clips_width() {
539 let mut target = Rendered {
540 lines: vec!["aaaaaaaaaa".into()],
541 cursor: None,
542 images: Vec::new(),
543 };
544 let source = Rendered {
545 lines: vec!["1234567890ABCDEF".into()],
546 cursor: None,
547 images: Vec::new(),
548 };
549 source.blit_into_rect(&mut target, Rect::new(0, 0, 5, 1));
550 assert_eq!(target.lines[0], "12345aaaaa");
551 }
552
553 #[test]
554 fn blit_into_rect_pads_short_target() {
555 let mut target = Rendered {
556 lines: vec!["hi".into()],
557 cursor: None,
558 images: Vec::new(),
559 };
560 let source = Rendered {
561 lines: vec!["XY".into()],
562 cursor: None,
563 images: Vec::new(),
564 };
565 source.blit_into_rect(&mut target, Rect::new(5, 0, 10, 1));
566 assert_eq!(target.lines[0], "hi XY");
567 }
568
569 #[test]
572 fn blit_into_rect_preserves_ansi_reset() {
573 let mut target = Rendered::empty();
574 let source = Rendered {
576 lines: vec!["\x1b[44mhello \x1b[0m".into()],
577 cursor: None,
578 images: Vec::new(),
579 };
580 source.blit_into_rect(&mut target, Rect::new(0, 0, 10, 1));
581 assert!(
583 target.lines[0].contains("\x1b[0m"),
584 "reset code should survive blit"
585 );
586 assert_eq!(crate::utils::visible_width(&target.lines[0]), 10);
588 }
589
590 #[test]
593 fn blit_into_rect_ansi_target() {
594 let mut target = Rendered {
595 lines: vec!["\x1b[31mred text here\x1b[0m".into()],
596 cursor: None,
597 images: Vec::new(),
598 };
599 let source = Rendered {
600 lines: vec!["XY".into()],
601 cursor: None,
602 images: Vec::new(),
603 };
604 source.blit_into_rect(&mut target, Rect::new(4, 0, 10, 1));
606 assert!(target.lines[0].contains("XY"));
607 assert_eq!(crate::utils::visible_width(&target.lines[0]), 13);
608 }
609
610 #[test]
613 fn blit_into_rect_preserves_ansi_reset_at_boundary() {
614 let mut target = Rendered::empty();
615 let blue_box = Rendered {
617 lines: vec!["\x1b[44m \x1b[0m".into()],
618 cursor: None,
619 images: Vec::new(),
620 };
621 blue_box.blit_into_rect(&mut target, Rect::new(0, 0, 8, 1));
622
623 let text = Rendered {
625 lines: vec!["hello".into()],
626 cursor: None,
627 images: Vec::new(),
628 };
629 text.blit_into_rect(&mut target, Rect::new(8, 0, 5, 1));
630
631 assert!(
633 target.lines[0].contains("\x1b[0mhello"),
634 "reset should be preserved before hello: {}",
635 target.lines[0]
636 );
637 assert_eq!(crate::utils::visible_width(&target.lines[0]), 13);
638 }
639
640 #[test]
642 fn blit_onto_ansi_target() {
643 let mut target = Rendered {
644 lines: vec!["\x1b[31mred text\x1b[0m".into()],
645 cursor: None,
646 images: Vec::new(),
647 };
648 let source = Rendered {
649 lines: vec!["XY".into()],
650 cursor: None,
651 images: Vec::new(),
652 };
653 source.blit_onto(&mut target, 0, 4);
655 assert!(target.lines[0].contains("XY"));
656 assert_eq!(crate::utils::visible_width(&target.lines[0]), 8);
657 }
658
659 #[test]
661 fn diff_resets_ansi_before_clear() {
662 let mut term = TestTerminal::new(80, 24);
663 let mut renderer = Renderer::new();
664
665 let frame1 = Rendered {
666 lines: vec!["\x1b[41mred bg\x1b[0m".into()],
667 cursor: None,
668 images: Vec::new(),
669 };
670 renderer.render(&mut term, &frame1).unwrap();
671
672 renderer.set_strategy(RenderStrategy::Diff);
673 let frame2 = Rendered {
674 lines: vec!["plain".into()],
675 cursor: None,
676 images: Vec::new(),
677 };
678 renderer.render(&mut term, &frame2).unwrap();
679
680 let written = term.written().join("");
681 for chunk in written.split("\x1b[2K") {
683 if !chunk.is_empty() && chunk.contains("\x1b[") {
684 assert!(
685 chunk.ends_with("\x1b[0m") || !chunk.contains("\x1b[2K"),
686 "clear must be preceded by reset: {}",
687 chunk
688 );
689 }
690 }
691 }
692
693 #[test]
695 fn first_render_resets_before_clear() {
696 let mut term = TestTerminal::new(80, 24);
697 let mut renderer = Renderer::new();
698 let rendered = Rendered {
699 lines: vec!["hello".into()],
700 cursor: None,
701 images: Vec::new(),
702 };
703 renderer.render(&mut term, &rendered).unwrap();
704 let written = term.written().join("");
705 assert!(
706 written.contains("\x1b[0m\x1b[2J"),
707 "reset must precede screen clear"
708 );
709 }
710
711 #[test]
713 fn full_redraw_resets_before_clear() {
714 let mut term = TestTerminal::new(80, 24);
715 let mut renderer = Renderer::new();
716 renderer.set_strategy(RenderStrategy::FullRedraw);
717 let rendered = Rendered {
718 lines: vec!["hello".into()],
719 cursor: None,
720 images: Vec::new(),
721 };
722 renderer.render(&mut term, &rendered).unwrap();
723 let written = term.written().join("");
724 assert!(
725 written.contains("\x1b[0m\x1b[2J"),
726 "reset must precede screen clear"
727 );
728 }
729}