1use std::any::Any;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Condvar, Mutex};
9use std::thread::JoinHandle;
10use std::time::{Duration, Instant};
11
12use super::component::Component;
13use super::container::Container;
14use super::layout::{
15 composite_tui_line, extract_cursor_position, render_layout_frame,
16 render_layout_frame_reusing_scroll_content, LayoutFrame,
17};
18use super::overlay::OverlayManager;
19use super::scroll_view::ScrollView;
20use super::tui::{OverlayHandle, OverlayOptions, TuiMode, TuiStopOptions, TUI};
21use crate::ansi::{visible_width, CURSOR_MARKER};
22use crate::terminal::{InputEvent, Terminal, TerminalInfo};
23
24pub const VIEWPORT_TUI: &[u8] = b"rpi-tui/viewport";
26
27pub const MIN_RENDER_INTERVAL_MS: u64 = 16;
36
37fn merge_pending_render(parked: Option<bool>, reuse_scroll_content: bool) -> Option<bool> {
44 match parked {
45 Some(parked) => Some(parked && reuse_scroll_content),
46 None => Some(reuse_scroll_content),
47 }
48}
49
50struct RenderSchedulerState {
53 pending: Option<bool>,
56 shutdown: bool,
58 last_render_at: Instant,
60}
61
62struct RenderScheduler {
65 state: Mutex<RenderSchedulerState>,
66 cv: Condvar,
67}
68
69impl RenderScheduler {
70 fn new() -> Self {
71 Self {
72 state: Mutex::new(RenderSchedulerState {
73 pending: None,
74 shutdown: false,
75 last_render_at: Instant::now(),
76 }),
77 cv: Condvar::new(),
78 }
79 }
80
81 fn park(&self, reuse_scroll_content: bool) {
83 if let Ok(mut state) = self.state.lock() {
84 state.pending = merge_pending_render(state.pending, reuse_scroll_content);
85 }
86 self.cv.notify_all();
89 }
90
91 fn mark_painted(&self) {
93 if let Ok(mut state) = self.state.lock() {
94 state.last_render_at = Instant::now();
95 }
96 }
97
98 fn take_pending(&self) -> Option<bool> {
100 self.state.lock().ok()?.pending.take()
101 }
102
103 fn clear_pending(&self) {
105 if let Ok(mut state) = self.state.lock() {
106 state.pending = None;
107 }
108 }
109
110 fn shutdown(&self) {
112 if let Ok(mut state) = self.state.lock() {
113 state.shutdown = true;
114 state.pending = None;
115 }
116 self.cv.notify_all();
117 }
118}
119
120pub struct TuiAltScreen {
122 render_lock: Mutex<()>,
127 jump_to_end: crate::jump_to_end::JumpToEnd,
128 terminal: Arc<Mutex<Box<dyn Terminal>>>,
129 terminal_proxy: TerminalProxy,
130 container: Container,
131 layout_root: Mutex<Option<Arc<dyn Component>>>,
132 running: Arc<Mutex<bool>>,
133 show_hardware_cursor: Mutex<bool>,
134 clear_on_shrink: Mutex<bool>,
135 focused: Mutex<Option<Arc<dyn Component>>>,
136 full_redraw_count: Mutex<usize>,
137 previous_screen: Mutex<Vec<String>>,
138 scroll_top: Mutex<usize>,
139 stick_to_bottom: Mutex<bool>,
140 current_frame: Mutex<Option<LayoutFrame>>,
141 main_screen_mode: Mutex<bool>,
144 main_previous_width: Mutex<usize>,
145 main_previous_height: Mutex<usize>,
146 main_hardware_row: Mutex<usize>,
147 main_viewport_top: Mutex<usize>,
148 main_previous_cursor: Mutex<Option<(usize, usize)>>,
149 #[allow(dead_code)]
151 input_handler: Mutex<Option<Arc<dyn Fn(InputEvent) + Send + Sync>>>,
152 #[allow(dead_code)]
153 resize_handler: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
154 overlays: Arc<OverlayManager>,
155 render_suspended: Mutex<bool>,
156 scheduler: Arc<RenderScheduler>,
162 scheduler_started: AtomicBool,
165 scheduler_thread: Mutex<Option<JoinHandle<()>>>,
168}
169
170impl TuiAltScreen {
171 pub fn new(
173 terminal: Box<dyn Terminal>,
174 show_hardware_cursor: bool,
175 _log_directory: Option<&str>,
176 ) -> Self {
177 let terminal = Arc::new(Mutex::new(terminal));
178 let terminal_proxy = TerminalProxy {
179 terminal: terminal.clone(),
180 };
181 Self {
182 render_lock: Mutex::new(()),
183 jump_to_end: crate::jump_to_end::JumpToEnd::default(),
184 terminal,
185 terminal_proxy,
186 container: Container::new(),
187 layout_root: Mutex::new(None),
188 running: Arc::new(Mutex::new(false)),
189 show_hardware_cursor: Mutex::new(show_hardware_cursor),
190 clear_on_shrink: Mutex::new(false),
191 focused: Mutex::new(None),
192 full_redraw_count: Mutex::new(0),
193 previous_screen: Mutex::new(Vec::new()),
194 scroll_top: Mutex::new(0),
195 stick_to_bottom: Mutex::new(true),
196 current_frame: Mutex::new(None),
197 main_screen_mode: Mutex::new(false),
198 main_previous_width: Mutex::new(0),
199 main_previous_height: Mutex::new(0),
200 main_hardware_row: Mutex::new(0),
201 main_viewport_top: Mutex::new(0),
202 main_previous_cursor: Mutex::new(None),
203 input_handler: Mutex::new(None),
204 resize_handler: Mutex::new(None),
205 overlays: Arc::new(OverlayManager::new()),
206 render_suspended: Mutex::new(false),
207 scheduler: Arc::new(RenderScheduler::new()),
208 scheduler_started: AtomicBool::new(false),
209 scheduler_thread: Mutex::new(None),
210 }
211 }
212
213 pub fn start_render_scheduler(self: &Arc<Self>) {
230 if self.scheduler_started.swap(true, Ordering::AcqRel) {
231 return;
232 }
233 let tui = self.clone();
234 let spawned = std::thread::Builder::new()
235 .name("rpi-tui-render".to_string())
236 .spawn(move || tui.run_render_scheduler());
237 match spawned {
238 Ok(handle) => {
239 if let Ok(mut slot) = self.scheduler_thread.lock() {
240 *slot = Some(handle);
241 }
242 }
243 Err(_) => {
244 self.scheduler_started.store(false, Ordering::Release);
247 }
248 }
249 }
250
251 fn run_render_scheduler(self: Arc<Self>) {
255 let interval = Duration::from_millis(MIN_RENDER_INTERVAL_MS);
256 loop {
257 {
259 let Ok(mut state) = self.scheduler.state.lock() else {
260 return;
261 };
262 while state.pending.is_none() && !state.shutdown {
263 let Ok(next) = self.scheduler.cv.wait(state) else {
264 return;
265 };
266 state = next;
267 }
268 if state.shutdown {
269 return;
270 }
271 }
272 loop {
274 let remaining = {
275 let Ok(state) = self.scheduler.state.lock() else {
276 return;
277 };
278 match interval.checked_sub(state.last_render_at.elapsed()) {
279 Some(remaining) => remaining,
280 None => break,
281 }
282 };
283 let Ok(state) = self.scheduler.state.lock() else {
284 return;
285 };
286 if state.shutdown {
287 return;
288 }
289 let Ok((state, _)) = self.scheduler.cv.wait_timeout(state, remaining) else {
290 return;
291 };
292 if state.shutdown {
293 return;
294 }
295 }
296 self.flush_pending_render();
297 }
298 }
299
300 pub fn flush_pending_render(&self) -> bool {
311 let Some(reuse_scroll_content) = self.scheduler.take_pending() else {
312 return false;
313 };
314 if !self.is_running() || self.is_render_suspended() {
315 return false;
316 }
317 self.do_render(reuse_scroll_content);
318 true
319 }
320
321 fn park_render(&self, reuse_scroll_content: bool) {
323 if !self.scheduler_started.load(Ordering::Acquire) {
324 self.do_render(reuse_scroll_content);
325 return;
326 }
327 self.scheduler.park(reuse_scroll_content);
328 }
329
330 #[cfg(test)]
334 fn enable_render_coalescing(&self) {
335 self.scheduler_started.store(true, Ordering::Release);
336 }
337
338 fn mark_frame_painted(&self) {
340 self.scheduler.mark_painted();
341 }
342
343 pub fn set_main_screen_mode(&self, enabled: bool) {
346 if !self.is_running() {
347 *self.main_screen_mode.lock().unwrap() = enabled;
348 }
349 }
350
351 fn uses_main_screen(&self) -> bool {
352 self.main_screen_mode
353 .lock()
354 .map(|mode| *mode)
355 .unwrap_or(false)
356 }
357
358 pub fn set_layout_root(&self, component: Option<Arc<dyn Component>>) {
360 if let Ok(mut root) = self.layout_root.lock() {
361 *root = component;
362 }
363 self.request_render(false);
364 }
365
366 pub fn get_layout_root(&self) -> Option<Arc<dyn Component>> {
368 self.layout_root.lock().ok()?.clone()
369 }
370
371 fn is_running(&self) -> bool {
372 self.running.lock().map(|running| *running).unwrap_or(false)
373 }
374
375 pub fn set_render_suspended(&self, suspended: bool) {
380 if let Ok(mut value) = self.render_suspended.lock() {
381 *value = suspended;
382 }
383 if !suspended {
384 self.request_render(true);
385 }
386 }
387
388 pub fn is_render_suspended(&self) -> bool {
389 self.render_suspended
390 .lock()
391 .map(|value| *value)
392 .unwrap_or(false)
393 }
394
395 pub fn viewport_top(&self) -> usize {
397 if let Some(scroll) = self.get_primary_scroll_view() {
398 return scroll.scroll_top();
399 }
400 self.scroll_top.lock().map(|s| *s).unwrap_or(0)
401 }
402
403 pub fn is_following_output(&self) -> bool {
405 if let Some(scroll) = self.get_primary_scroll_view() {
406 return scroll.is_following_end();
407 }
408 self.stick_to_bottom.lock().map(|s| *s).unwrap_or(true)
409 }
410
411 pub fn set_scroll_to_end_indicator(
414 &self,
415 label: Option<Arc<dyn Fn() -> String + Send + Sync>>,
416 ) {
417 self.jump_to_end.set_label(label);
418 if self.is_running() {
419 self.request_render_reusing_scroll_content();
420 }
421 }
422
423 pub fn handle_scroll_to_end_indicator_mouse_event(
425 &self,
426 event: &crossterm::event::MouseEvent,
427 ) -> bool {
428 if self.uses_main_screen()
429 || self.is_render_suspended()
430 || self.has_overlay()
431 || !self.jump_to_end.hit(event)
432 {
433 return false;
434 }
435 let Some(scroll) = self.get_primary_scroll_view() else {
436 return false;
437 };
438 if scroll.is_following_end() || !scroll.follows_end() {
439 return false;
440 }
441 self.scroll_to_bottom();
442 true
443 }
444
445 pub fn scroll_by(&self, lines: i32) {
447 if let Some(scroll) = self.get_primary_scroll_view() {
448 scroll.scroll_by(lines);
449 self.request_render_reusing_scroll_content();
450 return;
451 }
452 if let Ok(mut scroll_top) = self.scroll_top.lock() {
453 if lines > 0 {
454 *scroll_top = scroll_top.saturating_add(lines as usize);
455 } else {
456 *scroll_top = scroll_top.saturating_sub((-lines) as usize);
457 }
458 }
459 if lines != 0 {
460 if let Ok(mut stick) = self.stick_to_bottom.lock() {
461 *stick = lines > 0; }
463 }
464 self.request_render(false);
465 }
466
467 pub fn scroll_to_top(&self) {
469 if let Some(scroll) = self.get_primary_scroll_view() {
470 scroll.scroll_to_start();
471 self.request_render_reusing_scroll_content();
472 return;
473 }
474 if let Ok(mut scroll_top) = self.scroll_top.lock() {
475 *scroll_top = 0;
476 }
477 if let Ok(mut stick) = self.stick_to_bottom.lock() {
478 *stick = false;
479 }
480 self.request_render(false);
481 }
482
483 pub fn scroll_to_bottom(&self) {
485 if let Some(scroll) = self.get_primary_scroll_view() {
486 scroll.scroll_to_end();
487 self.request_render_reusing_scroll_content();
488 return;
489 }
490 if let Ok(mut stick) = self.stick_to_bottom.lock() {
491 *stick = true;
492 }
493 self.request_render(false);
494 }
495
496 pub fn get_primary_scroll_view(&self) -> Option<Arc<ScrollView>> {
498 self.current_frame
499 .lock()
500 .ok()?
501 .as_ref()?
502 .primary_scroll_view
503 .clone()
504 }
505
506 pub fn width(&self) -> usize {
510 self.terminal.lock().map(|t| t.columns()).unwrap_or(80)
511 }
512
513 pub fn set_title(&self, title: &str) {
521 if let Ok(terminal) = self.terminal.lock() {
522 terminal.set_title(title);
523 terminal.flush();
524 }
525 }
526
527 pub fn is_viewport_tui(&self) -> bool {
529 true
530 }
531
532 pub fn refresh_size(&self) {
536 if let Ok(terminal) = self.terminal.lock() {
537 terminal.refresh_size();
538 }
539 if !self.uses_main_screen() {
543 if let Ok(mut prev) = self.previous_screen.lock() {
544 prev.clear();
545 }
546 }
547 self.request_render(false);
548 }
549
550 fn enter_alt_screen(&self) {
551 if let Ok(terminal) = self.terminal.lock() {
552 terminal.write("\x1b[?1049h\x1b[?7l");
554 terminal.write("\x1b[2J\x1b[H\x1b[?25l");
556 terminal.flush();
557 }
558 }
559
560 fn exit_alt_screen(&self, preserve_screen: bool) {
562 if let Ok(terminal) = self.terminal.lock() {
563 terminal.write("\x1b[?7h");
565 if preserve_screen {
566 terminal.write("\x1b[?1049l\x1b[?25h");
567 } else {
568 terminal.write("\x1b[?1049l\x1b[2J\x1b[H\x1b[?25h");
575 }
576 terminal.flush();
577 }
578 }
579
580 pub fn suspend(&self) {
588 self.set_render_suspended(true);
589 if let Ok(terminal) = self.terminal.lock() {
590 terminal.disable_mouse();
591 terminal.exit_raw_mode();
592 terminal.flush();
593 }
594 if !self.uses_main_screen() {
595 self.exit_alt_screen(true);
598 }
599 }
600
601 pub fn resume(&self) {
605 if !self.uses_main_screen() {
606 self.enter_alt_screen();
607 }
608 if let Ok(terminal) = self.terminal.lock() {
609 terminal.enter_raw_mode();
610 if self.uses_main_screen() {
611 terminal.disable_mouse();
612 } else {
613 terminal.enable_mouse();
614 }
615 terminal.flush();
616 }
617 self.set_render_suspended(false);
618 }
619
620 fn do_render_main_screen(&self) {
624 let Ok(_render_guard) = self.render_lock.lock() else {
625 return;
626 };
627 if self.is_render_suspended() {
631 return;
632 }
633 let Ok(terminal) = self.terminal.lock() else {
634 return;
635 };
636 let width = terminal.columns();
637 let height = terminal.rows();
638 let root: Arc<dyn Component> = self
639 .get_layout_root()
640 .unwrap_or_else(|| Arc::new(self.container.clone()));
641 let raw_lines = root.render(width);
642 let cursor = raw_lines.iter().enumerate().rev().find_map(|(row, line)| {
643 line.find(CURSOR_MARKER)
644 .map(|idx| (row, visible_width(&line[..idx])))
645 });
646 let lines: Vec<String> = raw_lines
647 .into_iter()
648 .map(|line| line.replace(CURSOR_MARKER, ""))
649 .collect();
650 let previous = self
651 .previous_screen
652 .lock()
653 .map(|p| p.clone())
654 .unwrap_or_default();
655 let previous_width = *self.main_previous_width.lock().unwrap();
656 let previous_height = *self.main_previous_height.lock().unwrap();
657 let previous_cursor = *self.main_previous_cursor.lock().unwrap();
658 terminal.write(&crate::terminal_image::removed_kitty_placements(
659 &previous, &lines,
660 ));
661
662 if (previous_width != 0 && previous_width != width)
666 || (previous_height != 0 && previous_height != height)
667 {
668 terminal.write("\x1b[2J\x1b[H\x1b[3J");
669 terminal.write("\x1b[?2026h");
670 terminal.write(&lines.join("\r\n"));
671 terminal.write("\x1b[?2026l");
672 *self.main_hardware_row.lock().unwrap() = lines.len().saturating_sub(1);
673 *self.main_viewport_top.lock().unwrap() = lines.len().saturating_sub(height);
674 } else if previous.is_empty() {
675 terminal.write("\x1b[?2026h");
676 terminal.write(&lines.join("\r\n"));
677 terminal.write("\x1b[?2026l");
678 *self.main_hardware_row.lock().unwrap() = lines.len().saturating_sub(1);
679 *self.main_viewport_top.lock().unwrap() = lines.len().saturating_sub(height);
680 } else {
681 let mut first_changed = None;
682 let mut last_changed = None;
683 let max = previous.len().max(lines.len());
684 for index in 0..max {
685 if previous.get(index) != lines.get(index) {
686 first_changed.get_or_insert(index);
687 last_changed = Some(index);
688 }
689 }
690 if first_changed.is_none() && cursor == previous_cursor {
691 return;
692 }
693 if let (Some(first), Some(last)) = (first_changed, last_changed) {
694 let mut hardware_row = *self.main_hardware_row.lock().unwrap();
695 let mut viewport_top = *self.main_viewport_top.lock().unwrap();
696 let viewport_bottom = viewport_top + height.saturating_sub(1);
697 let deleted_tail_only = first >= lines.len();
698 let append_start = first == previous.len() && first > 0;
699 let move_target = if deleted_tail_only {
700 lines.len().saturating_sub(1)
701 } else if append_start {
702 first - 1
703 } else {
704 first
705 };
706 let mut output = String::from("\x1b[?2026h");
707 let mut force_tail_repaint = false;
708 if move_target < viewport_top {
709 force_tail_repaint = true;
722 let scroll = viewport_top - move_target;
723 output.push_str("\x1b[H");
724 output.push_str(&format!("\x1b[{scroll}T"));
725 viewport_top = move_target;
726 hardware_row = move_target;
727 } else if move_target > viewport_bottom {
728 let current_screen = hardware_row
729 .saturating_sub(viewport_top)
730 .min(height.saturating_sub(1));
731 let down = height.saturating_sub(1).saturating_sub(current_screen);
732 if down > 0 {
733 output.push_str(&format!("\x1b[{down}B"));
734 }
735 let scroll = move_target - viewport_bottom;
736 output.push_str(&"\r\n".repeat(scroll));
737 viewport_top += scroll;
738 hardware_row = move_target;
739 }
740 let current_screen = hardware_row.saturating_sub(viewport_top);
741 let target_screen = move_target.saturating_sub(viewport_top);
742 if target_screen > current_screen {
743 output.push_str(&format!("\x1b[{}B", target_screen - current_screen));
744 } else if current_screen > target_screen {
745 output.push_str(&format!("\x1b[{}A", current_screen - target_screen));
746 }
747 output.push_str(if append_start { "\r\n" } else { "\r" });
748 let mut render_end = last.min(lines.len().saturating_sub(1));
749 if force_tail_repaint {
750 render_end = lines.len().saturating_sub(1);
751 }
752 if !deleted_tail_only {
753 for index in first..=render_end {
754 if index > first {
755 output.push_str("\r\n");
756 }
757 if let Some((column, text)) = previous
758 .get(index)
759 .and_then(|old| crate::line_diff::patch(old, &lines[index]))
760 {
761 if column > 0 {
762 output.push_str(&format!("\x1b[{column}C"));
763 }
764 output.push_str(&text);
765 } else {
766 output.push_str(&lines[index]);
767 if index < previous.len()
768 && visible_width(&lines[index]) < visible_width(&previous[index])
769 {
770 output.push_str("\x1b[K");
771 }
772 }
773 }
774 }
775 let mut final_row = render_end;
776 if previous.len() > lines.len() {
777 let extra_lines = previous.len() - lines.len();
778 let clear_start_offset = usize::from(!lines.is_empty());
779 if clear_start_offset > 0 {
780 output.push_str("\x1b[1B");
781 }
782 for index in 0..extra_lines {
783 output.push_str("\r\x1b[2K");
784 if index + 1 < extra_lines {
785 output.push_str("\x1b[1B");
786 }
787 }
788 let move_back = extra_lines.saturating_sub(1) + clear_start_offset;
789 if move_back > 0 {
790 output.push_str(&format!("\x1b[{move_back}A"));
791 }
792 if extra_lines > 0 {
793 final_row = lines.len().saturating_sub(1);
794 }
795 }
796 output.push_str("\x1b[?2026l");
797 terminal.write(&output);
798 let advanced = final_row.saturating_sub(viewport_top + height.saturating_sub(1));
799 viewport_top += advanced;
800 *self.main_hardware_row.lock().unwrap() = final_row;
801 *self.main_viewport_top.lock().unwrap() = viewport_top;
802 }
803 }
804
805 if let Some((row, col)) = cursor {
806 let hardware_row = *self.main_hardware_row.lock().unwrap();
807 let mut viewport_top = *self.main_viewport_top.lock().unwrap();
808 let viewport_bottom = viewport_top + height.saturating_sub(1);
809
810 if row > viewport_bottom {
811 let scroll = row - viewport_bottom;
815 let mut output = String::new();
816 let current_screen = hardware_row
817 .saturating_sub(viewport_top)
818 .min(height.saturating_sub(1));
819 let down = height.saturating_sub(1).saturating_sub(current_screen);
820 if down > 0 {
821 output.push_str(&format!("\x1b[{down}B"));
822 }
823 output.push_str(&"\r\n".repeat(scroll));
824 terminal.write(&output);
825 viewport_top += scroll;
826 } else if row < viewport_top {
827 let scroll = viewport_top - row;
831 terminal.write(&format!("\x1b[H\x1b[{scroll}T"));
832 viewport_top = row;
833 let mut output = String::from("\x1b[?2026h");
834 for i in 0..height {
835 let line = lines
836 .get(viewport_top + i)
837 .map(String::as_str)
838 .unwrap_or("");
839 if i > 0 {
840 output.push_str("\r\n");
841 }
842 output.push_str("\x1b[2K");
843 output.push_str(line);
844 }
845 output.push_str(&format!("\x1b[{}A", height.saturating_sub(1)));
848 output.push_str("\x1b[?2026l");
849 terminal.write(&output);
850 } else {
851 let current_screen = hardware_row
854 .saturating_sub(viewport_top)
855 .min(height.saturating_sub(1));
856 let target_screen = row - viewport_top;
857 if target_screen > current_screen {
858 terminal.write(&format!("\x1b[{}B", target_screen - current_screen));
859 } else if current_screen > target_screen {
860 terminal.write(&format!("\x1b[{}A", current_screen - target_screen));
861 }
862 }
863
864 terminal.write(&format!("\r\x1b[{}C", col));
865 terminal.write("\x1b[?25h");
866 *self.main_hardware_row.lock().unwrap() = row;
867 *self.main_viewport_top.lock().unwrap() = viewport_top;
868 } else {
869 terminal.write("\x1b[?25l");
870 }
871 terminal.flush();
872 *self.previous_screen.lock().unwrap() = lines;
873 *self.main_previous_width.lock().unwrap() = width;
874 *self.main_previous_height.lock().unwrap() = height;
875 *self.main_previous_cursor.lock().unwrap() = cursor;
876 }
877
878 fn do_render(&self, reuse_scroll_content: bool) {
880 if self.is_render_suspended() {
881 return;
882 }
883 self.mark_frame_painted();
887 if self.uses_main_screen() {
888 self.do_render_main_screen();
889 return;
890 }
891 let Ok(_render_guard) = self.render_lock.lock() else {
892 return;
893 };
894 if self.is_render_suspended() {
895 return;
896 }
897
898 let (width, height) = if let Ok(terminal) = self.terminal.lock() {
899 (terminal.columns(), terminal.rows())
900 } else {
901 return;
902 };
903
904 let root = self.get_layout_root();
906 let root_component: Arc<dyn Component> =
907 root.unwrap_or_else(|| Arc::new(self.container.clone()));
908
909 let frame = if reuse_scroll_content {
911 render_layout_frame_reusing_scroll_content(root_component.clone(), width, height)
912 } else {
913 render_layout_frame(root_component.clone(), width, height)
914 };
915
916 if let Ok(mut current) = self.current_frame.lock() {
918 *current = Some(frame.clone());
919 }
920
921 let mut visible = frame.lines.clone();
923 self.jump_to_end
924 .paint(&frame, &mut visible, self.has_overlay());
925 self.paint_overlays(&mut visible, width, height);
926
927 let cursor = extract_cursor_position(&visible, height);
932 for line in &mut visible {
933 *line = line.replace(CURSOR_MARKER, "");
934 }
935
936 let previous = self
938 .previous_screen
939 .lock()
940 .map(|p| p.clone())
941 .unwrap_or_default();
942 let full_redraw = previous.len() != height || previous.is_empty();
943
944 let mut buffer = String::new();
946 buffer.push_str(&crate::terminal_image::removed_kitty_placements(
947 &previous, &visible,
948 ));
949
950 if let Ok(terminal) = self.terminal.lock() {
951 if full_redraw {
952 if let Ok(mut count) = self.full_redraw_count.lock() {
953 *count += 1;
954 }
955 buffer.push_str("\x1b[2J"); }
957
958 buffer.push_str("\x1b[?2026h");
960
961 for (row, line) in visible.iter().enumerate() {
963 if !full_redraw && row < previous.len() && &previous[row] == line {
964 continue; }
966 if let Some((column, text)) = (!full_redraw)
970 .then(|| crate::line_diff::patch(&previous[row], line))
971 .flatten()
972 {
973 buffer.push_str(&format!("\x1b[{};{}H{}", row + 1, column + 1, text));
974 } else {
975 buffer.push_str(&format!("\x1b[{};1H{}", row + 1, line));
976 if !full_redraw && visible_width(line) < visible_width(&previous[row]) {
977 buffer.push_str("\x1b[K");
978 }
979 }
980 }
981
982 if let Some((row, col)) = cursor {
985 buffer.push_str(&format!("\x1b[{};{}H", row + 1, col + 1));
986 if self.get_show_hardware_cursor() {
987 buffer.push_str("\x1b[?25h");
988 }
989 } else {
990 buffer.push_str("\x1b[?25l");
992 }
993
994 buffer.push_str("\x1b[?2026l");
996
997 terminal.write(&buffer);
998 terminal.flush();
999 }
1000
1001 if let Ok(mut prev) = self.previous_screen.lock() {
1003 *prev = visible;
1004 }
1005 }
1006
1007 fn paint_overlays(&self, lines: &mut [String], width: usize, height: usize) {
1008 for overlay in self.overlays.render_visible(width, height) {
1009 let (x, y, overlay_width) = (overlay.x, overlay.y, overlay.width);
1010 let overlay_lines = overlay.lines;
1011 let overlay_height = overlay_lines.len();
1012 for (index, line) in overlay_lines.iter().take(overlay_height).enumerate() {
1013 let row = y + index;
1014 if row >= lines.len() {
1015 break;
1016 }
1017 lines[row] = composite_tui_line(&lines[row], line, x, overlay_width, width);
1018 }
1019 }
1020 }
1021}
1022
1023impl Component for TuiAltScreen {
1024 fn render(&self, width: usize) -> Vec<String> {
1025 self.get_layout_root()
1026 .map(|r| r.render(width))
1027 .unwrap_or_else(|| self.container.render(width))
1028 }
1029
1030 fn invalidate(&self) {
1031 if let Some(root) = self.get_layout_root() {
1032 root.invalidate();
1033 } else {
1034 self.container.invalidate();
1035 }
1036 }
1037
1038 fn as_any(&self) -> &dyn Any {
1039 self
1040 }
1041}
1042
1043impl TUI for TuiAltScreen {
1044 fn mode(&self) -> TuiMode {
1045 if self.uses_main_screen() {
1046 TuiMode::Regular
1047 } else {
1048 TuiMode::Fullscreen
1049 }
1050 }
1051
1052 fn terminal(&self) -> &dyn Terminal {
1053 &self.terminal_proxy
1054 }
1055
1056 fn children(&self) -> Vec<Arc<dyn Component>> {
1057 self.container.get_children()
1058 }
1059
1060 fn add_child(&self, component: Arc<dyn Component>) {
1061 self.container.add_child(component);
1062 }
1063
1064 fn remove_child(&self, component: &Arc<dyn Component>) {
1065 self.container.remove_child(component);
1066 }
1067
1068 fn clear(&self) {
1069 self.container.clear();
1070 }
1071
1072 fn get_show_hardware_cursor(&self) -> bool {
1073 self.show_hardware_cursor
1074 .lock()
1075 .map(|s| *s)
1076 .unwrap_or(false)
1077 }
1078
1079 fn set_show_hardware_cursor(&self, enabled: bool) {
1080 if let Ok(mut show) = self.show_hardware_cursor.lock() {
1081 *show = enabled;
1082 }
1083 }
1084
1085 fn get_clear_on_shrink(&self) -> bool {
1086 self.clear_on_shrink.lock().map(|s| *s).unwrap_or(false)
1087 }
1088
1089 fn set_clear_on_shrink(&self, enabled: bool) {
1090 if let Ok(mut clear) = self.clear_on_shrink.lock() {
1091 *clear = enabled;
1092 }
1093 }
1094
1095 fn set_focus(&self, component: Option<Arc<dyn Component>>) {
1096 if let Ok(mut focused) = self.focused.lock() {
1097 *focused = component;
1098 }
1099 }
1100
1101 fn get_focus(&self) -> Option<Arc<dyn Component>> {
1102 self.focused.lock().ok()?.clone()
1103 }
1104
1105 fn show_overlay(
1106 &self,
1107 component: Arc<dyn Component>,
1108 options: Option<OverlayOptions>,
1109 ) -> Arc<dyn OverlayHandle> {
1110 let handle = self.overlays.add(component, options.unwrap_or_default());
1111 self.request_render(false);
1112 Arc::new(ManagedOverlayHandle {
1113 handle,
1114 hidden: Mutex::new(false),
1115 focused: Mutex::new(true),
1116 })
1117 }
1118
1119 fn hide_overlay(&self) {
1120 self.overlays.remove_topmost();
1121 self.request_render(false);
1122 }
1123
1124 fn has_overlay(&self) -> bool {
1125 !self.overlays.get_visible().is_empty()
1126 }
1127
1128 fn start(&self) {
1129 if let Ok(mut running) = self.running.lock() {
1130 *running = true;
1131 }
1132
1133 let running = self.running.clone();
1135
1136 if !self.uses_main_screen() {
1139 self.enter_alt_screen();
1140 }
1141
1142 if let Ok(terminal) = self.terminal.lock() {
1147 terminal.start(
1148 Box::new(move |event: InputEvent| {
1149 if !*running.lock().unwrap() {
1150 return;
1151 }
1152 let _ = event; }),
1154 Box::new(move || {}),
1155 );
1156 if self.uses_main_screen() {
1157 terminal.disable_mouse();
1158 } else {
1159 terminal.enable_mouse();
1160 }
1161 terminal.flush();
1162 }
1163
1164 self.do_render(false);
1165 }
1166
1167 fn stop(&self, options: TuiStopOptions) {
1168 if let Ok(mut running) = self.running.lock() {
1169 *running = false;
1170 }
1171
1172 self.scheduler.shutdown();
1176 if let Some(handle) = self.scheduler_thread.lock().ok().and_then(|mut s| s.take()) {
1177 let _ = handle.join();
1178 }
1179
1180 if self.uses_main_screen() {
1181 if let Ok(terminal) = self.terminal.lock() {
1182 terminal.write("\x1b[?25h\r\n");
1185 terminal.stop();
1186 }
1187 } else {
1188 if let Ok(terminal) = self.terminal.lock() {
1190 terminal.stop();
1191 }
1192 self.exit_alt_screen(options.preserve_screen);
1193 }
1194 }
1195
1196 fn render_now(&self, force: bool) {
1197 if !self.is_running() {
1201 return;
1202 }
1203 if self.is_render_suspended() {
1204 return;
1205 }
1206 if force {
1207 if let Ok(mut prev) = self.previous_screen.lock() {
1208 prev.clear();
1209 }
1210 }
1211 self.scheduler.clear_pending();
1214 self.do_render(false);
1215 }
1216
1217 fn request_render(&self, force: bool) {
1218 if self.is_render_suspended() {
1219 return;
1220 }
1221 if force {
1222 self.render_now(true);
1225 return;
1226 }
1227 if !self.is_running() {
1228 return;
1229 }
1230 self.park_render(false);
1233 }
1234
1235 fn full_redraws(&self) -> usize {
1236 self.full_redraw_count.lock().map(|c| *c).unwrap_or(0)
1237 }
1238}
1239
1240impl TuiAltScreen {
1241 pub fn start_readerless(&self) {
1254 if let Ok(mut running) = self.running.lock() {
1255 *running = true;
1256 }
1257 if !self.uses_main_screen() {
1258 self.enter_alt_screen();
1259 }
1260 if let Ok(terminal) = self.terminal.lock() {
1261 terminal.enter_raw_mode();
1262 if self.uses_main_screen() {
1263 terminal.disable_mouse();
1266 } else {
1267 terminal.enable_mouse();
1272 }
1273 terminal.flush();
1274 }
1275 self.do_render(false);
1276 }
1277
1278 pub fn request_render_reusing_scroll_content(&self) {
1285 if !self.is_running() || self.is_render_suspended() {
1286 return;
1287 }
1288 self.park_render(true);
1289 }
1290}
1291
1292pub fn is_viewport_tui(_tui: &dyn TUI) -> bool {
1294 _tui.as_any().is::<TuiAltScreen>()
1295}
1296
1297struct ManagedOverlayHandle {
1298 handle: super::overlay::OverlayHandle,
1299 hidden: Mutex<bool>,
1300 focused: Mutex<bool>,
1301}
1302
1303impl OverlayHandle for ManagedOverlayHandle {
1304 fn hide(&self) {
1305 self.handle.hide();
1306 if let Ok(mut hidden) = self.hidden.lock() {
1307 *hidden = true;
1308 }
1309 }
1310
1311 fn set_hidden(&self, hidden: bool) {
1312 self.handle.set_visible(!hidden);
1313 if let Ok(mut current) = self.hidden.lock() {
1314 *current = hidden;
1315 }
1316 }
1317
1318 fn is_hidden(&self) -> bool {
1319 self.hidden.lock().map(|hidden| *hidden).unwrap_or(true)
1320 }
1321
1322 fn focus(&self) {
1323 if let Ok(mut focused) = self.focused.lock() {
1324 *focused = true;
1325 }
1326 }
1327
1328 fn is_focused(&self) -> bool {
1329 self.focused.lock().map(|focused| *focused).unwrap_or(false)
1330 }
1331}
1332
1333struct TerminalProxy {
1337 terminal: Arc<Mutex<Box<dyn Terminal>>>,
1338}
1339
1340impl TerminalProxy {
1341 fn with<R>(&self, f: impl FnOnce(&dyn Terminal) -> R) -> Option<R> {
1342 self.terminal
1343 .lock()
1344 .ok()
1345 .map(|terminal| f(terminal.as_ref()))
1346 }
1347}
1348
1349impl Terminal for TerminalProxy {
1350 fn info(&self) -> TerminalInfo {
1351 self.with(|terminal| terminal.info()).unwrap_or_default()
1352 }
1353
1354 fn write(&self, data: &str) {
1355 let _ = self.with(|terminal| terminal.write(data));
1356 }
1357
1358 fn hide_cursor(&self) {
1359 let _ = self.with(|terminal| terminal.hide_cursor());
1360 }
1361
1362 fn show_cursor(&self) {
1363 let _ = self.with(|terminal| terminal.show_cursor());
1364 }
1365
1366 fn move_cursor(&self, row: usize, col: usize) {
1367 let _ = self.with(|terminal| terminal.move_cursor(row, col));
1368 }
1369
1370 fn clear_screen(&self) {
1371 let _ = self.with(|terminal| terminal.clear_screen());
1372 }
1373
1374 fn set_title(&self, title: &str) {
1375 let _ = self.with(|terminal| terminal.set_title(title));
1376 }
1377
1378 fn enable_mouse(&self) {
1379 let _ = self.with(|terminal| terminal.enable_mouse());
1380 }
1381
1382 fn disable_mouse(&self) {
1383 let _ = self.with(|terminal| terminal.disable_mouse());
1384 }
1385
1386 fn enter_raw_mode(&self) {
1387 let _ = self.with(|terminal| terminal.enter_raw_mode());
1388 }
1389
1390 fn exit_raw_mode(&self) {
1391 let _ = self.with(|terminal| terminal.exit_raw_mode());
1392 }
1393
1394 fn refresh_size(&self) {
1395 let _ = self.with(|terminal| terminal.refresh_size());
1396 }
1397
1398 fn start(
1399 &self,
1400 on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
1401 on_resize: Box<dyn Fn() + Send + Sync>,
1402 ) {
1403 let _ = self.with(|terminal| terminal.start(on_input, on_resize));
1404 }
1405
1406 fn stop(&self) {
1407 let _ = self.with(|terminal| terminal.stop());
1408 }
1409
1410 fn is_tty(&self) -> bool {
1411 self.with(|terminal| terminal.is_tty()).unwrap_or(false)
1412 }
1413
1414 fn set_progress(&self, active: bool) {
1415 let _ = self.with(|terminal| terminal.set_progress(active));
1416 }
1417
1418 fn flush(&self) {
1419 let _ = self.with(|terminal| terminal.flush());
1420 }
1421}
1422
1423#[cfg(test)]
1424mod tests {
1425 use super::*;
1426 use crate::{Editor, Focusable, Text};
1427 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1428
1429 #[test]
1430 fn changed_rows_overwrite_without_blanking_sidebar_and_clear_shorter_tails_afterwards() {
1431 for main_screen in [false, true] {
1432 let output = Arc::new(Mutex::new(String::new()));
1433 let tui = TuiAltScreen::new(
1434 Box::new(RecordingTerminal {
1435 output: output.clone(),
1436 }),
1437 false,
1438 None,
1439 );
1440 let text = Arc::new(Text::new("chat one │ RUN STATS", 0, 0));
1441 tui.set_main_screen_mode(main_screen);
1442 tui.set_layout_root(Some(text.clone()));
1443 tui.start_readerless();
1444 output.lock().unwrap().clear();
1445 text.set_text("chat two │ RUN STATS");
1446 tui.do_render(false);
1447 let frame = output.lock().unwrap().clone();
1448 assert!(frame.contains("tw"));
1449 assert!(!frame.contains("RUN STATS"));
1450 assert!(!frame.contains("\x1b[2K"));
1451 assert!(!frame.contains("\x1b[2J"));
1452 output.lock().unwrap().clear();
1453 text.set_text("short");
1454 tui.do_render(false);
1455 let frame = output.lock().unwrap().clone();
1456 assert!(frame.contains("short"));
1457 assert!(!frame.contains("\x1b[2K"));
1458 }
1459 }
1460
1461 #[test]
1462 fn jump_click_resumes_following_and_preserves_the_editor_draft_and_focus() {
1463 use crate::{StackChild, StackEntry, VStack};
1464 use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1465 let tui = TuiAltScreen::new(
1466 Box::new(RecordingTerminal {
1467 output: Arc::new(Mutex::new(String::new())),
1468 }),
1469 false,
1470 None,
1471 );
1472 let text = Arc::new(Text::new(
1473 (1..=20)
1474 .map(|n| format!("message {n}"))
1475 .collect::<Vec<_>>()
1476 .join("\n"),
1477 0,
1478 0,
1479 ));
1480 let scroll = Arc::new(ScrollView::primary(text.clone()));
1481 let editor = Arc::new(Editor::simple());
1482 editor.set_text("未发送的草稿");
1483 let root = Arc::new(VStack::from_children(vec![
1484 StackChild::Entry(StackEntry::new(scroll.clone()).basis(0).grow(1).min_size(1)),
1485 StackChild::Entry(StackEntry::new(editor.clone())),
1486 ]));
1487 tui.set_layout_root(Some(root));
1488 tui.set_focus(Some(editor.clone()));
1489 tui.set_scroll_to_end_indicator(Some(Arc::new(|| {
1490 " ↓ Jump to latest message · End ".to_owned()
1491 })));
1492 tui.start_readerless();
1493 tui.scroll_by(-4);
1494 assert!(!scroll.is_following_end());
1495 assert!(tui
1496 .previous_screen
1497 .lock()
1498 .unwrap()
1499 .iter()
1500 .any(|line| line.contains("Jump to latest")));
1501 let click = MouseEvent {
1502 kind: MouseEventKind::Down(MouseButton::Left),
1503 column: 20,
1504 row: (scroll.viewport_height() - 1) as u16,
1505 modifiers: KeyModifiers::NONE,
1506 };
1507 assert!(tui.handle_scroll_to_end_indicator_mouse_event(&click));
1508 assert!(scroll.is_following_end());
1509 assert!(!tui
1510 .previous_screen
1511 .lock()
1512 .unwrap()
1513 .iter()
1514 .any(|line| line.contains("Jump to latest")));
1515 assert_eq!(editor.get_text(), "未发送的草稿");
1516 let focused: Arc<dyn Component> = editor.clone();
1517 assert!(Arc::ptr_eq(&tui.get_focus().unwrap(), &focused));
1518 text.set_text(
1519 (1..=24)
1520 .map(|n| format!("message {n}"))
1521 .collect::<Vec<_>>()
1522 .join("\n"),
1523 );
1524 tui.do_render(false);
1525 assert_eq!(
1526 scroll.scroll_top(),
1527 scroll.content_height() - scroll.viewport_height()
1528 );
1529 assert!(tui
1530 .previous_screen
1531 .lock()
1532 .unwrap()
1533 .iter()
1534 .any(|line| line.contains("message 24")));
1535 tui.stop(Default::default());
1536 }
1537
1538 struct RecordingTerminal {
1539 output: Arc<Mutex<String>>,
1540 }
1541
1542 impl Terminal for RecordingTerminal {
1543 fn info(&self) -> TerminalInfo {
1544 TerminalInfo {
1545 columns: 40,
1546 rows: 8,
1547 ..Default::default()
1548 }
1549 }
1550
1551 fn write(&self, data: &str) {
1552 self.output.lock().unwrap().push_str(data);
1553 }
1554
1555 fn hide_cursor(&self) {}
1556 fn show_cursor(&self) {}
1557 fn move_cursor(&self, _row: usize, _col: usize) {}
1558 fn clear_screen(&self) {}
1559 fn set_title(&self, _title: &str) {}
1560 fn enable_mouse(&self) {}
1561 fn disable_mouse(&self) {}
1562 fn enter_raw_mode(&self) {
1563 self.write("<raw-on>");
1564 }
1565 fn exit_raw_mode(&self) {
1566 self.write("<raw-off>");
1567 }
1568 fn refresh_size(&self) {}
1569 fn start(
1570 &self,
1571 _on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
1572 _on_resize: Box<dyn Fn() + Send + Sync>,
1573 ) {
1574 self.enter_raw_mode();
1575 }
1576 fn stop(&self) {
1577 self.write("<stop>");
1578 }
1579 fn is_tty(&self) -> bool {
1580 true
1581 }
1582 fn set_progress(&self, _active: bool) {}
1583 fn flush(&self) {}
1584 }
1585
1586 #[test]
1587 fn keyboard_setup_and_teardown_stay_on_the_owning_screen_buffer() {
1588 for readerless in [true, false] {
1589 let output = Arc::new(Mutex::new(String::new()));
1590 let tui = TuiAltScreen::new(
1591 Box::new(RecordingTerminal {
1592 output: output.clone(),
1593 }),
1594 true,
1595 None,
1596 );
1597 if readerless {
1598 tui.start_readerless();
1599 } else {
1600 tui.start();
1601 }
1602 let rendered = output.lock().unwrap().clone();
1603 assert!(rendered.find("\x1b[?1049h").unwrap() < rendered.find("<raw-on>").unwrap());
1604 output.lock().unwrap().clear();
1605 tui.suspend();
1606 let rendered = output.lock().unwrap().clone();
1607 assert!(rendered.find("<raw-off>").unwrap() < rendered.find("\x1b[?1049l").unwrap());
1608 assert!(rendered.contains("\x1b[?7h"));
1609 output.lock().unwrap().clear();
1610 tui.resume();
1611 let rendered = output.lock().unwrap().clone();
1612 assert!(rendered.find("\x1b[?1049h").unwrap() < rendered.find("<raw-on>").unwrap());
1613 output.lock().unwrap().clear();
1614 tui.stop(TuiStopOptions::default());
1615 let rendered = output.lock().unwrap().clone();
1616 assert!(rendered.find("<stop>").unwrap() < rendered.find("\x1b[?1049l").unwrap());
1617 }
1618 }
1619
1620 #[test]
1621 fn regular_start_never_switches_to_the_alternate_screen() {
1622 let output = Arc::new(Mutex::new(String::new()));
1623 let tui = TuiAltScreen::new(
1624 Box::new(RecordingTerminal {
1625 output: output.clone(),
1626 }),
1627 true,
1628 None,
1629 );
1630 tui.set_main_screen_mode(true);
1631 tui.start();
1632 tui.suspend();
1633 tui.resume();
1634 tui.stop(TuiStopOptions::default());
1635 let rendered = output.lock().unwrap().clone();
1636 assert!(rendered.contains("<raw-on>"));
1637 assert!(!rendered.contains("\x1b[?1049"));
1638 }
1639
1640 #[test]
1641 fn cursor_marker_is_never_written_to_the_terminal() {
1642 let output = Arc::new(Mutex::new(String::new()));
1643 let terminal = RecordingTerminal {
1644 output: output.clone(),
1645 };
1646 let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1647 let editor = Arc::new(Editor::simple());
1648 editor.set_focused(true);
1649 editor.insert("hello");
1650 tui.set_layout_root(Some(editor.clone()));
1651 tui.start_readerless();
1652
1653 editor.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
1654 tui.request_render(false);
1655
1656 let rendered = output.lock().unwrap().clone();
1657 assert!(!rendered.contains(CURSOR_MARKER));
1658 assert!(rendered.contains("hello"));
1659 assert_eq!(editor.get_text(), "hello");
1660 }
1661
1662 #[test]
1663 fn layout_does_not_touch_main_screen_and_stop_clears_it() {
1664 let output = Arc::new(Mutex::new(String::new()));
1665 let terminal = RecordingTerminal {
1666 output: output.clone(),
1667 };
1668 let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1669
1670 tui.set_layout_root(Some(Arc::new(Text::new("screen content", 0, 0))));
1671 assert!(output.lock().unwrap().is_empty());
1672
1673 tui.start_readerless();
1674 assert!(output.lock().unwrap().contains("screen content"));
1675
1676 tui.stop(TuiStopOptions::default());
1677 let rendered = output.lock().unwrap().clone();
1678 let content_position = rendered.find("screen content").unwrap();
1679 let clear_position = rendered
1680 .rfind("\x1b[?1049l\x1b[2J\x1b[H\x1b[?25h")
1681 .expect("stop should leave alt screen and clear the restored main screen");
1682 assert!(clear_position > content_position);
1683 }
1684
1685 #[test]
1686 fn cached_redraw_is_suppressed_while_rendering_is_suspended() {
1687 let output = Arc::new(Mutex::new(String::new()));
1688 let terminal = RecordingTerminal {
1689 output: output.clone(),
1690 };
1691 let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1692 tui.set_layout_root(Some(Arc::new(Text::new("outer frame", 0, 0))));
1693 tui.start_readerless();
1694
1695 tui.set_render_suspended(true);
1696 let before = output.lock().unwrap().len();
1697 tui.request_render_reusing_scroll_content();
1698 assert_eq!(output.lock().unwrap().len(), before);
1699
1700 tui.set_render_suspended(false);
1701 tui.set_layout_root(Some(Arc::new(Text::new("new frame", 0, 0))));
1702 assert!(output.lock().unwrap().len() > before);
1703 }
1704
1705 #[test]
1706 fn regular_single_line_update_does_not_rewrite_unchanged_tail() {
1707 let output = Arc::new(Mutex::new(String::new()));
1708 let terminal = RecordingTerminal {
1709 output: output.clone(),
1710 };
1711 let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1712 let content = Arc::new(Text::new("header\nstatus\neditor\nfooter", 0, 0));
1713 tui.set_main_screen_mode(true);
1714 tui.set_layout_root(Some(content.clone()));
1715 tui.start_readerless();
1716 output.lock().unwrap().clear();
1717
1718 content.set_text("header\nstatus2\neditor\nfooter");
1719 tui.request_render(false);
1720
1721 let rendered = output.lock().unwrap().clone();
1722 assert!(rendered.contains("\x1b[6C2"));
1723 assert!(!rendered.contains("editor"));
1724 assert!(!rendered.contains("footer"));
1725 }
1726
1727 #[test]
1728 fn regular_unchanged_frame_writes_nothing() {
1729 let output = Arc::new(Mutex::new(String::new()));
1730 let terminal = RecordingTerminal {
1731 output: output.clone(),
1732 };
1733 let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1734 tui.set_main_screen_mode(true);
1735 tui.set_layout_root(Some(Arc::new(Text::new(
1736 "header\nstatus\neditor\nfooter",
1737 0,
1738 0,
1739 ))));
1740 tui.start_readerless();
1741 output.lock().unwrap().clear();
1742
1743 tui.request_render(false);
1744
1745 assert!(output.lock().unwrap().is_empty());
1746 }
1747
1748 #[test]
1749 fn merge_pending_render_never_downgrades_a_full_render() {
1750 assert_eq!(merge_pending_render(None, false), Some(false));
1752 assert_eq!(merge_pending_render(None, true), Some(true));
1753 assert_eq!(merge_pending_render(Some(true), false), Some(false));
1755 assert_eq!(merge_pending_render(Some(true), true), Some(true));
1757 assert_eq!(merge_pending_render(Some(false), true), Some(false));
1759 assert_eq!(merge_pending_render(Some(false), false), Some(false));
1760 }
1761
1762 #[test]
1763 fn rapid_requests_coalesce_into_a_single_frame() {
1764 let output = Arc::new(Mutex::new(String::new()));
1765 let terminal = RecordingTerminal {
1766 output: output.clone(),
1767 };
1768 let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1769 let content = Arc::new(Text::new("first", 0, 0));
1770 tui.set_layout_root(Some(content.clone()));
1771 tui.enable_render_coalescing();
1772 tui.start_readerless();
1773 output.lock().unwrap().clear();
1774
1775 content.set_text("second");
1777 for _ in 0..50 {
1778 tui.request_render(false);
1779 }
1780 assert!(
1781 output.lock().unwrap().is_empty(),
1782 "parked requests must not repaint the transcript"
1783 );
1784
1785 assert!(tui.flush_pending_render());
1787 let rendered = output.lock().unwrap().clone();
1788 assert!(rendered.contains("second"));
1789 assert!(!tui.flush_pending_render(), "nothing owed after the flush");
1790 }
1791
1792 #[test]
1793 fn immediate_render_discards_a_parked_frame() {
1794 let output = Arc::new(Mutex::new(String::new()));
1795 let terminal = RecordingTerminal {
1796 output: output.clone(),
1797 };
1798 let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1799 let content = Arc::new(Text::new("before", 0, 0));
1800 tui.set_layout_root(Some(content.clone()));
1801 tui.enable_render_coalescing();
1802 tui.start_readerless();
1803 output.lock().unwrap().clear();
1804
1805 content.set_text("after");
1806 tui.request_render(false);
1807 tui.render_now(true);
1810
1811 assert!(output.lock().unwrap().contains("after"));
1812 assert!(!tui.flush_pending_render());
1813 }
1814
1815 #[test]
1820 fn streaming_burst_paints_at_most_one_frame_per_interval() {
1821 let output = Arc::new(Mutex::new(String::new()));
1822 let terminal = RecordingTerminal {
1823 output: output.clone(),
1824 };
1825 let tui = Arc::new(TuiAltScreen::new(Box::new(terminal), true, None));
1826 let content = Arc::new(Text::new("0", 0, 0));
1827 tui.set_layout_root(Some(content.clone()));
1828 tui.start_render_scheduler();
1829 tui.start_readerless();
1830 output.lock().unwrap().clear();
1831
1832 for i in 0..200 {
1833 content.set_text(&format!("delta {i}"));
1834 tui.request_render(false);
1835 std::thread::sleep(Duration::from_millis(1));
1836 }
1837 std::thread::sleep(Duration::from_millis(50));
1838
1839 let frames = output.lock().unwrap().matches("\x1b[?2026h").count();
1840 assert!(frames >= 1, "the scheduler must still paint frames");
1841 assert!(
1842 frames <= 40,
1843 "200 requests in ~250 ms must coalesce into ~16 ms frames, painted {frames}"
1844 );
1845 tui.stop(TuiStopOptions::default());
1846 }
1847}