1#![forbid(unsafe_code)]
2
3use std::io::{self, BufWriter, Write};
51use std::sync::atomic::{AtomicU32, Ordering};
52use web_time::Instant;
53
54static INLINE_ACTIVE_WIDGETS: AtomicU32 = AtomicU32::new(0);
59
60pub fn inline_active_widgets() -> u32 {
62 INLINE_ACTIVE_WIDGETS.load(Ordering::Relaxed)
63}
64
65use crate::evidence_sink::EvidenceSink;
66use crate::evidence_telemetry::{DiffDecisionSnapshot, set_diff_snapshot};
67use crate::render_trace::{
68 RenderTraceFrame, RenderTraceRecorder, build_diff_runs_payload, build_full_buffer_payload,
69};
70use ftui_core::inline_mode::InlineStrategy;
71use ftui_core::terminal_capabilities::TerminalCapabilities;
72use ftui_render::buffer::{Buffer, DirtySpanConfig, DirtySpanStats};
73use ftui_render::counting_writer::CountingWriter;
74use ftui_render::diff::{BufferDiff, TileDiffConfig, TileDiffFallback, TileDiffStats};
75use ftui_render::diff_strategy::{DiffStrategy, DiffStrategyConfig, DiffStrategySelector};
76use ftui_render::grapheme_pool::GraphemePool;
77use ftui_render::link_registry::LinkRegistry;
78use ftui_render::presenter::Presenter;
79use ftui_render::sanitize::sanitize;
80use tracing::{debug_span, info, info_span, trace, warn};
81
82#[allow(dead_code)] const BUFFER_CAPACITY: usize = 64 * 1024;
85
86const CURSOR_SAVE: &[u8] = b"\x1b7";
88
89const CURSOR_RESTORE: &[u8] = b"\x1b8";
91
92const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
94
95const SYNC_END: &[u8] = b"\x1b[?2026l";
97
98const ERASE_LINE: &[u8] = b"\x1b[2K";
100const SGR_BG_DEFAULT: &[u8] = b"\x1b[49m";
102
103#[allow(dead_code)] const FULL_REDRAW_PROBE_INTERVAL: u64 = 60;
106
107fn default_diff_run_id() -> String {
112 format!("diff-{}", std::process::id())
113}
114
115fn diff_strategy_str(strategy: DiffStrategy) -> &'static str {
116 match strategy {
117 DiffStrategy::Full => "full",
118 DiffStrategy::DirtyRows => "dirty",
119 DiffStrategy::FullRedraw => "redraw",
120 }
121}
122
123fn inline_strategy_str(strategy: InlineStrategy) -> &'static str {
124 match strategy {
125 InlineStrategy::ScrollRegion => "scroll_region",
126 InlineStrategy::OverlayRedraw => "overlay_redraw",
127 InlineStrategy::Hybrid => "hybrid",
128 }
129}
130
131fn ui_anchor_str(anchor: UiAnchor) -> &'static str {
132 match anchor {
133 UiAnchor::Bottom => "bottom",
134 UiAnchor::Top => "top",
135 }
136}
137
138#[allow(dead_code)]
139#[inline]
140fn json_escape(value: &str) -> String {
141 let mut out = String::with_capacity(value.len());
142 for ch in value.chars() {
143 match ch {
144 '"' => out.push_str("\\\""),
145 '\\' => out.push_str("\\\\"),
146 '\n' => out.push_str("\\n"),
147 '\r' => out.push_str("\\r"),
148 '\t' => out.push_str("\\t"),
149 c if c.is_control() => {
150 use std::fmt::Write as _;
151 let _ = write!(out, "\\u{:04X}", c as u32);
152 }
153 _ => out.push(ch),
154 }
155 }
156 out
157}
158
159#[allow(dead_code)]
160fn estimate_diff_scan_cost(
161 strategy: DiffStrategy,
162 dirty_rows: usize,
163 width: usize,
164 height: usize,
165 span_stats: &DirtySpanStats,
166 tile_stats: Option<TileDiffStats>,
167) -> (usize, &'static str) {
168 match strategy {
169 DiffStrategy::Full => (width.saturating_mul(height), "full_strategy"),
170 DiffStrategy::FullRedraw => (0, "full_redraw"),
171 DiffStrategy::DirtyRows => {
172 if dirty_rows == 0 {
173 return (0, "no_dirty_rows");
174 }
175 if let Some(tile_stats) = tile_stats
176 && tile_stats.fallback.is_none()
177 {
178 return (tile_stats.scan_cells_estimate, "tile_skip");
179 }
180 let span_cells = span_stats.span_coverage_cells;
181 if span_stats.overflows > 0 {
182 let estimate = if span_cells > 0 {
183 span_cells
184 } else {
185 dirty_rows.saturating_mul(width)
186 };
187 return (estimate, "span_overflow");
188 }
189 if span_cells > 0 {
190 (span_cells, "none")
191 } else {
192 (dirty_rows.saturating_mul(width), "no_spans")
193 }
194 }
195 }
196}
197
198fn sanitize_auto_bounds(min_height: u16, max_height: u16) -> (u16, u16) {
199 let min = min_height.max(1);
200 let max = max_height.max(min);
201 (min, max)
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum ScreenMode {
207 Inline {
209 ui_height: u16,
211 },
212 InlineAuto {
216 min_height: u16,
218 max_height: u16,
220 },
221 #[default]
223 AltScreen,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
228pub enum UiAnchor {
229 #[default]
231 Bottom,
232 Top,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237struct InlineRegion {
238 start: u16,
239 height: u16,
240}
241
242struct DiffDecision {
243 #[allow(dead_code)] strategy: DiffStrategy,
245 has_diff: bool,
246}
247
248#[derive(Debug, Clone, Copy)]
249#[allow(dead_code)]
250struct EmitStats {
251 diff_cells: usize,
252 diff_runs: usize,
253}
254
255#[derive(Debug, Clone, Copy)]
256#[allow(dead_code)]
257struct FrameEmitStats {
258 diff_strategy: DiffStrategy,
259 diff_cells: usize,
260 diff_runs: usize,
261 ui_height: u16,
262}
263
264#[derive(Debug, Clone, Copy)]
265#[allow(dead_code)]
266pub struct PresentTimings {
267 pub diff_us: u64,
268}
269
270#[derive(Debug, Clone)]
299pub struct RuntimeDiffConfig {
300 pub bayesian_enabled: bool,
310
311 pub dirty_rows_enabled: bool,
318
319 pub dirty_span_config: DirtySpanConfig,
323
324 pub tile_diff_config: TileDiffConfig,
328
329 pub reset_on_resize: bool,
336
337 pub reset_on_invalidation: bool,
344
345 pub strategy_config: DiffStrategyConfig,
349
350 pub full_redraw_interval_frames: u64,
358}
359
360impl Default for RuntimeDiffConfig {
361 fn default() -> Self {
362 Self {
363 bayesian_enabled: true,
364 dirty_rows_enabled: true,
365 dirty_span_config: DirtySpanConfig::default(),
366 tile_diff_config: TileDiffConfig::default(),
367 reset_on_resize: true,
368 reset_on_invalidation: true,
369 strategy_config: DiffStrategyConfig::default(),
370 full_redraw_interval_frames: 240,
371 }
372 }
373}
374
375impl RuntimeDiffConfig {
376 pub fn new() -> Self {
378 Self::default()
379 }
380
381 #[must_use]
383 pub fn with_bayesian_enabled(mut self, enabled: bool) -> Self {
384 self.bayesian_enabled = enabled;
385 self
386 }
387
388 #[must_use]
390 pub fn with_dirty_rows_enabled(mut self, enabled: bool) -> Self {
391 self.dirty_rows_enabled = enabled;
392 self
393 }
394
395 #[must_use]
397 pub fn with_dirty_spans_enabled(mut self, enabled: bool) -> Self {
398 self.dirty_span_config = self.dirty_span_config.with_enabled(enabled);
399 self
400 }
401
402 #[must_use]
404 pub fn with_dirty_span_config(mut self, config: DirtySpanConfig) -> Self {
405 self.dirty_span_config = config;
406 self
407 }
408
409 #[must_use]
411 pub fn with_tile_skip_enabled(mut self, enabled: bool) -> Self {
412 self.tile_diff_config = self.tile_diff_config.with_enabled(enabled);
413 self
414 }
415
416 #[must_use]
418 pub fn with_tile_diff_config(mut self, config: TileDiffConfig) -> Self {
419 self.tile_diff_config = config;
420 self
421 }
422
423 #[must_use]
425 pub fn with_reset_on_resize(mut self, enabled: bool) -> Self {
426 self.reset_on_resize = enabled;
427 self
428 }
429
430 #[must_use]
432 pub fn with_reset_on_invalidation(mut self, enabled: bool) -> Self {
433 self.reset_on_invalidation = enabled;
434 self
435 }
436
437 #[must_use]
439 pub fn with_strategy_config(mut self, config: DiffStrategyConfig) -> Self {
440 self.strategy_config = config;
441 self
442 }
443
444 #[must_use]
448 pub fn with_full_redraw_interval_frames(mut self, frames: u64) -> Self {
449 self.full_redraw_interval_frames = frames;
450 self
451 }
452}
453
454pub struct TerminalWriter<W: Write> {
459 presenter: Option<Presenter<W>>,
463 screen_mode: ScreenMode,
465 auto_ui_height: Option<u16>,
467 ui_anchor: UiAnchor,
469 prev_buffer: Option<Buffer>,
471 spare_buffer: Option<Buffer>,
473 clone_buf: Option<Buffer>,
476 pool: GraphemePool,
478 links: LinkRegistry,
480 capabilities: TerminalCapabilities,
482 term_width: u16,
484 term_height: u16,
486 in_sync_block: bool,
488 cursor_saved: bool,
490 cursor_visible: bool,
492 inline_strategy: InlineStrategy,
494 scroll_region_active: bool,
496 last_inline_region: Option<InlineRegion>,
498 diff_strategy: DiffStrategySelector,
500 diff_scratch: BufferDiff,
502 full_redraw_probe: u64,
504 frames_since_full_redraw: u64,
506 #[allow(dead_code)] diff_config: RuntimeDiffConfig,
509 evidence_sink: Option<EvidenceSink>,
511 #[allow(dead_code)]
513 diff_evidence_run_id: String,
514 #[allow(dead_code)]
516 diff_evidence_idx: u64,
517 last_diff_strategy: Option<DiffStrategy>,
519 render_trace: Option<RenderTraceRecorder>,
521 timing_enabled: bool,
523 last_present_timings: Option<PresentTimings>,
525}
526
527impl<W: Write> TerminalWriter<W> {
528 pub fn new(
537 writer: W,
538 screen_mode: ScreenMode,
539 ui_anchor: UiAnchor,
540 capabilities: TerminalCapabilities,
541 ) -> Self {
542 Self::with_diff_config(
543 writer,
544 screen_mode,
545 ui_anchor,
546 capabilities,
547 RuntimeDiffConfig::default(),
548 )
549 }
550
551 pub fn with_diff_config(
580 writer: W,
581 screen_mode: ScreenMode,
582 ui_anchor: UiAnchor,
583 capabilities: TerminalCapabilities,
584 diff_config: RuntimeDiffConfig,
585 ) -> Self {
586 let inline_strategy = InlineStrategy::select(&capabilities);
587 let auto_ui_height = None;
588 let diff_strategy = DiffStrategySelector::new(diff_config.strategy_config.clone());
589
590 let is_inline = matches!(
595 screen_mode,
596 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
597 );
598 if is_inline {
599 INLINE_ACTIVE_WIDGETS.fetch_add(1, Ordering::SeqCst);
600 }
601
602 match screen_mode {
604 ScreenMode::Inline { ui_height } => {
605 info!(
606 inline_height = ui_height,
607 render_mode = %inline_strategy_str(inline_strategy),
608 "inline mode activated"
609 );
610 }
611 ScreenMode::InlineAuto {
612 min_height,
613 max_height,
614 } => {
615 info!(
616 min_height,
617 max_height,
618 render_mode = %inline_strategy_str(inline_strategy),
619 "inline auto mode activated"
620 );
621 }
622 ScreenMode::AltScreen => {}
623 }
624
625 let mut diff_scratch = BufferDiff::new();
626 diff_scratch
627 .tile_config_mut()
628 .clone_from(&diff_config.tile_diff_config);
629
630 let presenter = Presenter::new(writer, capabilities);
631
632 Self {
633 presenter: Some(presenter),
634 screen_mode,
635 auto_ui_height,
636 ui_anchor,
637 prev_buffer: None,
638 spare_buffer: None,
639 clone_buf: None,
640 pool: GraphemePool::new(),
641 links: LinkRegistry::new(),
642 capabilities,
643 term_width: 80,
644 term_height: 24,
645 in_sync_block: false,
646 cursor_saved: false,
647 cursor_visible: true,
648 inline_strategy,
649 scroll_region_active: false,
650 last_inline_region: None,
651 diff_strategy,
652 diff_scratch,
653 full_redraw_probe: 0,
654 frames_since_full_redraw: 0,
655 diff_config,
656 evidence_sink: None,
657 diff_evidence_run_id: default_diff_run_id(),
658 diff_evidence_idx: 0,
659 last_diff_strategy: None,
660 render_trace: None,
661 timing_enabled: false,
662 last_present_timings: None,
663 }
664 }
665
666 #[inline]
672 fn writer(&mut self) -> &mut CountingWriter<BufWriter<W>> {
673 self.presenter_mut().counting_writer_mut()
674 }
675
676 #[inline]
682 fn presenter_mut(&mut self) -> &mut Presenter<W> {
683 self.presenter
684 .as_mut()
685 .expect("presenter has been consumed")
686 }
687
688 fn reset_diff_strategy(&mut self) {
690 if self.diff_config.reset_on_invalidation {
691 self.diff_strategy.reset();
692 }
693 self.full_redraw_probe = 0;
694 self.frames_since_full_redraw = 0;
695 self.last_diff_strategy = None;
696 }
697
698 #[allow(dead_code)] fn reset_diff_on_resize(&mut self) {
701 if self.diff_config.reset_on_resize {
702 self.diff_strategy.reset();
703 }
704 self.full_redraw_probe = 0;
705 self.frames_since_full_redraw = 0;
706 self.last_diff_strategy = None;
707 }
708
709 pub fn diff_config(&self) -> &RuntimeDiffConfig {
711 &self.diff_config
712 }
713
714 pub(crate) fn set_timing_enabled(&mut self, enabled: bool) {
716 self.timing_enabled = enabled;
717 if !enabled {
718 self.last_present_timings = None;
719 }
720 }
721
722 pub(crate) fn take_last_present_timings(&mut self) -> Option<PresentTimings> {
724 self.last_present_timings.take()
725 }
726
727 #[must_use]
729 pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
730 self.evidence_sink = Some(sink);
731 self
732 }
733
734 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
736 self.evidence_sink = sink;
737 }
738
739 #[must_use]
741 pub fn with_render_trace(mut self, recorder: RenderTraceRecorder) -> Self {
742 self.render_trace = Some(recorder);
743 self
744 }
745
746 pub fn set_render_trace(&mut self, recorder: Option<RenderTraceRecorder>) {
748 self.render_trace = recorder;
749 }
750
751 pub fn diff_strategy_mut(&mut self) -> &mut DiffStrategySelector {
755 &mut self.diff_strategy
756 }
757
758 pub fn diff_strategy(&self) -> &DiffStrategySelector {
760 &self.diff_strategy
761 }
762
763 pub fn last_diff_strategy(&self) -> Option<DiffStrategy> {
765 self.last_diff_strategy
766 }
767
768 pub fn set_size(&mut self, width: u16, height: u16) {
772 self.term_width = width;
773 self.term_height = height;
774 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. }) {
775 self.auto_ui_height = None;
776 }
777 self.prev_buffer = None;
779 self.spare_buffer = None;
780 self.clone_buf = None;
781 self.reset_diff_on_resize();
782 if self.scroll_region_active {
784 let _ = self.deactivate_scroll_region();
785 }
786 }
787
788 pub fn take_render_buffer(&mut self, width: u16, height: u16) -> Buffer {
792 if let Some(mut buffer) = self.spare_buffer.take()
793 && buffer.width() == width
794 && buffer.height() == height
795 {
796 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
797 buffer.reset_for_frame();
798 return buffer;
799 }
800
801 let mut buffer = Buffer::new(width, height);
802 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
803 buffer
804 }
805
806 #[inline]
808 pub fn width(&self) -> u16 {
809 self.term_width
810 }
811
812 #[inline]
814 pub fn height(&self) -> u16 {
815 self.term_height
816 }
817
818 #[inline]
820 pub fn screen_mode(&self) -> ScreenMode {
821 self.screen_mode
822 }
823
824 pub fn render_height_hint(&self) -> u16 {
829 match self.screen_mode {
830 ScreenMode::Inline { ui_height } => ui_height,
831 ScreenMode::InlineAuto {
832 min_height,
833 max_height,
834 } => {
835 let (min, max) = sanitize_auto_bounds(min_height, max_height);
836 let max = max.min(self.term_height);
837 let min = min.min(max);
838 if let Some(current) = self.auto_ui_height {
839 current.clamp(min, max).min(self.term_height).max(min)
840 } else {
841 max.max(min)
842 }
843 }
844 ScreenMode::AltScreen => self.term_height,
845 }
846 }
847
848 pub fn inline_auto_bounds(&self) -> Option<(u16, u16)> {
850 match self.screen_mode {
851 ScreenMode::InlineAuto {
852 min_height,
853 max_height,
854 } => {
855 let (min, max) = sanitize_auto_bounds(min_height, max_height);
856 Some((min.min(self.term_height), max.min(self.term_height)))
857 }
858 _ => None,
859 }
860 }
861
862 pub fn auto_ui_height(&self) -> Option<u16> {
864 match self.screen_mode {
865 ScreenMode::InlineAuto { .. } => self.auto_ui_height,
866 _ => None,
867 }
868 }
869
870 pub fn set_auto_ui_height(&mut self, height: u16) {
872 if let ScreenMode::InlineAuto {
873 min_height,
874 max_height,
875 } = self.screen_mode
876 {
877 let (min, max) = sanitize_auto_bounds(min_height, max_height);
878 let max = max.min(self.term_height);
879 let min = min.min(max);
880 let clamped = height.clamp(min, max);
881 let previous_effective = self.auto_ui_height.unwrap_or(min);
882 if self.auto_ui_height != Some(clamped) {
883 self.auto_ui_height = Some(clamped);
884 if clamped != previous_effective {
885 self.prev_buffer = None;
886 self.reset_diff_strategy();
887 if self.scroll_region_active {
888 let _ = self.deactivate_scroll_region();
889 }
890 }
891 }
892 }
893 }
894
895 pub fn clear_auto_ui_height(&mut self) {
897 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. })
898 && self.auto_ui_height.is_some()
899 {
900 self.auto_ui_height = None;
901 self.prev_buffer = None;
902 self.reset_diff_strategy();
903 if self.scroll_region_active {
904 let _ = self.deactivate_scroll_region();
905 }
906 }
907 }
908
909 fn effective_ui_height(&self) -> u16 {
910 match self.screen_mode {
911 ScreenMode::Inline { ui_height } => ui_height,
912 ScreenMode::InlineAuto {
913 min_height,
914 max_height,
915 } => {
916 let (min, max) = sanitize_auto_bounds(min_height, max_height);
917 let current = self.auto_ui_height.unwrap_or(min);
918 current.clamp(min, max).min(self.term_height)
919 }
920 ScreenMode::AltScreen => self.term_height,
921 }
922 }
923
924 pub fn ui_height(&self) -> u16 {
926 self.effective_ui_height()
927 }
928
929 fn ui_start_row(&self) -> u16 {
931 let ui_height = self.effective_ui_height().min(self.term_height);
932 match (self.screen_mode, self.ui_anchor) {
933 (ScreenMode::Inline { .. }, UiAnchor::Bottom)
934 | (ScreenMode::InlineAuto { .. }, UiAnchor::Bottom) => {
935 self.term_height.saturating_sub(ui_height)
936 }
937 (ScreenMode::Inline { .. }, UiAnchor::Top)
938 | (ScreenMode::InlineAuto { .. }, UiAnchor::Top) => 0,
939 (ScreenMode::AltScreen, _) => 0,
940 }
941 }
942
943 pub fn inline_strategy(&self) -> InlineStrategy {
945 self.inline_strategy
946 }
947
948 pub fn scroll_region_active(&self) -> bool {
950 self.scroll_region_active
951 }
952
953 fn activate_scroll_region(&mut self, ui_height: u16) -> io::Result<()> {
961 if self.scroll_region_active {
962 return Ok(());
963 }
964
965 let ui_height = ui_height.min(self.term_height);
966 if ui_height >= self.term_height {
967 return Ok(());
968 }
969
970 match self.ui_anchor {
971 UiAnchor::Bottom => {
972 let term_height = self.term_height;
973 let log_bottom = term_height.saturating_sub(ui_height);
974 if log_bottom > 0 {
975 write!(self.writer(), "\x1b[1;{}r", log_bottom)?;
977 self.scroll_region_active = true;
978 }
979 }
980 UiAnchor::Top => {
981 let term_height = self.term_height;
982 let log_top = ui_height.saturating_add(1);
983 if log_top <= term_height {
984 write!(self.writer(), "\x1b[{};{}r", log_top, term_height)?;
986 self.scroll_region_active = true;
987 write!(self.writer(), "\x1b[{};1H", log_top)?;
990 }
991 }
992 }
993 Ok(())
994 }
995
996 fn deactivate_scroll_region(&mut self) -> io::Result<()> {
998 if self.scroll_region_active {
999 self.writer().write_all(b"\x1b[r")?;
1000 self.scroll_region_active = false;
1001 }
1002 Ok(())
1003 }
1004
1005 fn clear_rows(&mut self, start_row: u16, height: u16) -> io::Result<()> {
1006 let start_row = start_row.min(self.term_height);
1007 let end_row = start_row.saturating_add(height).min(self.term_height);
1008 if start_row >= end_row {
1009 return Ok(());
1010 }
1011
1012 self.writer().write_all(SGR_BG_DEFAULT)?;
1015 for row in start_row..end_row {
1016 write!(self.writer(), "\x1b[{};1H", row.saturating_add(1))?;
1017 self.writer().write_all(ERASE_LINE)?;
1018 }
1019 Ok(())
1020 }
1021
1022 fn clear_inline_region_diff(&mut self, current: InlineRegion) -> io::Result<()> {
1023 let Some(previous) = self.last_inline_region else {
1024 return Ok(());
1025 };
1026
1027 let prev_start = previous.start.min(self.term_height);
1028 let prev_end = previous
1029 .start
1030 .saturating_add(previous.height)
1031 .min(self.term_height);
1032 if prev_start >= prev_end {
1033 return Ok(());
1034 }
1035
1036 let curr_start = current.start.min(self.term_height);
1037 let curr_end = current
1038 .start
1039 .saturating_add(current.height)
1040 .min(self.term_height);
1041
1042 if curr_start > prev_start {
1043 let clear_end = curr_start.min(prev_end);
1044 if clear_end > prev_start {
1045 self.clear_rows(prev_start, clear_end - prev_start)?;
1046 }
1047 }
1048
1049 if curr_end < prev_end {
1050 let clear_start = curr_end.max(prev_start);
1051 if prev_end > clear_start {
1052 self.clear_rows(clear_start, prev_end - clear_start)?;
1053 }
1054 }
1055
1056 Ok(())
1057 }
1058
1059 pub fn present_ui(
1073 &mut self,
1074 buffer: &Buffer,
1075 cursor: Option<(u16, u16)>,
1076 cursor_visible: bool,
1077 ) -> io::Result<()> {
1078 let mode_str = match self.screen_mode {
1079 ScreenMode::Inline { .. } => "inline",
1080 ScreenMode::InlineAuto { .. } => "inline_auto",
1081 ScreenMode::AltScreen => "altscreen",
1082 };
1083 let trace_enabled = self.render_trace.is_some();
1084 if trace_enabled {
1085 self.writer().reset_counter();
1086 }
1087 let present_start = if trace_enabled {
1088 Some(Instant::now())
1089 } else {
1090 None
1091 };
1092 let _span = info_span!(
1093 "ftui.render.present",
1094 mode = mode_str,
1095 width = buffer.width(),
1096 height = buffer.height(),
1097 )
1098 .entered();
1099
1100 let result = match self.screen_mode {
1101 ScreenMode::Inline { ui_height } => {
1102 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1103 }
1104 ScreenMode::InlineAuto { .. } => {
1105 let ui_height = self.effective_ui_height();
1106 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1107 }
1108 ScreenMode::AltScreen => self.present_altscreen(buffer, cursor, cursor_visible),
1109 };
1110
1111 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1112 let present_bytes = if trace_enabled {
1113 {
1114 let w = self.writer();
1115 let count = w.bytes_written();
1116 w.reset_counter();
1117 Some(count)
1118 }
1119 } else {
1120 None
1121 };
1122 if trace_enabled {
1123 }
1125
1126 if let Ok(stats) = result {
1127 self.record_successful_present(stats.diff_strategy);
1128 let new_prev = match self.clone_buf.take() {
1133 Some(mut buf)
1134 if buf.width() == buffer.width() && buf.height() == buffer.height() =>
1135 {
1136 buf.clone_from(buffer);
1137 buf
1138 }
1139 _ => buffer.clone(),
1140 };
1141 self.clone_buf = self.spare_buffer.take();
1142 self.spare_buffer = self.prev_buffer.take();
1143 self.prev_buffer = Some(new_prev);
1144
1145 if let Some(ref mut trace) = self.render_trace {
1146 let payload_info = match stats.diff_strategy {
1147 DiffStrategy::FullRedraw => {
1148 let payload = build_full_buffer_payload(buffer, &self.pool);
1149 trace.write_payload(&payload).ok()
1150 }
1151 _ => {
1152 let payload =
1153 build_diff_runs_payload(buffer, &self.diff_scratch, &self.pool);
1154 trace.write_payload(&payload).ok()
1155 }
1156 };
1157 let (payload_kind, payload_path) = match payload_info {
1158 Some(info) => (info.kind, Some(info.path)),
1159 None => ("none", None),
1160 };
1161 let payload_path_ref = payload_path.as_deref();
1162 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1163 let ui_anchor = ui_anchor_str(self.ui_anchor);
1164 let frame = RenderTraceFrame {
1165 cols: buffer.width(),
1166 rows: buffer.height(),
1167 mode: mode_str,
1168 ui_height: stats.ui_height,
1169 ui_anchor,
1170 diff_strategy,
1171 diff_cells: stats.diff_cells,
1172 diff_runs: stats.diff_runs,
1173 present_bytes: present_bytes.unwrap_or(0),
1174 render_us: None,
1175 present_us,
1176 payload_kind,
1177 payload_path: payload_path_ref,
1178 trace_us: None,
1179 };
1180 let _ = trace.record_frame(frame, buffer, &self.pool);
1181 }
1182 return Ok(());
1183 }
1184
1185 self.invalidate_after_present_error();
1186 result.map(|_| ())
1187 }
1188
1189 pub fn present_ui_owned(
1194 &mut self,
1195 buffer: Buffer,
1196 cursor: Option<(u16, u16)>,
1197 cursor_visible: bool,
1198 ) -> io::Result<()> {
1199 let mode_str = match self.screen_mode {
1200 ScreenMode::Inline { .. } => "inline",
1201 ScreenMode::InlineAuto { .. } => "inline_auto",
1202 ScreenMode::AltScreen => "altscreen",
1203 };
1204 let trace_enabled = self.render_trace.is_some();
1205 if trace_enabled {
1206 self.writer().reset_counter();
1207 }
1208 let present_start = if trace_enabled {
1209 Some(Instant::now())
1210 } else {
1211 None
1212 };
1213 let _span = info_span!(
1214 "ftui.render.present",
1215 mode = mode_str,
1216 width = buffer.width(),
1217 height = buffer.height(),
1218 )
1219 .entered();
1220
1221 let result = match self.screen_mode {
1222 ScreenMode::Inline { ui_height } => {
1223 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1224 }
1225 ScreenMode::InlineAuto { .. } => {
1226 let ui_height = self.effective_ui_height();
1227 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1228 }
1229 ScreenMode::AltScreen => self.present_altscreen(&buffer, cursor, cursor_visible),
1230 };
1231
1232 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1233 let present_bytes = if trace_enabled {
1234 {
1235 let w = self.writer();
1236 let count = w.bytes_written();
1237 w.reset_counter();
1238 Some(count)
1239 }
1240 } else {
1241 None
1242 };
1243 if trace_enabled {
1244 }
1246
1247 if let Ok(stats) = result {
1248 self.record_successful_present(stats.diff_strategy);
1249 if let Some(ref mut trace) = self.render_trace {
1250 let payload_info = match stats.diff_strategy {
1251 DiffStrategy::FullRedraw => {
1252 let payload = build_full_buffer_payload(&buffer, &self.pool);
1253 trace.write_payload(&payload).ok()
1254 }
1255 _ => {
1256 let payload =
1257 build_diff_runs_payload(&buffer, &self.diff_scratch, &self.pool);
1258 trace.write_payload(&payload).ok()
1259 }
1260 };
1261 let (payload_kind, payload_path) = match payload_info {
1262 Some(info) => (info.kind, Some(info.path)),
1263 None => ("none", None),
1264 };
1265 let payload_path_ref = payload_path.as_deref();
1266 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1267 let ui_anchor = ui_anchor_str(self.ui_anchor);
1268 let frame = RenderTraceFrame {
1269 cols: buffer.width(),
1270 rows: buffer.height(),
1271 mode: mode_str,
1272 ui_height: stats.ui_height,
1273 ui_anchor,
1274 diff_strategy,
1275 diff_cells: stats.diff_cells,
1276 diff_runs: stats.diff_runs,
1277 present_bytes: present_bytes.unwrap_or(0),
1278 render_us: None,
1279 present_us,
1280 payload_kind,
1281 payload_path: payload_path_ref,
1282 trace_us: None,
1283 };
1284 let _ = trace.record_frame(frame, &buffer, &self.pool);
1285 }
1286
1287 self.clone_buf = self.spare_buffer.take();
1289 self.spare_buffer = self.prev_buffer.take();
1290 self.prev_buffer = Some(buffer);
1291 return Ok(());
1292 }
1293
1294 self.invalidate_after_present_error();
1295 result.map(|_| ())
1296 }
1297
1298 fn decide_diff(&mut self, buffer: &Buffer) -> DiffDecision {
1299 let prev_dims = self
1300 .prev_buffer
1301 .as_ref()
1302 .map(|prev| (prev.width(), prev.height()));
1303 if prev_dims.is_none() || prev_dims != Some((buffer.width(), buffer.height())) {
1304 self.full_redraw_probe = 0;
1305 self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1306 return DiffDecision {
1307 strategy: DiffStrategy::FullRedraw,
1308 has_diff: false,
1309 };
1310 }
1311
1312 if self.full_redraw_interval_due() {
1313 self.full_redraw_probe = 0;
1314 self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1315 return DiffDecision {
1316 strategy: DiffStrategy::FullRedraw,
1317 has_diff: false,
1318 };
1319 }
1320
1321 let dirty_rows = buffer.dirty_row_count();
1322 let width = buffer.width() as usize;
1323 let height = buffer.height() as usize;
1324 let mut span_stats_snapshot: Option<DirtySpanStats> = None;
1325 let mut dirty_scan_cells_estimate = dirty_rows.saturating_mul(width);
1326
1327 if self.diff_config.bayesian_enabled {
1328 let span_stats = buffer.dirty_span_stats();
1329 if span_stats.span_coverage_cells > 0 {
1330 dirty_scan_cells_estimate = span_stats.span_coverage_cells;
1331 }
1332 span_stats_snapshot = Some(span_stats);
1333 }
1334
1335 let mut strategy = if self.diff_config.bayesian_enabled {
1337 self.diff_strategy.select_with_scan_estimate(
1339 buffer.width(),
1340 buffer.height(),
1341 dirty_rows,
1342 dirty_scan_cells_estimate,
1343 )
1344 } else {
1345 if self.diff_config.dirty_rows_enabled && dirty_rows < buffer.height() as usize {
1347 DiffStrategy::DirtyRows
1348 } else {
1349 DiffStrategy::Full
1350 }
1351 };
1352
1353 if !self.diff_config.dirty_rows_enabled && strategy == DiffStrategy::DirtyRows {
1355 strategy = DiffStrategy::Full;
1356 if self.diff_config.bayesian_enabled {
1357 self.diff_strategy
1358 .override_last_strategy(strategy, "dirty_rows_disabled");
1359 }
1360 }
1361
1362 if strategy == DiffStrategy::FullRedraw {
1364 if self.full_redraw_probe >= FULL_REDRAW_PROBE_INTERVAL {
1365 self.full_redraw_probe = 0;
1366 let probed = if self.diff_config.dirty_rows_enabled
1367 && dirty_rows < buffer.height() as usize
1368 {
1369 DiffStrategy::DirtyRows
1370 } else {
1371 DiffStrategy::Full
1372 };
1373 if probed != strategy {
1374 strategy = probed;
1375 if self.diff_config.bayesian_enabled {
1376 self.diff_strategy
1377 .override_last_strategy(strategy, "full_redraw_probe");
1378 }
1379 }
1380 } else {
1381 self.full_redraw_probe = self.full_redraw_probe.saturating_add(1);
1382 }
1383 } else {
1384 self.full_redraw_probe = 0;
1385 }
1386
1387 let mut has_diff = false;
1388 match strategy {
1389 DiffStrategy::Full => {
1390 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1391 self.diff_scratch.compute_into(prev, buffer);
1392 has_diff = true;
1393 }
1394 DiffStrategy::DirtyRows => {
1395 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1396 self.diff_scratch.compute_dirty_into(prev, buffer);
1397 has_diff = true;
1398 }
1399 DiffStrategy::FullRedraw => {}
1400 }
1401
1402 let mut scan_cost_estimate = 0usize;
1403 let mut fallback_reason: &'static str = "none";
1404 let tile_stats = if strategy == DiffStrategy::DirtyRows {
1405 self.diff_scratch.last_tile_stats()
1406 } else {
1407 None
1408 };
1409
1410 if self.diff_config.bayesian_enabled && has_diff {
1412 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1413 let (scan_cost, reason) = estimate_diff_scan_cost(
1414 strategy,
1415 dirty_rows,
1416 width,
1417 height,
1418 &span_stats,
1419 tile_stats,
1420 );
1421 let scanned_cells = scan_cost.max(self.diff_scratch.len());
1422 self.diff_strategy
1423 .observe(scanned_cells, self.diff_scratch.len());
1424 span_stats_snapshot = Some(span_stats);
1425 scan_cost_estimate = scan_cost;
1426 fallback_reason = reason;
1427 }
1428
1429 if let Some(evidence) = self.diff_strategy.last_evidence() {
1430 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1431 let (scan_cost, reason) = if span_stats_snapshot.is_some() {
1432 (scan_cost_estimate, fallback_reason)
1433 } else {
1434 estimate_diff_scan_cost(
1435 strategy,
1436 dirty_rows,
1437 width,
1438 height,
1439 &span_stats,
1440 tile_stats,
1441 )
1442 };
1443 let span_coverage_pct = if evidence.total_cells == 0 {
1444 0.0
1445 } else {
1446 (span_stats.span_coverage_cells as f64 / evidence.total_cells as f64) * 100.0
1447 };
1448 let span_count = span_stats.total_spans;
1449 let max_span_len = span_stats.max_span_len;
1450 let event_idx = self.diff_evidence_idx;
1451 self.diff_evidence_idx = self.diff_evidence_idx.saturating_add(1);
1452 let tile_used = tile_stats.is_some_and(|stats| stats.fallback.is_none());
1453 let tile_fallback = tile_stats
1454 .and_then(|stats| stats.fallback)
1455 .map(TileDiffFallback::as_str)
1456 .unwrap_or("none");
1457 let run_id = json_escape(&self.diff_evidence_run_id);
1458 let strategy_json = json_escape(&strategy.to_string());
1459 let guard_reason_json = json_escape(evidence.guard_reason);
1460 let fallback_reason_json = json_escape(reason);
1461 let tile_fallback_json = json_escape(tile_fallback);
1462 let schema_version = crate::evidence_sink::EVIDENCE_SCHEMA_VERSION;
1463 let screen_mode = match self.screen_mode {
1464 ScreenMode::Inline { .. } => "inline",
1465 ScreenMode::InlineAuto { .. } => "inline_auto",
1466 ScreenMode::AltScreen => "altscreen",
1467 };
1468 let (
1469 tile_w,
1470 tile_h,
1471 tiles_x,
1472 tiles_y,
1473 dirty_tiles,
1474 dirty_cells,
1475 dirty_tile_ratio,
1476 dirty_cell_ratio,
1477 scanned_tiles,
1478 skipped_tiles,
1479 scan_cells_estimate,
1480 sat_build_cells,
1481 ) = if let Some(stats) = tile_stats {
1482 (
1483 stats.tile_w,
1484 stats.tile_h,
1485 stats.tiles_x,
1486 stats.tiles_y,
1487 stats.dirty_tiles,
1488 stats.dirty_cells,
1489 stats.dirty_tile_ratio,
1490 stats.dirty_cell_ratio,
1491 stats.scanned_tiles,
1492 stats.skipped_tiles,
1493 stats.scan_cells_estimate,
1494 stats.sat_build_cells,
1495 )
1496 } else {
1497 (0, 0, 0, 0, 0, 0, 0.0, 0.0, 0, 0, 0, 0)
1498 };
1499 let tile_size = tile_w as usize * tile_h as usize;
1500 let dirty_tile_count = dirty_tiles;
1501 let skipped_tile_count = skipped_tiles;
1502 let sat_build_cost_est = sat_build_cells;
1503
1504 set_diff_snapshot(Some(DiffDecisionSnapshot {
1505 event_idx,
1506 screen_mode: screen_mode.to_string(),
1507 cols: u16::try_from(width).unwrap_or(u16::MAX),
1508 rows: u16::try_from(height).unwrap_or(u16::MAX),
1509 evidence: evidence.clone(),
1510 span_count,
1511 span_coverage_pct,
1512 max_span_len,
1513 scan_cost_estimate: scan_cost,
1514 fallback_reason: reason.to_string(),
1515 tile_used,
1516 tile_fallback: tile_fallback.to_string(),
1517 strategy_used: strategy,
1518 }));
1519
1520 trace!(
1521 strategy = %strategy,
1522 selected = %evidence.strategy,
1523 cost_full = evidence.cost_full,
1524 cost_dirty = evidence.cost_dirty,
1525 cost_redraw = evidence.cost_redraw,
1526 dirty_rows = evidence.dirty_rows,
1527 total_rows = evidence.total_rows,
1528 total_cells = evidence.total_cells,
1529 bayesian_enabled = self.diff_config.bayesian_enabled,
1530 dirty_rows_enabled = self.diff_config.dirty_rows_enabled,
1531 "diff strategy selected"
1532 );
1533 if let Some(ref sink) = self.evidence_sink {
1534 let line = format!(
1535 r#"{{"schema_version":"{}","event":"diff_decision","run_id":"{}","event_idx":{},"screen_mode":"{}","cols":{},"rows":{},"strategy":"{}","cost_full":{:.6},"cost_dirty":{:.6},"cost_redraw":{:.6},"posterior_mean":{:.6},"posterior_variance":{:.6},"alpha":{:.6},"beta":{:.6},"guard_reason":"{}","hysteresis_applied":{},"hysteresis_ratio":{:.6},"dirty_rows":{},"total_rows":{},"total_cells":{},"span_count":{},"span_coverage_pct":{:.6},"max_span_len":{},"fallback_reason":"{}","scan_cost_estimate":{},"tile_used":{},"tile_fallback":"{}","tile_w":{},"tile_h":{},"tile_size":{},"tiles_x":{},"tiles_y":{},"dirty_tiles":{},"dirty_tile_count":{},"dirty_cells":{},"dirty_tile_ratio":{:.6},"dirty_cell_ratio":{:.6},"scanned_tiles":{},"skipped_tiles":{},"skipped_tile_count":{},"tile_scan_cells_estimate":{},"sat_build_cost_est":{},"bayesian_enabled":{},"dirty_rows_enabled":{}}}"#,
1536 schema_version,
1537 run_id,
1538 event_idx,
1539 screen_mode,
1540 width,
1541 height,
1542 strategy_json,
1543 evidence.cost_full,
1544 evidence.cost_dirty,
1545 evidence.cost_redraw,
1546 evidence.posterior_mean,
1547 evidence.posterior_variance,
1548 evidence.alpha,
1549 evidence.beta,
1550 guard_reason_json,
1551 evidence.hysteresis_applied,
1552 evidence.hysteresis_ratio,
1553 evidence.dirty_rows,
1554 evidence.total_rows,
1555 evidence.total_cells,
1556 span_count,
1557 span_coverage_pct,
1558 max_span_len,
1559 fallback_reason_json,
1560 scan_cost,
1561 tile_used,
1562 tile_fallback_json,
1563 tile_w,
1564 tile_h,
1565 tile_size,
1566 tiles_x,
1567 tiles_y,
1568 dirty_tiles,
1569 dirty_tile_count,
1570 dirty_cells,
1571 dirty_tile_ratio,
1572 dirty_cell_ratio,
1573 scanned_tiles,
1574 skipped_tiles,
1575 skipped_tile_count,
1576 scan_cells_estimate,
1577 sat_build_cost_est,
1578 self.diff_config.bayesian_enabled,
1579 self.diff_config.dirty_rows_enabled,
1580 );
1581 let _ = sink.write_jsonl(&line);
1582 }
1583 }
1584
1585 self.last_diff_strategy = Some(strategy);
1586 DiffDecision { strategy, has_diff }
1587 }
1588
1589 fn full_redraw_interval_due(&self) -> bool {
1590 self.diff_config.full_redraw_interval_frames > 0
1591 && self.frames_since_full_redraw >= self.diff_config.full_redraw_interval_frames
1592 }
1593
1594 fn record_successful_present(&mut self, strategy: DiffStrategy) {
1595 if strategy == DiffStrategy::FullRedraw {
1596 self.frames_since_full_redraw = 0;
1597 } else {
1598 self.frames_since_full_redraw = self.frames_since_full_redraw.saturating_add(1);
1599 }
1600 }
1601
1602 fn invalidate_after_present_error(&mut self) {
1603 self.prev_buffer = None;
1604 self.last_inline_region = None;
1605 self.reset_diff_strategy();
1606 }
1607
1608 fn present_inline(
1614 &mut self,
1615 buffer: &Buffer,
1616 ui_height: u16,
1617 cursor: Option<(u16, u16)>,
1618 cursor_visible: bool,
1619 ) -> io::Result<FrameEmitStats> {
1620 let sync_output_enabled = self.capabilities.use_sync_output();
1621 let render_mode = inline_strategy_str(self.inline_strategy);
1622 let _inline_span = info_span!(
1623 "inline.render",
1624 inline_height = ui_height,
1625 scrollback_preserved = tracing::field::Empty,
1626 render_mode,
1627 )
1628 .entered();
1629
1630 let result = (|| -> io::Result<FrameEmitStats> {
1631 let visible_height = ui_height.min(self.term_height);
1632 let ui_y_start = self.ui_start_row();
1633 let current_region = InlineRegion {
1634 start: ui_y_start,
1635 height: visible_height,
1636 };
1637
1638 if sync_output_enabled && !self.in_sync_block {
1640 self.in_sync_block = true;
1643 if let Err(err) = self.writer().write_all(SYNC_BEGIN) {
1644 let _ = self.writer().write_all(SYNC_END);
1647 self.in_sync_block = false;
1648 let _ = self.writer().flush();
1649 return Err(err);
1650 }
1651 }
1652
1653 self.writer().write_all(CURSOR_SAVE)?;
1655 self.cursor_saved = true;
1656
1657 self.set_cursor_visibility(false)?;
1660
1661 {
1663 let _span = debug_span!("ftui.render.scroll_region").entered();
1664 if visible_height > 0 {
1665 match self.inline_strategy {
1666 InlineStrategy::ScrollRegion | InlineStrategy::Hybrid => {
1667 self.activate_scroll_region(visible_height)?;
1668 }
1669 InlineStrategy::OverlayRedraw => {}
1670 }
1671 } else if self.scroll_region_active {
1672 self.deactivate_scroll_region()?;
1673 }
1674 }
1675
1676 self.clear_inline_region_diff(current_region)?;
1677
1678 let mut diff_strategy = DiffStrategy::FullRedraw;
1679 let mut diff_us = 0u64;
1680 let mut emit_stats = EmitStats {
1681 diff_cells: 0,
1682 diff_runs: 0,
1683 };
1684
1685 if visible_height > 0 {
1686 let dims_changed = self.prev_buffer.as_ref().map(|b| (b.width(), b.height()))
1689 != Some((buffer.width(), buffer.height()));
1690
1691 if self.prev_buffer.is_none() || dims_changed {
1692 self.clear_rows(ui_y_start, visible_height)?;
1693 } else {
1694 let buf_height = buffer.height().min(visible_height);
1697 if buf_height < visible_height {
1698 let clear_start = ui_y_start.saturating_add(buf_height);
1699 let clear_height = visible_height.saturating_sub(buf_height);
1700 self.clear_rows(clear_start, clear_height)?;
1701 }
1702 }
1703
1704 let diff_start = if self.timing_enabled {
1706 Some(Instant::now())
1707 } else {
1708 None
1709 };
1710 let decision = {
1711 let _span = debug_span!("ftui.render.diff_compute").entered();
1712 self.decide_diff(buffer)
1713 };
1714 if let Some(start) = diff_start {
1715 diff_us = start.elapsed().as_micros() as u64;
1716 }
1717 diff_strategy = decision.strategy;
1718
1719 {
1721 let _span = debug_span!("ftui.render.emit").entered();
1722
1723 let presenter = self.presenter.as_mut().expect("presenter consumed");
1726 presenter.reset();
1727 presenter.set_viewport_offset_y(ui_y_start);
1728
1729 if decision.has_diff {
1730 presenter.prepare_runs(&self.diff_scratch);
1731 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1733
1734 emit_stats.diff_cells = self.diff_scratch.len();
1735 emit_stats.diff_runs = self.diff_scratch.runs().len();
1736 } else {
1737 let render_height = buffer.height().min(visible_height);
1741 let full = BufferDiff::full(buffer.width(), render_height);
1742 presenter.prepare_runs(&full);
1743 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1744
1745 emit_stats.diff_cells = full.len();
1746 emit_stats.diff_runs = full.runs().len();
1747 }
1748
1749 presenter.finish_frame()?;
1750 }
1751 }
1752
1753 self.writer().write_all(CURSOR_RESTORE)?;
1755 self.cursor_saved = false;
1756
1757 let mut show_cursor = false;
1758 if cursor_visible
1759 && let Some((cx, cy)) = cursor
1760 && cx < buffer.width()
1761 && cy < buffer.height()
1762 && cy < visible_height
1763 {
1764 let abs_y = ui_y_start.saturating_add(cy);
1766 write!(
1767 self.writer(),
1768 "\x1b[{};{}H",
1769 abs_y.saturating_add(1),
1770 cx.saturating_add(1)
1771 )?;
1772 show_cursor = true;
1773 }
1774 self.set_cursor_visibility(show_cursor)?;
1775
1776 if sync_output_enabled && self.in_sync_block {
1778 self.writer().write_all(SYNC_END)?;
1779 self.in_sync_block = false;
1780 } else if !sync_output_enabled {
1781 self.in_sync_block = false;
1784 }
1785
1786 self.writer().flush()?;
1787 self.last_inline_region = if visible_height > 0 {
1788 Some(current_region)
1789 } else {
1790 None
1791 };
1792
1793 if self.timing_enabled {
1794 self.last_present_timings = Some(PresentTimings { diff_us });
1795 }
1796
1797 Ok(FrameEmitStats {
1798 diff_strategy,
1799 diff_cells: emit_stats.diff_cells,
1800 diff_runs: emit_stats.diff_runs,
1801 ui_height: visible_height,
1802 })
1803 })();
1804
1805 if result.is_err() {
1806 _inline_span.record("scrollback_preserved", false);
1807 warn!(
1808 inline_height = ui_height,
1809 render_mode, "scrollback preservation failed during inline render"
1810 );
1811 self.best_effort_inline_cleanup();
1812 } else {
1813 _inline_span.record("scrollback_preserved", true);
1814 }
1815
1816 result
1817 }
1818
1819 fn present_altscreen(
1821 &mut self,
1822 buffer: &Buffer,
1823 cursor: Option<(u16, u16)>,
1824 cursor_visible: bool,
1825 ) -> io::Result<FrameEmitStats> {
1826 let sync_output_enabled = self.capabilities.use_sync_output();
1827 let diff_start = if self.timing_enabled {
1828 Some(Instant::now())
1829 } else {
1830 None
1831 };
1832 let decision = {
1833 let _span = debug_span!("ftui.render.diff_compute").entered();
1834 self.decide_diff(buffer)
1835 };
1836 let diff_us = diff_start
1837 .map(|start| start.elapsed().as_micros() as u64)
1838 .unwrap_or(0);
1839
1840 if sync_output_enabled && !self.in_sync_block {
1843 self.in_sync_block = true;
1846 if let Err(err) = self.writer().write_all(SYNC_BEGIN) {
1847 let _ = self.writer().write_all(SYNC_END);
1850 self.in_sync_block = false;
1851 let _ = self.writer().flush();
1852 return Err(err);
1853 }
1854 }
1855
1856 let operation_result = (|| -> io::Result<FrameEmitStats> {
1857 self.set_cursor_visibility(false)?;
1860
1861 let emit_stats = {
1862 let _span = debug_span!("ftui.render.emit").entered();
1863 let presenter = self.presenter.as_mut().expect("presenter consumed");
1864
1865 presenter.reset();
1868 presenter.set_viewport_offset_y(0);
1870
1871 let stats = if decision.has_diff {
1872 presenter.prepare_runs(&self.diff_scratch);
1873 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1874
1875 EmitStats {
1876 diff_cells: self.diff_scratch.len(),
1877 diff_runs: self.diff_scratch.runs().len(),
1878 }
1879 } else {
1880 self.diff_scratch.fill_full(buffer.width(), buffer.height());
1882 presenter.prepare_runs(&self.diff_scratch);
1883 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1884
1885 EmitStats {
1886 diff_cells: (buffer.width() as usize) * (buffer.height() as usize),
1887 diff_runs: buffer.height() as usize,
1888 }
1889 };
1890
1891 presenter.finish_frame()?;
1892 stats
1893 };
1894
1895 let mut show_cursor = false;
1896 if cursor_visible
1897 && let Some((cx, cy)) = cursor
1898 && cx < buffer.width()
1899 && cy < buffer.height()
1900 {
1901 write!(
1903 self.writer(),
1904 "\x1b[{};{}H",
1905 cy.saturating_add(1),
1906 cx.saturating_add(1)
1907 )?;
1908 show_cursor = true;
1909 }
1910 self.set_cursor_visibility(show_cursor)?;
1911
1912 if self.timing_enabled {
1913 self.last_present_timings = Some(PresentTimings { diff_us });
1914 }
1915
1916 Ok(FrameEmitStats {
1917 diff_strategy: decision.strategy,
1918 diff_cells: emit_stats.diff_cells,
1919 diff_runs: emit_stats.diff_runs,
1920 ui_height: 0,
1921 })
1922 })();
1923
1924 if operation_result.is_err()
1925 && let Some(ref mut presenter) = self.presenter
1926 {
1927 presenter.finish_frame_best_effort();
1928 }
1929
1930 let sync_end_result = if sync_output_enabled && self.in_sync_block {
1932 let res = self.writer().write_all(SYNC_END);
1933 if res.is_ok() {
1934 self.in_sync_block = false;
1935 }
1936 Some(res)
1937 } else {
1938 if !sync_output_enabled {
1939 self.in_sync_block = false;
1942 }
1943 None
1944 };
1945 let flush_result = self.writer().flush();
1946
1947 let cleanup_error = sync_end_result
1950 .and_then(Result::err)
1951 .or_else(|| flush_result.err());
1952 if let Some(err) = cleanup_error {
1953 return Err(err);
1954 }
1955 operation_result
1956 }
1957
1958 #[allow(dead_code)] fn create_full_diff(&self, buffer: &Buffer) -> BufferDiff {
1964 BufferDiff::full(buffer.width(), buffer.height())
1965 }
1966
1967 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
1978 let sanitized = sanitize(text);
1982 let text = sanitized.as_ref();
1983 match self.screen_mode {
1984 ScreenMode::Inline { ui_height } => {
1985 if !self.position_cursor_for_log(ui_height)? {
1986 return Ok(());
1987 }
1988 if !self.scroll_region_active {
1991 self.prev_buffer = None;
1992 self.last_inline_region = None;
1993 self.reset_diff_strategy();
1994 }
1995
1996 self.writer().write_all(text.as_bytes())?;
1997 self.writer().flush()
1998 }
1999 ScreenMode::InlineAuto { .. } => {
2000 let ui_height = self.effective_ui_height();
2002 if !self.position_cursor_for_log(ui_height)? {
2003 return Ok(());
2004 }
2005 if !self.scroll_region_active {
2007 self.prev_buffer = None;
2008 self.last_inline_region = None;
2009 self.reset_diff_strategy();
2010 }
2011
2012 self.writer().write_all(text.as_bytes())?;
2013 self.writer().flush()
2014 }
2015 ScreenMode::AltScreen => {
2016 Ok(())
2019 }
2020 }
2021 }
2022
2023 fn position_cursor_for_log(&mut self, ui_height: u16) -> io::Result<bool> {
2030 let visible_height = ui_height.min(self.term_height);
2031 if visible_height >= self.term_height {
2032 return Ok(false);
2034 }
2035
2036 let log_row = match self.ui_anchor {
2037 UiAnchor::Bottom => {
2038 self.term_height.saturating_sub(visible_height)
2041 }
2042 UiAnchor::Top => {
2043 self.term_height
2046 }
2047 };
2048
2049 write!(self.writer(), "\x1b[{};1H", log_row)?;
2051 Ok(true)
2052 }
2053
2054 pub fn clear_screen(&mut self) -> io::Result<()> {
2056 let mut first_error = None;
2057 if self.in_sync_block {
2058 if self.capabilities.use_sync_output()
2059 && let Err(err) = self.writer().write_all(SYNC_END)
2060 {
2061 first_error = Some(err);
2062 }
2063 self.in_sync_block = false;
2064 }
2065 if self.cursor_saved {
2066 if let Err(err) = self.writer().write_all(CURSOR_RESTORE) {
2067 first_error.get_or_insert(err);
2068 }
2069 self.cursor_saved = false;
2070 }
2071 if self.scroll_region_active {
2072 if let Err(err) = self.writer().write_all(b"\x1b[r") {
2073 first_error.get_or_insert(err);
2074 }
2075 self.scroll_region_active = false;
2076 }
2077 if let Err(err) = self.writer().write_all(b"\x1b[2J\x1b[1;1H") {
2078 first_error.get_or_insert(err);
2079 }
2080 if let Err(err) = self.writer().flush() {
2081 first_error.get_or_insert(err);
2082 }
2083 self.prev_buffer = None;
2084 self.last_inline_region = None;
2085 self.reset_diff_strategy();
2086 if let Some(err) = first_error {
2087 Err(err)
2088 } else {
2089 Ok(())
2090 }
2091 }
2092
2093 fn set_cursor_visibility(&mut self, visible: bool) -> io::Result<()> {
2094 if self.cursor_visible == visible {
2095 return Ok(());
2096 }
2097 self.cursor_visible = visible;
2098 if visible {
2099 self.writer().write_all(b"\x1b[?25h")?;
2100 } else {
2101 self.writer().write_all(b"\x1b[?25l")?;
2102 }
2103 Ok(())
2104 }
2105
2106 pub fn hide_cursor(&mut self) -> io::Result<()> {
2108 self.set_cursor_visibility(false)?;
2109 self.writer().flush()
2110 }
2111
2112 pub fn show_cursor(&mut self) -> io::Result<()> {
2114 self.set_cursor_visibility(true)?;
2115 self.writer().flush()
2116 }
2117
2118 pub fn flush(&mut self) -> io::Result<()> {
2120 self.writer().flush()
2121 }
2122
2123 pub fn pool(&self) -> &GraphemePool {
2125 &self.pool
2126 }
2127
2128 pub fn pool_mut(&mut self) -> &mut GraphemePool {
2130 &mut self.pool
2131 }
2132
2133 pub fn links(&self) -> &LinkRegistry {
2135 &self.links
2136 }
2137
2138 pub fn links_mut(&mut self) -> &mut LinkRegistry {
2140 &mut self.links
2141 }
2142
2143 pub fn pool_and_links_mut(&mut self) -> (&mut GraphemePool, &mut LinkRegistry) {
2147 (&mut self.pool, &mut self.links)
2148 }
2149
2150 pub fn capabilities(&self) -> &TerminalCapabilities {
2152 &self.capabilities
2153 }
2154
2155 pub fn into_inner(mut self) -> Option<W> {
2160 self.cleanup();
2161 self.presenter.take()?.into_inner().ok()
2163 }
2164
2165 pub fn gc(&mut self, extra_buffer: Option<&Buffer>) {
2173 let mut buffers = Vec::with_capacity(2);
2174 if let Some(ref buf) = self.prev_buffer {
2175 buffers.push(buf);
2176 }
2177 if let Some(buf) = extra_buffer {
2178 buffers.push(buf);
2179 }
2180 self.pool.gc(&buffers);
2181 }
2182
2183 pub fn estimate_memory_usage(&self) -> usize {
2185 let mut total = 0;
2186 if let Some(b) = &self.prev_buffer {
2188 total += b.width() as usize * b.height() as usize * 16;
2189 }
2190 if let Some(b) = &self.spare_buffer {
2191 total += b.width() as usize * b.height() as usize * 16;
2192 }
2193 if let Some(b) = &self.clone_buf {
2194 total += b.width() as usize * b.height() as usize * 16;
2195 }
2196 total += self.pool.capacity() * 32;
2198 total += self.links.estimate_memory();
2200 total
2201 }
2202
2203 fn best_effort_inline_cleanup(&mut self) {
2207 let Some(ref mut presenter) = self.presenter else {
2208 return;
2209 };
2210 presenter.finish_frame_best_effort();
2211 let writer = presenter.counting_writer_mut();
2212
2213 let _ = writer.write_all(SGR_BG_DEFAULT);
2216
2217 if self.in_sync_block {
2220 if self.capabilities.use_sync_output() {
2221 let _ = writer.write_all(SYNC_END);
2222 }
2223 self.in_sync_block = false;
2224 }
2225
2226 let _ = writer.write_all(CURSOR_RESTORE);
2227 self.cursor_saved = false;
2228
2229 let _ = writer.write_all(b"\x1b[r");
2230 self.scroll_region_active = false;
2231
2232 let _ = writer.write_all(b"\x1b[?25h");
2233 self.cursor_visible = true;
2234 let _ = writer.flush();
2235 }
2236
2237 fn cleanup(&mut self) {
2239 let Some(ref mut presenter) = self.presenter else {
2240 return; };
2242 presenter.finish_frame_best_effort();
2243 let writer = presenter.counting_writer_mut();
2244
2245 let _ = writer.write_all(SGR_BG_DEFAULT);
2247
2248 if self.in_sync_block {
2250 if self.capabilities.use_sync_output() {
2251 let _ = writer.write_all(SYNC_END);
2252 }
2253 self.in_sync_block = false;
2254 }
2255
2256 if self.cursor_saved {
2258 let _ = writer.write_all(CURSOR_RESTORE);
2259 self.cursor_saved = false;
2260 }
2261
2262 if self.scroll_region_active {
2264 let _ = writer.write_all(b"\x1b[r");
2265 self.scroll_region_active = false;
2266 }
2267
2268 let _ = writer.write_all(b"\x1b[?25h");
2270 self.cursor_visible = true;
2271
2272 let _ = writer.flush();
2274
2275 if let Some(ref mut trace) = self.render_trace {
2276 let _ = trace.finish(None);
2277 }
2278 }
2279}
2280
2281impl<W: Write> Drop for TerminalWriter<W> {
2282 fn drop(&mut self) {
2283 if matches!(
2285 self.screen_mode,
2286 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
2287 ) {
2288 INLINE_ACTIVE_WIDGETS.fetch_sub(1, Ordering::SeqCst);
2289 }
2290 self.cleanup();
2291 }
2292}
2293
2294#[cfg(test)]
2295mod tests {
2296 use super::*;
2297 use ftui_render::cell::{Cell, CellAttrs, CellContent, PackedRgba, StyleFlags};
2298 use std::cell::RefCell;
2299 use std::io;
2300 use std::path::PathBuf;
2301 use std::rc::Rc;
2302 use std::sync::atomic::{AtomicUsize, Ordering};
2303
2304 fn max_cursor_row(output: &[u8]) -> u16 {
2305 let mut max_row = 0u16;
2306 let mut i = 0;
2307 while i + 2 < output.len() {
2308 if output[i] == 0x1b && output[i + 1] == b'[' {
2309 let mut j = i + 2;
2310 let mut row: u16 = 0;
2311 let mut saw_row = false;
2312 while j < output.len() && output[j].is_ascii_digit() {
2313 saw_row = true;
2314 row = row
2315 .saturating_mul(10)
2316 .saturating_add((output[j] - b'0') as u16);
2317 j += 1;
2318 }
2319 if saw_row && j < output.len() && output[j] == b';' {
2320 j += 1;
2321 let mut saw_col = false;
2322 while j < output.len() && output[j].is_ascii_digit() {
2323 saw_col = true;
2324 j += 1;
2325 }
2326 if saw_col && j < output.len() && output[j] == b'H' {
2327 max_row = max_row.max(row);
2328 }
2329 }
2330 }
2331 i += 1;
2332 }
2333 max_row
2334 }
2335
2336 fn basic_caps() -> TerminalCapabilities {
2337 TerminalCapabilities::basic()
2338 }
2339
2340 fn full_caps() -> TerminalCapabilities {
2341 let mut caps = TerminalCapabilities::basic();
2342 caps.true_color = true;
2343 caps.sync_output = true;
2344 caps
2345 }
2346
2347 fn find_nth(haystack: &[u8], needle: &[u8], nth: usize) -> Option<usize> {
2348 if nth == 0 {
2349 return None;
2350 }
2351 let mut count = 0;
2352 let mut i = 0;
2353 while i + needle.len() <= haystack.len() {
2354 if &haystack[i..i + needle.len()] == needle {
2355 count += 1;
2356 if count == nth {
2357 return Some(i);
2358 }
2359 }
2360 i += 1;
2361 }
2362 None
2363 }
2364
2365 fn temp_evidence_path(label: &str) -> PathBuf {
2366 static COUNTER: AtomicUsize = AtomicUsize::new(0);
2367 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2368 let mut path = std::env::temp_dir();
2369 path.push(format!(
2370 "ftui_{}_{}_{}.jsonl",
2371 label,
2372 std::process::id(),
2373 id
2374 ));
2375 path
2376 }
2377
2378 #[derive(Default)]
2379 struct FaultState {
2380 bytes: Vec<u8>,
2381 write_calls: usize,
2382 injected_failure_triggered: bool,
2383 }
2384
2385 struct SingleWriteFaultWriter {
2386 state: Rc<RefCell<FaultState>>,
2387 fail_on_call: usize,
2388 max_chunk_len: usize,
2389 }
2390
2391 impl SingleWriteFaultWriter {
2392 fn new(state: Rc<RefCell<FaultState>>, fail_on_call: usize, max_chunk_len: usize) -> Self {
2393 Self {
2394 state,
2395 fail_on_call,
2396 max_chunk_len: max_chunk_len.max(1),
2397 }
2398 }
2399 }
2400
2401 impl Write for SingleWriteFaultWriter {
2402 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
2403 let mut state = self.state.borrow_mut();
2404 state.write_calls = state.write_calls.saturating_add(1);
2405 if !state.injected_failure_triggered && state.write_calls == self.fail_on_call {
2406 state.injected_failure_triggered = true;
2407 return Err(io::Error::other("injected partial-write fault"));
2408 }
2409
2410 let write_len = buf.len().min(self.max_chunk_len);
2411 state.bytes.extend_from_slice(&buf[..write_len]);
2412 Ok(write_len)
2413 }
2414
2415 fn flush(&mut self) -> io::Result<()> {
2416 Ok(())
2417 }
2418 }
2419
2420 #[test]
2421 fn new_creates_writer() {
2422 let output = Vec::new();
2423 let writer = TerminalWriter::new(
2424 output,
2425 ScreenMode::Inline { ui_height: 10 },
2426 UiAnchor::Bottom,
2427 basic_caps(),
2428 );
2429 assert_eq!(writer.ui_height(), 10);
2430 }
2431
2432 #[test]
2433 fn ui_start_row_bottom_anchor() {
2434 let output = Vec::new();
2435 let mut writer = TerminalWriter::new(
2436 output,
2437 ScreenMode::Inline { ui_height: 10 },
2438 UiAnchor::Bottom,
2439 basic_caps(),
2440 );
2441 writer.set_size(80, 24);
2442 assert_eq!(writer.ui_start_row(), 14); }
2444
2445 #[test]
2446 fn ui_start_row_top_anchor() {
2447 let output = Vec::new();
2448 let mut writer = TerminalWriter::new(
2449 output,
2450 ScreenMode::Inline { ui_height: 10 },
2451 UiAnchor::Top,
2452 basic_caps(),
2453 );
2454 writer.set_size(80, 24);
2455 assert_eq!(writer.ui_start_row(), 0);
2456 }
2457
2458 #[test]
2459 fn ui_start_row_altscreen() {
2460 let output = Vec::new();
2461 let mut writer = TerminalWriter::new(
2462 output,
2463 ScreenMode::AltScreen,
2464 UiAnchor::Bottom,
2465 basic_caps(),
2466 );
2467 writer.set_size(80, 24);
2468 assert_eq!(writer.ui_start_row(), 0);
2469 }
2470
2471 #[test]
2472 fn present_ui_inline_saves_restores_cursor() {
2473 let mut output = Vec::new();
2474 {
2475 let mut writer = TerminalWriter::new(
2476 &mut output,
2477 ScreenMode::Inline { ui_height: 5 },
2478 UiAnchor::Bottom,
2479 basic_caps(),
2480 );
2481 writer.set_size(10, 10);
2482
2483 let buffer = Buffer::new(10, 5);
2484 writer.present_ui(&buffer, None, true).unwrap();
2485 }
2486
2487 assert!(output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE));
2489 assert!(
2490 output
2491 .windows(CURSOR_RESTORE.len())
2492 .any(|w| w == CURSOR_RESTORE)
2493 );
2494 }
2495
2496 #[test]
2497 fn present_ui_with_sync_output() {
2498 let mut output = Vec::new();
2499 {
2500 let mut writer = TerminalWriter::new(
2501 &mut output,
2502 ScreenMode::Inline { ui_height: 5 },
2503 UiAnchor::Bottom,
2504 full_caps(),
2505 );
2506 writer.set_size(10, 10);
2507
2508 let buffer = Buffer::new(10, 5);
2509 writer.present_ui(&buffer, None, true).unwrap();
2510 }
2511
2512 assert!(output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN));
2514 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2515 }
2516
2517 #[test]
2518 fn present_ui_altscreen_closes_stale_sync_block_when_policy_allows_sync() {
2519 let mut output = Vec::new();
2520 {
2521 let mut writer = TerminalWriter::new(
2522 &mut output,
2523 ScreenMode::AltScreen,
2524 UiAnchor::Bottom,
2525 full_caps(),
2526 );
2527 writer.set_size(8, 2);
2528 writer.in_sync_block = true;
2529
2530 let mut buffer = Buffer::new(8, 2);
2531 buffer.set_raw(0, 0, Cell::from_char('X'));
2532 writer.present_ui(&buffer, None, true).unwrap();
2533
2534 assert!(
2535 !writer.in_sync_block,
2536 "present_altscreen must close stale sync blocks"
2537 );
2538 }
2539
2540 assert!(
2541 output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2542 "sync end should be emitted when stale sync state is detected"
2543 );
2544 }
2545
2546 #[test]
2547 fn present_ui_altscreen_stale_sync_block_skips_sync_end_in_mux() {
2548 let mut output = Vec::new();
2549 {
2550 let mut writer = TerminalWriter::new(
2551 &mut output,
2552 ScreenMode::AltScreen,
2553 UiAnchor::Bottom,
2554 mux_caps(),
2555 );
2556 writer.set_size(8, 2);
2557 writer.in_sync_block = true;
2558
2559 let mut buffer = Buffer::new(8, 2);
2560 buffer.set_raw(0, 0, Cell::from_char('X'));
2561 writer.present_ui(&buffer, None, true).unwrap();
2562
2563 assert!(
2564 !writer.in_sync_block,
2565 "present_altscreen must clear stale sync state"
2566 );
2567 }
2568
2569 assert!(
2570 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2571 "sync end must be suppressed when policy disables synchronized output"
2572 );
2573 }
2574
2575 #[test]
2576 fn present_ui_altscreen_sanitizes_grapheme_escape_payloads() {
2577 let mut output = Vec::new();
2578 {
2579 let mut writer = TerminalWriter::new(
2580 &mut output,
2581 ScreenMode::AltScreen,
2582 UiAnchor::Bottom,
2583 basic_caps(),
2584 );
2585 writer.set_size(12, 1);
2586
2587 let gid = writer
2588 .pool_mut()
2589 .intern("ok\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}", 6);
2590 let mut buffer = Buffer::new(12, 1);
2591 buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
2592
2593 writer.present_ui(&buffer, None, true).unwrap();
2594 }
2595
2596 let output_str = String::from_utf8_lossy(&output);
2597 assert!(
2598 output_str.contains("oktail"),
2599 "sanitized grapheme content should preserve visible payload"
2600 );
2601 assert!(
2602 !output_str.contains("52;c;SGVsbG8"),
2603 "OSC payload must not be forwarded by alt-screen emitter"
2604 );
2605 assert!(
2606 !output_str.contains('\u{009d}'),
2607 "C1 controls must be stripped from alt-screen grapheme output"
2608 );
2609 }
2610
2611 #[test]
2612 fn present_ui_inline_skips_sync_output_in_mux() {
2613 let mut output = Vec::new();
2614 {
2615 let mut writer = TerminalWriter::new(
2616 &mut output,
2617 ScreenMode::Inline { ui_height: 5 },
2618 UiAnchor::Bottom,
2619 mux_caps(),
2620 );
2621 writer.set_size(10, 10);
2622
2623 let buffer = Buffer::new(10, 5);
2624 writer.present_ui(&buffer, None, true).unwrap();
2625 }
2626
2627 assert!(
2628 !output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN),
2629 "sync begin must be suppressed in tmux/screen/zellij environments"
2630 );
2631 assert!(
2632 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2633 "sync end must be suppressed in tmux/screen/zellij environments"
2634 );
2635 }
2636
2637 #[test]
2638 fn present_ui_altscreen_skips_sync_output_in_mux() {
2639 let mut output = Vec::new();
2640 {
2641 let mut writer = TerminalWriter::new(
2642 &mut output,
2643 ScreenMode::AltScreen,
2644 UiAnchor::Bottom,
2645 mux_caps(),
2646 );
2647 writer.set_size(10, 10);
2648
2649 let buffer = Buffer::new(10, 5);
2650 writer.present_ui(&buffer, None, true).unwrap();
2651 }
2652
2653 assert!(
2654 !output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN),
2655 "sync begin must be suppressed in tmux/screen/zellij environments"
2656 );
2657 assert!(
2658 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2659 "sync end must be suppressed in tmux/screen/zellij environments"
2660 );
2661 }
2662
2663 #[test]
2664 fn present_ui_inline_skips_hyperlinks_in_mux() {
2665 let mut output = Vec::new();
2666 {
2667 let mut caps = mux_caps();
2668 caps.osc8_hyperlinks = true;
2669
2670 let mut writer = TerminalWriter::new(
2671 &mut output,
2672 ScreenMode::Inline { ui_height: 2 },
2673 UiAnchor::Bottom,
2674 caps,
2675 );
2676 writer.set_size(8, 4);
2677
2678 let link_id = writer.links_mut().register("https://example.com");
2679 let mut buffer = Buffer::new(8, 2);
2680 buffer.set_raw(
2681 0,
2682 0,
2683 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2684 );
2685 writer.present_ui(&buffer, None, true).unwrap();
2686 }
2687
2688 assert!(
2689 !output.windows(b"\x1b]8;".len()).any(|w| w == b"\x1b]8;"),
2690 "OSC 8 sequences must be suppressed by mux hyperlink policy"
2691 );
2692 }
2693
2694 #[test]
2695 fn present_ui_inline_closes_hyperlinks_at_frame_end() {
2696 let mut output = Vec::new();
2697 {
2698 let mut caps = full_caps();
2699 caps.osc8_hyperlinks = true;
2700
2701 let mut writer = TerminalWriter::new(
2702 &mut output,
2703 ScreenMode::Inline { ui_height: 2 },
2704 UiAnchor::Bottom,
2705 caps,
2706 );
2707 writer.set_size(8, 4);
2708
2709 let link_id = writer.links_mut().register("https://example.com");
2710 let mut buffer = Buffer::new(8, 2);
2711 buffer.set_raw(
2712 0,
2713 0,
2714 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2715 );
2716 writer.present_ui(&buffer, None, true).unwrap();
2717 }
2718
2719 let open = b"\x1b]8;;https://example.com\x07";
2720 let close = b"\x1b]8;;\x07";
2721 let open_pos = output
2722 .windows(open.len())
2723 .position(|window| window == open)
2724 .expect("expected OSC 8 open sequence");
2725 let close_pos = output
2726 .windows(close.len())
2727 .position(|window| window == close)
2728 .expect("expected OSC 8 close sequence");
2729 assert!(
2730 open_pos < close_pos,
2731 "hyperlink must close before frame end"
2732 );
2733 }
2734
2735 #[test]
2736 fn present_ui_altscreen_skips_hyperlinks_in_mux() {
2737 let mut output = Vec::new();
2738 {
2739 let mut caps = mux_caps();
2740 caps.osc8_hyperlinks = true;
2741
2742 let mut writer =
2743 TerminalWriter::new(&mut output, ScreenMode::AltScreen, UiAnchor::Bottom, caps);
2744 writer.set_size(8, 4);
2745
2746 let link_id = writer.links_mut().register("https://example.com");
2747 let mut buffer = Buffer::new(8, 2);
2748 buffer.set_raw(
2749 0,
2750 0,
2751 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2752 );
2753 writer.present_ui(&buffer, None, true).unwrap();
2754 }
2755
2756 assert!(
2757 !output.windows(b"\x1b]8;".len()).any(|w| w == b"\x1b]8;"),
2758 "OSC 8 sequences must be suppressed by mux hyperlink policy"
2759 );
2760 }
2761
2762 #[test]
2763 fn present_ui_altscreen_closes_hyperlinks_at_frame_end() {
2764 let mut output = Vec::new();
2765 {
2766 let mut caps = full_caps();
2767 caps.osc8_hyperlinks = true;
2768
2769 let mut writer =
2770 TerminalWriter::new(&mut output, ScreenMode::AltScreen, UiAnchor::Bottom, caps);
2771 writer.set_size(8, 4);
2772
2773 let link_id = writer.links_mut().register("https://example.com");
2774 let mut buffer = Buffer::new(8, 2);
2775 buffer.set_raw(
2776 0,
2777 0,
2778 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2779 );
2780 writer.present_ui(&buffer, None, true).unwrap();
2781 }
2782
2783 let open = b"\x1b]8;;https://example.com\x07";
2784 let close = b"\x1b]8;;\x07";
2785 let open_pos = output
2786 .windows(open.len())
2787 .position(|window| window == open)
2788 .expect("expected OSC 8 open sequence");
2789 let close_pos = output
2790 .windows(close.len())
2791 .position(|window| window == close)
2792 .expect("expected OSC 8 close sequence");
2793 assert!(
2794 open_pos < close_pos,
2795 "hyperlink must close before frame end"
2796 );
2797 }
2798
2799 #[test]
2800 fn present_ui_hides_cursor_when_requested() {
2801 let mut output = Vec::new();
2802 {
2803 let mut writer = TerminalWriter::new(
2804 &mut output,
2805 ScreenMode::AltScreen,
2806 UiAnchor::Bottom,
2807 basic_caps(),
2808 );
2809 writer.set_size(10, 5);
2810
2811 let buffer = Buffer::new(10, 5);
2812 writer.present_ui(&buffer, None, false).unwrap();
2813 }
2814
2815 assert!(
2816 output.windows(6).any(|w| w == b"\x1b[?25l"),
2817 "expected cursor hide sequence"
2818 );
2819 }
2820
2821 #[test]
2822 fn present_ui_visible_with_position_temporarily_hides_cursor() {
2823 let mut output = Vec::new();
2824 {
2825 let mut writer = TerminalWriter::new(
2826 &mut output,
2827 ScreenMode::AltScreen,
2828 UiAnchor::Bottom,
2829 basic_caps(),
2830 );
2831 writer.set_size(10, 5);
2832
2833 let buffer = Buffer::new(10, 5);
2834 writer.present_ui(&buffer, Some((0, 0)), true).unwrap();
2835 }
2836
2837 assert!(
2838 output.windows(6).any(|w| w == b"\x1b[?25l"),
2839 "expected cursor hide during frame emission"
2840 );
2841 }
2842
2843 #[test]
2844 fn present_ui_visible_without_position_hides_cursor() {
2845 let mut output = Vec::new();
2846 {
2847 let mut writer = TerminalWriter::new(
2848 &mut output,
2849 ScreenMode::AltScreen,
2850 UiAnchor::Bottom,
2851 basic_caps(),
2852 );
2853 writer.set_size(10, 5);
2854
2855 let buffer = Buffer::new(10, 5);
2856 writer.present_ui(&buffer, None, true).unwrap();
2857 }
2858
2859 assert!(
2860 output.windows(6).any(|w| w == b"\x1b[?25l"),
2861 "expected cursor hide sequence when no explicit cursor position exists"
2862 );
2863 }
2864
2865 #[test]
2866 fn write_log_in_inline_mode() {
2867 let mut output = Vec::new();
2868 {
2869 let mut writer = TerminalWriter::new(
2870 &mut output,
2871 ScreenMode::Inline { ui_height: 5 },
2872 UiAnchor::Bottom,
2873 basic_caps(),
2874 );
2875 writer.write_log("test log\n").unwrap();
2876 }
2877
2878 let output_str = String::from_utf8_lossy(&output);
2879 assert!(output_str.contains("test log"));
2880 }
2881
2882 #[test]
2883 fn write_log_in_altscreen_is_noop() {
2884 let mut output = Vec::new();
2885 {
2886 let mut writer = TerminalWriter::new(
2887 &mut output,
2888 ScreenMode::AltScreen,
2889 UiAnchor::Bottom,
2890 basic_caps(),
2891 );
2892 writer.write_log("test log\n").unwrap();
2893 }
2894
2895 let output_str = String::from_utf8_lossy(&output);
2896 assert!(!output_str.contains("test log"));
2898 }
2899
2900 #[test]
2901 fn clear_screen_resets_prev_buffer() {
2902 let mut output = Vec::new();
2903 let mut writer = TerminalWriter::new(
2904 &mut output,
2905 ScreenMode::AltScreen,
2906 UiAnchor::Bottom,
2907 basic_caps(),
2908 );
2909
2910 let buffer = Buffer::new(10, 5);
2912 writer.present_ui(&buffer, None, true).unwrap();
2913 assert!(writer.prev_buffer.is_some());
2914
2915 writer.clear_screen().unwrap();
2917 assert!(writer.prev_buffer.is_none());
2918 }
2919
2920 #[test]
2921 fn set_size_clears_prev_buffer() {
2922 let output = Vec::new();
2923 let mut writer = TerminalWriter::new(
2924 output,
2925 ScreenMode::AltScreen,
2926 UiAnchor::Bottom,
2927 basic_caps(),
2928 );
2929
2930 writer.prev_buffer = Some(Buffer::new(10, 10));
2931 writer.set_size(20, 20);
2932
2933 assert!(writer.prev_buffer.is_none());
2934 }
2935
2936 #[test]
2937 fn inline_auto_resize_clears_cached_height() {
2938 let output = Vec::new();
2939 let mut writer = TerminalWriter::new(
2940 output,
2941 ScreenMode::InlineAuto {
2942 min_height: 3,
2943 max_height: 8,
2944 },
2945 UiAnchor::Bottom,
2946 basic_caps(),
2947 );
2948
2949 writer.set_size(80, 24);
2950 writer.set_auto_ui_height(6);
2951 assert_eq!(writer.auto_ui_height(), Some(6));
2952 assert_eq!(writer.render_height_hint(), 6);
2953
2954 writer.set_size(100, 30);
2955 assert_eq!(writer.auto_ui_height(), None);
2956 assert_eq!(writer.render_height_hint(), 8);
2957 }
2958
2959 #[test]
2960 fn drop_cleanup_restores_cursor() {
2961 let mut output = Vec::new();
2962 {
2963 let mut writer = TerminalWriter::new(
2964 &mut output,
2965 ScreenMode::Inline { ui_height: 5 },
2966 UiAnchor::Bottom,
2967 basic_caps(),
2968 );
2969 writer.cursor_saved = true;
2970 }
2972
2973 assert!(
2975 output
2976 .windows(CURSOR_RESTORE.len())
2977 .any(|w| w == CURSOR_RESTORE)
2978 );
2979 }
2980
2981 #[test]
2982 fn drop_cleanup_ends_sync_block() {
2983 let mut output = Vec::new();
2984 {
2985 let mut writer = TerminalWriter::new(
2986 &mut output,
2987 ScreenMode::Inline { ui_height: 5 },
2988 UiAnchor::Bottom,
2989 full_caps(),
2990 );
2991 writer.in_sync_block = true;
2992 }
2994
2995 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2997 }
2998
2999 #[test]
3000 fn drop_cleanup_skips_sync_end_in_mux_even_with_stale_state() {
3001 let mut output = Vec::new();
3002 {
3003 let mut writer = TerminalWriter::new(
3004 &mut output,
3005 ScreenMode::Inline { ui_height: 5 },
3006 UiAnchor::Bottom,
3007 mux_caps(),
3008 );
3009 writer.in_sync_block = true;
3010 }
3012
3013 assert!(
3014 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
3015 "drop cleanup must not emit sync_end in mux environments"
3016 );
3017 }
3018
3019 #[test]
3020 fn present_multiple_frames_uses_diff() {
3021 use std::io::Cursor;
3022
3023 let output = Cursor::new(Vec::new());
3025 let mut writer = TerminalWriter::new(
3026 output,
3027 ScreenMode::AltScreen,
3028 UiAnchor::Bottom,
3029 basic_caps(),
3030 );
3031 writer.set_size(10, 5);
3032
3033 let mut buffer1 = Buffer::new(10, 5);
3035 buffer1.set_raw(0, 0, Cell::from_char('A'));
3036 writer.present_ui(&buffer1, None, true).unwrap();
3037
3038 writer.present_ui(&buffer1, None, true).unwrap();
3040
3041 let mut buffer2 = buffer1.clone();
3043 buffer2.set_raw(1, 0, Cell::from_char('B'));
3044 writer.present_ui(&buffer2, None, true).unwrap();
3045
3046 }
3049
3050 #[test]
3051 fn cell_content_rendered_correctly() {
3052 let mut output = Vec::new();
3053 {
3054 let mut writer = TerminalWriter::new(
3055 &mut output,
3056 ScreenMode::AltScreen,
3057 UiAnchor::Bottom,
3058 basic_caps(),
3059 );
3060 writer.set_size(10, 5);
3061
3062 let mut buffer = Buffer::new(10, 5);
3063 buffer.set_raw(0, 0, Cell::from_char('H'));
3064 buffer.set_raw(1, 0, Cell::from_char('i'));
3065 buffer.set_raw(2, 0, Cell::from_char('!'));
3066 writer.present_ui(&buffer, None, true).unwrap();
3067 }
3068
3069 let output_str = String::from_utf8_lossy(&output);
3070 assert!(output_str.contains('H'));
3071 assert!(output_str.contains('i'));
3072 assert!(output_str.contains('!'));
3073 }
3074
3075 #[test]
3076 fn resize_reanchors_ui_region() {
3077 let output = Vec::new();
3078 let mut writer = TerminalWriter::new(
3079 output,
3080 ScreenMode::Inline { ui_height: 10 },
3081 UiAnchor::Bottom,
3082 basic_caps(),
3083 );
3084
3085 writer.set_size(80, 24);
3087 assert_eq!(writer.ui_start_row(), 14);
3088
3089 writer.set_size(80, 40);
3091 assert_eq!(writer.ui_start_row(), 30);
3092
3093 writer.set_size(80, 15);
3095 assert_eq!(writer.ui_start_row(), 5);
3096 }
3097
3098 #[test]
3099 fn inline_auto_height_clamps_and_uses_max_for_render() {
3100 let output = Vec::new();
3101 let mut writer = TerminalWriter::new(
3102 output,
3103 ScreenMode::InlineAuto {
3104 min_height: 3,
3105 max_height: 8,
3106 },
3107 UiAnchor::Bottom,
3108 basic_caps(),
3109 );
3110 writer.set_size(80, 24);
3111
3112 assert_eq!(writer.ui_height(), 3);
3114 assert_eq!(writer.auto_ui_height(), None);
3115
3116 assert_eq!(writer.render_height_hint(), 8);
3118
3119 writer.set_auto_ui_height(6);
3121 assert_eq!(writer.render_height_hint(), 6);
3122
3123 writer.clear_auto_ui_height();
3125 assert_eq!(writer.render_height_hint(), 8);
3126
3127 writer.set_auto_ui_height(3);
3129 assert_eq!(writer.auto_ui_height(), Some(3));
3130 assert_eq!(writer.ui_height(), 3);
3131
3132 writer.clear_auto_ui_height();
3133 assert_eq!(writer.render_height_hint(), 8);
3134
3135 writer.set_auto_ui_height(10);
3137 assert_eq!(writer.ui_height(), 8);
3138
3139 writer.set_auto_ui_height(1);
3141 assert_eq!(writer.ui_height(), 3);
3142 }
3143
3144 #[test]
3145 fn resize_with_top_anchor_stays_at_zero() {
3146 let output = Vec::new();
3147 let mut writer = TerminalWriter::new(
3148 output,
3149 ScreenMode::Inline { ui_height: 10 },
3150 UiAnchor::Top,
3151 basic_caps(),
3152 );
3153
3154 writer.set_size(80, 24);
3155 assert_eq!(writer.ui_start_row(), 0);
3156
3157 writer.set_size(80, 40);
3158 assert_eq!(writer.ui_start_row(), 0);
3159 }
3160
3161 #[test]
3162 fn inline_mode_never_clears_full_screen() {
3163 let mut output = Vec::new();
3164 {
3165 let mut writer = TerminalWriter::new(
3166 &mut output,
3167 ScreenMode::Inline { ui_height: 5 },
3168 UiAnchor::Bottom,
3169 basic_caps(),
3170 );
3171 writer.set_size(10, 10);
3172
3173 let buffer = Buffer::new(10, 5);
3174 writer.present_ui(&buffer, None, true).unwrap();
3175 }
3176
3177 let has_ed2 = output.windows(4).any(|w| w == b"\x1b[2J");
3179 assert!(!has_ed2, "Inline mode should never use full screen clear");
3180
3181 assert!(output.windows(ERASE_LINE.len()).any(|w| w == ERASE_LINE));
3183 }
3184
3185 #[test]
3186 fn present_after_log_maintains_cursor_position() {
3187 let mut output = Vec::new();
3188 {
3189 let mut writer = TerminalWriter::new(
3190 &mut output,
3191 ScreenMode::Inline { ui_height: 5 },
3192 UiAnchor::Bottom,
3193 basic_caps(),
3194 );
3195 writer.set_size(10, 10);
3196
3197 let buffer = Buffer::new(10, 5);
3199 writer.present_ui(&buffer, None, true).unwrap();
3200
3201 writer.write_log("log line\n").unwrap();
3203
3204 writer.present_ui(&buffer, None, true).unwrap();
3206 }
3207
3208 let save_count = output
3210 .windows(CURSOR_SAVE.len())
3211 .filter(|w| *w == CURSOR_SAVE)
3212 .count();
3213 assert_eq!(save_count, 2, "Should have saved cursor twice");
3214
3215 let restore_count = output
3217 .windows(CURSOR_RESTORE.len())
3218 .filter(|w| *w == CURSOR_RESTORE)
3219 .count();
3220 assert!(
3222 restore_count >= 2,
3223 "Should have restored cursor at least twice"
3224 );
3225 }
3226
3227 #[test]
3228 fn ui_height_bounds_check() {
3229 let output = Vec::new();
3230 let mut writer = TerminalWriter::new(
3231 output,
3232 ScreenMode::Inline { ui_height: 100 },
3233 UiAnchor::Bottom,
3234 basic_caps(),
3235 );
3236
3237 writer.set_size(80, 10);
3239
3240 assert_eq!(writer.ui_start_row(), 0);
3242 }
3243
3244 #[test]
3245 fn inline_ui_height_clamped_to_terminal_height() {
3246 let mut output = Vec::new();
3247 {
3248 let mut writer = TerminalWriter::new(
3249 &mut output,
3250 ScreenMode::Inline { ui_height: 10 },
3251 UiAnchor::Bottom,
3252 basic_caps(),
3253 );
3254 writer.set_size(8, 3);
3255 let buffer = Buffer::new(8, 10);
3256 writer.present_ui(&buffer, None, true).unwrap();
3257 }
3258
3259 let max_row = max_cursor_row(&output);
3260 assert!(
3261 max_row <= 3,
3262 "cursor row {} exceeds terminal height",
3263 max_row
3264 );
3265 }
3266
3267 #[test]
3268 fn inline_shrink_clears_stale_rows() {
3269 let mut output = Vec::new();
3270 {
3271 let mut writer = TerminalWriter::new(
3272 &mut output,
3273 ScreenMode::InlineAuto {
3274 min_height: 1,
3275 max_height: 6,
3276 },
3277 UiAnchor::Bottom,
3278 basic_caps(),
3279 );
3280 writer.set_size(10, 10);
3281
3282 let buffer = Buffer::new(10, 6);
3283 writer.set_auto_ui_height(6);
3284 writer.present_ui(&buffer, None, true).unwrap();
3285
3286 writer.set_auto_ui_height(3);
3287 writer.present_ui(&buffer, None, true).unwrap();
3288 }
3289
3290 let second_save = find_nth(&output, CURSOR_SAVE, 2).expect("expected second cursor save");
3291 let after_save = &output[second_save..];
3292 let restore_idx = after_save
3293 .windows(CURSOR_RESTORE.len())
3294 .position(|w| w == CURSOR_RESTORE)
3295 .expect("expected cursor restore after second save");
3296 let segment = &after_save[..restore_idx];
3297 let erase_count = segment
3298 .windows(ERASE_LINE.len())
3299 .filter(|w| *w == ERASE_LINE)
3300 .count();
3301 let bg_reset_count = segment
3302 .windows(SGR_BG_DEFAULT.len())
3303 .filter(|w| *w == SGR_BG_DEFAULT)
3304 .count();
3305
3306 assert_eq!(erase_count, 6, "expected clears for stale + new rows");
3307 assert!(
3308 bg_reset_count >= 2,
3309 "expected background resets before row clears"
3310 );
3311 }
3312
3313 fn scroll_region_caps() -> TerminalCapabilities {
3317 let mut caps = TerminalCapabilities::basic();
3318 caps.scroll_region = true;
3319 caps.sync_output = true;
3320 caps
3321 }
3322
3323 fn hybrid_caps() -> TerminalCapabilities {
3325 let mut caps = TerminalCapabilities::basic();
3326 caps.scroll_region = true;
3327 caps
3328 }
3329
3330 fn mux_caps() -> TerminalCapabilities {
3332 let mut caps = TerminalCapabilities::basic();
3333 caps.scroll_region = true;
3334 caps.sync_output = true;
3335 caps.in_tmux = true;
3336 caps
3337 }
3338
3339 #[test]
3340 fn scroll_region_bounds_bottom_anchor() {
3341 let mut output = Vec::new();
3342 {
3343 let mut writer = TerminalWriter::new(
3344 &mut output,
3345 ScreenMode::Inline { ui_height: 5 },
3346 UiAnchor::Bottom,
3347 scroll_region_caps(),
3348 );
3349 writer.set_size(10, 10);
3350 let buffer = Buffer::new(10, 5);
3351 writer.present_ui(&buffer, None, true).unwrap();
3352 }
3353
3354 let seq = b"\x1b[1;5r";
3355 assert!(
3356 output.windows(seq.len()).any(|w| w == seq),
3357 "expected scroll region for bottom anchor"
3358 );
3359 }
3360
3361 #[test]
3362 fn scroll_region_bounds_top_anchor() {
3363 let mut output = Vec::new();
3364 {
3365 let mut writer = TerminalWriter::new(
3366 &mut output,
3367 ScreenMode::Inline { ui_height: 5 },
3368 UiAnchor::Top,
3369 scroll_region_caps(),
3370 );
3371 writer.set_size(10, 10);
3372 let buffer = Buffer::new(10, 5);
3373 writer.present_ui(&buffer, None, true).unwrap();
3374 }
3375
3376 let seq = b"\x1b[6;10r";
3377 assert!(
3378 output.windows(seq.len()).any(|w| w == seq),
3379 "expected scroll region for top anchor"
3380 );
3381 let cursor_seq = b"\x1b[6;1H";
3382 assert!(
3383 output.windows(cursor_seq.len()).any(|w| w == cursor_seq),
3384 "expected cursor move into log region for top anchor"
3385 );
3386 }
3387
3388 #[test]
3389 fn present_ui_inline_resets_style_before_cursor_restore() {
3390 let mut output = Vec::new();
3391 {
3392 let mut writer = TerminalWriter::new(
3393 &mut output,
3394 ScreenMode::Inline { ui_height: 2 },
3395 UiAnchor::Bottom,
3396 basic_caps(),
3397 );
3398 writer.set_size(5, 5);
3399 let mut buffer = Buffer::new(5, 2);
3400 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(PackedRgba::RED));
3401 writer.present_ui(&buffer, None, true).unwrap();
3402 }
3403
3404 let seq = b"\x1b[0m\x1b8";
3405 assert!(
3406 output.windows(seq.len()).any(|w| w == seq),
3407 "expected SGR reset before cursor restore in inline mode"
3408 );
3409 }
3410
3411 #[test]
3412 fn strategy_selected_from_capabilities() {
3413 let w = TerminalWriter::new(
3415 Vec::new(),
3416 ScreenMode::Inline { ui_height: 5 },
3417 UiAnchor::Bottom,
3418 basic_caps(),
3419 );
3420 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3421
3422 let w = TerminalWriter::new(
3424 Vec::new(),
3425 ScreenMode::Inline { ui_height: 5 },
3426 UiAnchor::Bottom,
3427 scroll_region_caps(),
3428 );
3429 assert_eq!(w.inline_strategy(), InlineStrategy::ScrollRegion);
3430
3431 let w = TerminalWriter::new(
3433 Vec::new(),
3434 ScreenMode::Inline { ui_height: 5 },
3435 UiAnchor::Bottom,
3436 hybrid_caps(),
3437 );
3438 assert_eq!(w.inline_strategy(), InlineStrategy::Hybrid);
3439
3440 let w = TerminalWriter::new(
3442 Vec::new(),
3443 ScreenMode::Inline { ui_height: 5 },
3444 UiAnchor::Bottom,
3445 mux_caps(),
3446 );
3447 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3448 }
3449
3450 #[test]
3451 fn scroll_region_activated_on_present() {
3452 let mut output = Vec::new();
3453 {
3454 let mut writer = TerminalWriter::new(
3455 &mut output,
3456 ScreenMode::Inline { ui_height: 5 },
3457 UiAnchor::Bottom,
3458 scroll_region_caps(),
3459 );
3460 writer.set_size(80, 24);
3461 assert!(!writer.scroll_region_active());
3462
3463 let buffer = Buffer::new(80, 5);
3464 writer.present_ui(&buffer, None, true).unwrap();
3465 assert!(writer.scroll_region_active());
3466 }
3467
3468 let expected = b"\x1b[1;19r";
3470 assert!(
3471 output.windows(expected.len()).any(|w| w == expected),
3472 "Should set scroll region to rows 1-19"
3473 );
3474 }
3475
3476 #[test]
3477 fn scroll_region_not_activated_for_overlay() {
3478 let mut output = Vec::new();
3479 {
3480 let mut writer = TerminalWriter::new(
3481 &mut output,
3482 ScreenMode::Inline { ui_height: 5 },
3483 UiAnchor::Bottom,
3484 basic_caps(),
3485 );
3486 writer.set_size(80, 24);
3487
3488 let buffer = Buffer::new(80, 5);
3489 writer.present_ui(&buffer, None, true).unwrap();
3490 assert!(!writer.scroll_region_active());
3491 }
3492
3493 let decstbm = b"\x1b[1;19r";
3495 assert!(
3496 !output.windows(decstbm.len()).any(|w| w == decstbm),
3497 "OverlayRedraw should not set scroll region"
3498 );
3499 }
3500
3501 #[test]
3502 fn scroll_region_not_activated_in_mux() {
3503 let mut output = Vec::new();
3504 {
3505 let mut writer = TerminalWriter::new(
3506 &mut output,
3507 ScreenMode::Inline { ui_height: 5 },
3508 UiAnchor::Bottom,
3509 mux_caps(),
3510 );
3511 writer.set_size(80, 24);
3512
3513 let buffer = Buffer::new(80, 5);
3514 writer.present_ui(&buffer, None, true).unwrap();
3515 assert!(!writer.scroll_region_active());
3516 }
3517
3518 let decstbm = b"\x1b[1;19r";
3520 assert!(
3521 !output.windows(decstbm.len()).any(|w| w == decstbm),
3522 "Mux environment should not use scroll region"
3523 );
3524 }
3525
3526 #[test]
3527 fn scroll_region_reset_on_cleanup() {
3528 let mut output = Vec::new();
3529 {
3530 let mut writer = TerminalWriter::new(
3531 &mut output,
3532 ScreenMode::Inline { ui_height: 5 },
3533 UiAnchor::Bottom,
3534 scroll_region_caps(),
3535 );
3536 writer.set_size(80, 24);
3537
3538 let buffer = Buffer::new(80, 5);
3539 writer.present_ui(&buffer, None, true).unwrap();
3540 }
3542
3543 let reset = b"\x1b[r";
3545 assert!(
3546 output.windows(reset.len()).any(|w| w == reset),
3547 "Cleanup should reset scroll region"
3548 );
3549 }
3550
3551 #[test]
3552 fn scroll_region_reset_on_resize() {
3553 let output = Vec::new();
3554 let mut writer = TerminalWriter::new(
3555 output,
3556 ScreenMode::Inline { ui_height: 5 },
3557 UiAnchor::Bottom,
3558 scroll_region_caps(),
3559 );
3560 writer.set_size(80, 24);
3561
3562 writer.activate_scroll_region(5).unwrap();
3564 assert!(writer.scroll_region_active());
3565
3566 writer.set_size(80, 40);
3568 assert!(!writer.scroll_region_active());
3569 }
3570
3571 #[test]
3572 fn scroll_region_reactivated_after_resize() {
3573 let mut output = Vec::new();
3574 {
3575 let mut writer = TerminalWriter::new(
3576 &mut output,
3577 ScreenMode::Inline { ui_height: 5 },
3578 UiAnchor::Bottom,
3579 scroll_region_caps(),
3580 );
3581 writer.set_size(80, 24);
3582
3583 let buffer = Buffer::new(80, 5);
3585 writer.present_ui(&buffer, None, true).unwrap();
3586 assert!(writer.scroll_region_active());
3587
3588 writer.set_size(80, 40);
3590 assert!(!writer.scroll_region_active());
3591
3592 let buffer2 = Buffer::new(80, 5);
3594 writer.present_ui(&buffer2, None, true).unwrap();
3595 assert!(writer.scroll_region_active());
3596 }
3597
3598 let new_region = b"\x1b[1;35r";
3600 assert!(
3601 output.windows(new_region.len()).any(|w| w == new_region),
3602 "Should set scroll region to new dimensions after resize"
3603 );
3604 }
3605
3606 #[test]
3607 fn hybrid_strategy_activates_scroll_region() {
3608 let mut output = Vec::new();
3609 {
3610 let mut writer = TerminalWriter::new(
3611 &mut output,
3612 ScreenMode::Inline { ui_height: 5 },
3613 UiAnchor::Bottom,
3614 hybrid_caps(),
3615 );
3616 writer.set_size(80, 24);
3617
3618 let buffer = Buffer::new(80, 5);
3619 writer.present_ui(&buffer, None, true).unwrap();
3620 assert!(writer.scroll_region_active());
3621 }
3622
3623 let expected = b"\x1b[1;19r";
3625 assert!(
3626 output.windows(expected.len()).any(|w| w == expected),
3627 "Hybrid should activate scroll region as optimization"
3628 );
3629 }
3630
3631 #[test]
3632 fn altscreen_does_not_activate_scroll_region() {
3633 let output = Vec::new();
3634 let mut writer = TerminalWriter::new(
3635 output,
3636 ScreenMode::AltScreen,
3637 UiAnchor::Bottom,
3638 scroll_region_caps(),
3639 );
3640 writer.set_size(80, 24);
3641
3642 let buffer = Buffer::new(80, 24);
3643 writer.present_ui(&buffer, None, true).unwrap();
3644 assert!(!writer.scroll_region_active());
3645 }
3646
3647 #[test]
3648 fn scroll_region_still_saves_restores_cursor() {
3649 let mut output = Vec::new();
3650 {
3651 let mut writer = TerminalWriter::new(
3652 &mut output,
3653 ScreenMode::Inline { ui_height: 5 },
3654 UiAnchor::Bottom,
3655 scroll_region_caps(),
3656 );
3657 writer.set_size(80, 24);
3658
3659 let buffer = Buffer::new(80, 5);
3660 writer.present_ui(&buffer, None, true).unwrap();
3661 }
3662
3663 assert!(
3665 output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE),
3666 "Scroll region mode should still save cursor"
3667 );
3668 assert!(
3669 output
3670 .windows(CURSOR_RESTORE.len())
3671 .any(|w| w == CURSOR_RESTORE),
3672 "Scroll region mode should still restore cursor"
3673 );
3674 }
3675
3676 #[test]
3679 fn write_log_positions_cursor_bottom_anchor() {
3680 let mut output = Vec::new();
3683 {
3684 let mut writer = TerminalWriter::new(
3685 &mut output,
3686 ScreenMode::Inline { ui_height: 5 },
3687 UiAnchor::Bottom,
3688 basic_caps(),
3689 );
3690 writer.set_size(80, 24);
3691 writer.write_log("test log\n").unwrap();
3692 }
3693
3694 let expected_pos = b"\x1b[19;1H";
3698 assert!(
3699 output
3700 .windows(expected_pos.len())
3701 .any(|w| w == expected_pos),
3702 "Log write should position cursor at row 19 for bottom anchor"
3703 );
3704 }
3705
3706 #[test]
3707 fn write_log_positions_cursor_top_anchor() {
3708 let mut output = Vec::new();
3711 {
3712 let mut writer = TerminalWriter::new(
3713 &mut output,
3714 ScreenMode::Inline { ui_height: 5 },
3715 UiAnchor::Top,
3716 basic_caps(),
3717 );
3718 writer.set_size(80, 24);
3719 writer.write_log("test log\n").unwrap();
3720 }
3721
3722 let expected_pos = b"\x1b[24;1H";
3726 assert!(
3727 output
3728 .windows(expected_pos.len())
3729 .any(|w| w == expected_pos),
3730 "Log write should position cursor at row 24 for top anchor"
3731 );
3732 }
3733
3734 #[test]
3735 fn write_log_contains_text() {
3736 let mut output = Vec::new();
3738 {
3739 let mut writer = TerminalWriter::new(
3740 &mut output,
3741 ScreenMode::Inline { ui_height: 5 },
3742 UiAnchor::Bottom,
3743 basic_caps(),
3744 );
3745 writer.set_size(80, 24);
3746 writer.write_log("hello world\n").unwrap();
3747 }
3748
3749 let output_str = String::from_utf8_lossy(&output);
3750 assert!(output_str.contains("hello world"));
3751 }
3752
3753 #[test]
3754 fn write_log_sanitizes_escape_injection_payloads() {
3755 let mut output = Vec::new();
3756 {
3757 let mut writer = TerminalWriter::new(
3758 &mut output,
3759 ScreenMode::Inline { ui_height: 5 },
3760 UiAnchor::Bottom,
3761 basic_caps(),
3762 );
3763 writer.set_size(80, 24);
3764 writer
3765 .write_log("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x\n")
3766 .unwrap();
3767 }
3768
3769 let output_str = String::from_utf8_lossy(&output);
3770 assert!(output_str.contains("safetailx"));
3771 assert!(
3772 !output_str.contains("52;c;SGVsbG8"),
3773 "OSC payload must not be forwarded to terminal output"
3774 );
3775 assert!(
3776 !output_str.contains('\u{009d}'),
3777 "C1 controls must be stripped from log output"
3778 );
3779 }
3780
3781 #[test]
3782 fn write_log_multiple_writes_position_each_time() {
3783 let mut output = Vec::new();
3785 {
3786 let mut writer = TerminalWriter::new(
3787 &mut output,
3788 ScreenMode::Inline { ui_height: 5 },
3789 UiAnchor::Bottom,
3790 basic_caps(),
3791 );
3792 writer.set_size(80, 24);
3793 writer.write_log("first\n").unwrap();
3794 writer.write_log("second\n").unwrap();
3795 }
3796
3797 let expected_pos = b"\x1b[19;1H";
3799 let count = output
3800 .windows(expected_pos.len())
3801 .filter(|w| *w == expected_pos)
3802 .count();
3803 assert_eq!(count, 2, "Should position cursor for each log write");
3804 }
3805
3806 #[test]
3807 fn write_log_after_present_ui_works_correctly() {
3808 let mut output = Vec::new();
3810 {
3811 let mut writer = TerminalWriter::new(
3812 &mut output,
3813 ScreenMode::Inline { ui_height: 5 },
3814 UiAnchor::Bottom,
3815 basic_caps(),
3816 );
3817 writer.set_size(80, 24);
3818
3819 let buffer = Buffer::new(80, 5);
3821 writer.present_ui(&buffer, None, true).unwrap();
3822
3823 writer.write_log("after UI\n").unwrap();
3825 }
3826
3827 let output_str = String::from_utf8_lossy(&output);
3828 assert!(output_str.contains("after UI"));
3829
3830 let expected_pos = b"\x1b[19;1H";
3832 assert!(
3834 output
3835 .windows(expected_pos.len())
3836 .any(|w| w == expected_pos),
3837 "Log write after present_ui should position cursor"
3838 );
3839 }
3840
3841 #[test]
3842 fn write_log_ui_fills_terminal_is_noop() {
3843 let mut output = Vec::new();
3847 {
3848 let mut writer = TerminalWriter::new(
3849 &mut output,
3850 ScreenMode::Inline { ui_height: 24 },
3851 UiAnchor::Bottom,
3852 basic_caps(),
3853 );
3854 writer.set_size(80, 24);
3855 writer.write_log("should still write\n").unwrap();
3856 }
3857 assert!(
3859 !output
3860 .windows(b"should still write".len())
3861 .any(|w| w == b"should still write"),
3862 "write_log should not emit log text when UI fills the terminal"
3863 );
3864 }
3865
3866 #[test]
3867 fn write_log_with_scroll_region_active() {
3868 let mut output = Vec::new();
3870 {
3871 let mut writer = TerminalWriter::new(
3872 &mut output,
3873 ScreenMode::Inline { ui_height: 5 },
3874 UiAnchor::Bottom,
3875 scroll_region_caps(),
3876 );
3877 writer.set_size(80, 24);
3878
3879 let buffer = Buffer::new(80, 5);
3881 writer.present_ui(&buffer, None, true).unwrap();
3882 assert!(writer.scroll_region_active());
3883
3884 writer.write_log("with scroll region\n").unwrap();
3886 }
3887
3888 let output_str = String::from_utf8_lossy(&output);
3889 assert!(output_str.contains("with scroll region"));
3890 }
3891
3892 #[test]
3893 fn log_write_cursor_position_not_in_ui_region_bottom_anchor() {
3894 let mut output = Vec::new();
3900 {
3901 let mut writer = TerminalWriter::new(
3902 &mut output,
3903 ScreenMode::Inline { ui_height: 5 },
3904 UiAnchor::Bottom,
3905 basic_caps(),
3906 );
3907 writer.set_size(80, 24);
3908 writer.write_log("test\n").unwrap();
3909 }
3910
3911 let mut found_row = None;
3914 let mut i = 0;
3915 while i + 2 < output.len() {
3916 if output[i] == 0x1b && output[i + 1] == b'[' {
3917 let mut j = i + 2;
3918 let mut row: u16 = 0;
3919 while j < output.len() && output[j].is_ascii_digit() {
3920 row = row * 10 + (output[j] - b'0') as u16;
3921 j += 1;
3922 }
3923 if j < output.len() && output[j] == b';' {
3924 j += 1;
3925 while j < output.len() && output[j].is_ascii_digit() {
3926 j += 1;
3927 }
3928 if j < output.len() && output[j] == b'H' {
3929 found_row = Some(row);
3930 }
3931 }
3932 }
3933 i += 1;
3934 }
3935
3936 if let Some(row) = found_row {
3937 assert!(
3939 row < 20,
3940 "Log cursor row {} should be below UI start row 20",
3941 row
3942 );
3943 }
3944 }
3945
3946 #[test]
3947 fn log_write_cursor_position_not_in_ui_region_top_anchor() {
3948 let mut output = Vec::new();
3954 {
3955 let mut writer = TerminalWriter::new(
3956 &mut output,
3957 ScreenMode::Inline { ui_height: 5 },
3958 UiAnchor::Top,
3959 basic_caps(),
3960 );
3961 writer.set_size(80, 24);
3962 writer.write_log("test\n").unwrap();
3963 }
3964
3965 let mut found_row = None;
3967 let mut i = 0;
3968 while i + 2 < output.len() {
3969 if output[i] == 0x1b && output[i + 1] == b'[' {
3970 let mut j = i + 2;
3971 let mut row: u16 = 0;
3972 while j < output.len() && output[j].is_ascii_digit() {
3973 row = row * 10 + (output[j] - b'0') as u16;
3974 j += 1;
3975 }
3976 if j < output.len() && output[j] == b';' {
3977 j += 1;
3978 while j < output.len() && output[j].is_ascii_digit() {
3979 j += 1;
3980 }
3981 if j < output.len() && output[j] == b'H' {
3982 found_row = Some(row);
3983 }
3984 }
3985 }
3986 i += 1;
3987 }
3988
3989 if let Some(row) = found_row {
3990 assert!(
3992 row > 5,
3993 "Log cursor row {} should be above UI end row 5",
3994 row
3995 );
3996 }
3997 }
3998
3999 #[test]
4000 fn present_ui_positions_cursor_after_restore() {
4001 let mut output = Vec::new();
4002 {
4003 let mut writer = TerminalWriter::new(
4004 &mut output,
4005 ScreenMode::Inline { ui_height: 5 },
4006 UiAnchor::Bottom,
4007 basic_caps(),
4008 );
4009 writer.set_size(80, 24);
4010
4011 let buffer = Buffer::new(80, 5);
4012 writer.present_ui(&buffer, Some((2, 1)), true).unwrap();
4014 }
4015
4016 let expected_pos = b"\x1b[21;3H";
4020
4021 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4023 let after_restore = &output[restore_idx..];
4024
4025 assert!(
4027 after_restore
4028 .windows(expected_pos.len())
4029 .any(|w| w == expected_pos),
4030 "Cursor positioning should happen after restore"
4031 );
4032 }
4033
4034 #[test]
4035 fn present_ui_inline_skips_cursor_position_when_x_is_out_of_bounds() {
4036 let mut output = Vec::new();
4037 {
4038 let mut writer = TerminalWriter::new(
4039 &mut output,
4040 ScreenMode::Inline { ui_height: 5 },
4041 UiAnchor::Bottom,
4042 basic_caps(),
4043 );
4044 writer.set_size(80, 24);
4045
4046 let buffer = Buffer::new(4, 5);
4047 writer.present_ui(&buffer, Some((4, 1)), true).unwrap();
4048 }
4049
4050 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4051 let after_restore = &output[restore_idx..];
4052 let invalid_pos = b"\x1b[21;5H";
4053 assert!(
4054 !after_restore
4055 .windows(invalid_pos.len())
4056 .any(|w| w == invalid_pos),
4057 "inline cursor should not move to x outside the buffer width"
4058 );
4059 }
4060
4061 #[test]
4062 fn present_ui_inline_skips_cursor_position_when_y_is_below_buffer_height() {
4063 let mut output = Vec::new();
4064 {
4065 let mut writer = TerminalWriter::new(
4066 &mut output,
4067 ScreenMode::Inline { ui_height: 5 },
4068 UiAnchor::Bottom,
4069 basic_caps(),
4070 );
4071 writer.set_size(80, 24);
4072
4073 let buffer = Buffer::new(4, 2);
4074 writer.present_ui(&buffer, Some((1, 4)), true).unwrap();
4075 }
4076
4077 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4078 let after_restore = &output[restore_idx..];
4079 let invalid_pos = b"\x1b[24;2H";
4080 assert!(
4081 !after_restore
4082 .windows(invalid_pos.len())
4083 .any(|w| w == invalid_pos),
4084 "inline cursor should not move below the buffer height just because the inline region is taller"
4085 );
4086 }
4087
4088 #[test]
4093 fn runtime_diff_config_default() {
4094 let config = RuntimeDiffConfig::default();
4095 assert!(config.bayesian_enabled);
4096 assert!(config.dirty_rows_enabled);
4097 assert!(config.dirty_span_config.enabled);
4098 assert!(config.tile_diff_config.enabled);
4099 assert!(config.reset_on_resize);
4100 assert!(config.reset_on_invalidation);
4101 assert_eq!(config.full_redraw_interval_frames, 240);
4102 }
4103
4104 #[test]
4105 fn runtime_diff_config_builder() {
4106 let custom_span = DirtySpanConfig::default().with_max_spans_per_row(8);
4107 let tile_config = TileDiffConfig::default()
4108 .with_enabled(false)
4109 .with_tile_size(24, 12)
4110 .with_dense_tile_ratio(0.75)
4111 .with_max_tiles(2048);
4112 let config = RuntimeDiffConfig::new()
4113 .with_bayesian_enabled(false)
4114 .with_dirty_rows_enabled(false)
4115 .with_dirty_span_config(custom_span)
4116 .with_dirty_spans_enabled(false)
4117 .with_tile_diff_config(tile_config)
4118 .with_reset_on_resize(false)
4119 .with_reset_on_invalidation(false)
4120 .with_full_redraw_interval_frames(17);
4121
4122 assert!(!config.bayesian_enabled);
4123 assert!(!config.dirty_rows_enabled);
4124 assert!(!config.dirty_span_config.enabled);
4125 assert_eq!(config.dirty_span_config.max_spans_per_row, 8);
4126 assert!(!config.tile_diff_config.enabled);
4127 assert_eq!(config.tile_diff_config.tile_w, 24);
4128 assert_eq!(config.tile_diff_config.tile_h, 12);
4129 assert_eq!(config.tile_diff_config.max_tiles, 2048);
4130 assert!(!config.reset_on_resize);
4131 assert!(!config.reset_on_invalidation);
4132 assert_eq!(config.full_redraw_interval_frames, 17);
4133 }
4134
4135 #[test]
4136 fn with_diff_config_applies_strategy_config() {
4137 use ftui_render::diff_strategy::DiffStrategyConfig;
4138
4139 let strategy_config = DiffStrategyConfig {
4140 prior_alpha: 5.0,
4141 prior_beta: 5.0,
4142 ..Default::default()
4143 };
4144
4145 let runtime_config =
4146 RuntimeDiffConfig::default().with_strategy_config(strategy_config.clone());
4147
4148 let writer = TerminalWriter::with_diff_config(
4149 Vec::<u8>::new(),
4150 ScreenMode::AltScreen,
4151 UiAnchor::Bottom,
4152 basic_caps(),
4153 runtime_config,
4154 );
4155
4156 let (alpha, beta) = writer.diff_strategy().posterior_params();
4158 assert!((alpha - 5.0).abs() < 0.001);
4159 assert!((beta - 5.0).abs() < 0.001);
4160 }
4161
4162 #[test]
4163 fn with_diff_config_applies_tile_config() {
4164 let tile_config = TileDiffConfig::default()
4165 .with_enabled(false)
4166 .with_tile_size(32, 16)
4167 .with_max_tiles(1024);
4168 let runtime_config = RuntimeDiffConfig::default().with_tile_diff_config(tile_config);
4169
4170 let mut writer = TerminalWriter::with_diff_config(
4171 Vec::<u8>::new(),
4172 ScreenMode::AltScreen,
4173 UiAnchor::Bottom,
4174 basic_caps(),
4175 runtime_config,
4176 );
4177
4178 let applied = writer.diff_scratch.tile_config_mut();
4179 assert!(!applied.enabled);
4180 assert_eq!(applied.tile_w, 32);
4181 assert_eq!(applied.tile_h, 16);
4182 assert_eq!(applied.max_tiles, 1024);
4183 }
4184
4185 #[test]
4186 fn diff_config_accessor() {
4187 let config = RuntimeDiffConfig::default().with_bayesian_enabled(false);
4188
4189 let writer = TerminalWriter::with_diff_config(
4190 Vec::<u8>::new(),
4191 ScreenMode::AltScreen,
4192 UiAnchor::Bottom,
4193 basic_caps(),
4194 config,
4195 );
4196
4197 assert!(!writer.diff_config().bayesian_enabled);
4198 }
4199
4200 #[test]
4201 fn last_diff_strategy_updates_after_present() {
4202 let mut output = Vec::new();
4203 let mut writer = TerminalWriter::with_diff_config(
4204 &mut output,
4205 ScreenMode::AltScreen,
4206 UiAnchor::Bottom,
4207 basic_caps(),
4208 RuntimeDiffConfig::default(),
4209 );
4210 writer.set_size(10, 3);
4211
4212 let mut buffer = Buffer::new(10, 3);
4213 buffer.set_raw(0, 0, Cell::from_char('X'));
4214
4215 assert!(writer.last_diff_strategy().is_none());
4216 writer.present_ui(&buffer, None, false).unwrap();
4217 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4218
4219 buffer.set_raw(1, 1, Cell::from_char('Y'));
4220 writer.present_ui(&buffer, None, false).unwrap();
4221 assert!(writer.last_diff_strategy().is_some());
4222 }
4223
4224 #[test]
4225 fn full_redraw_interval_forces_terminal_resync() {
4226 let mut output = Vec::new();
4227 let mut writer = TerminalWriter::with_diff_config(
4228 &mut output,
4229 ScreenMode::AltScreen,
4230 UiAnchor::Bottom,
4231 basic_caps(),
4232 RuntimeDiffConfig::default()
4233 .with_bayesian_enabled(false)
4234 .with_full_redraw_interval_frames(1),
4235 );
4236 writer.set_size(4, 2);
4237
4238 let mut buffer = Buffer::new(4, 2);
4239 buffer.set_raw(0, 0, Cell::from_char('A'));
4240
4241 writer.present_ui(&buffer, None, false).unwrap();
4242 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4243
4244 writer.present_ui(&buffer, None, false).unwrap();
4245 assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4246
4247 writer.present_ui(&buffer, None, false).unwrap();
4248 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4249 }
4250
4251 #[test]
4252 fn full_redraw_interval_emits_current_frame_after_incremental_baseline() {
4253 let state = Rc::new(RefCell::new(FaultState::default()));
4254 let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), usize::MAX, 1);
4255 let mut writer = TerminalWriter::with_diff_config(
4256 writer_backend,
4257 ScreenMode::AltScreen,
4258 UiAnchor::Bottom,
4259 basic_caps(),
4260 RuntimeDiffConfig::default()
4261 .with_bayesian_enabled(false)
4262 .with_full_redraw_interval_frames(1),
4263 );
4264 writer.set_size(4, 2);
4265
4266 let mut buffer = Buffer::new(4, 2);
4267 buffer.set_raw(0, 0, Cell::from_char('Q'));
4268
4269 writer.present_ui(&buffer, None, false).unwrap();
4270 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4271
4272 buffer.set_raw(1, 0, Cell::from_char('Z'));
4273 writer.present_ui(&buffer, None, false).unwrap();
4274 assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4275
4276 state.borrow_mut().bytes.clear();
4277 writer.present_ui(&buffer, None, false).unwrap();
4278 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4279
4280 let bytes = state.borrow().bytes.clone();
4281 assert!(
4282 bytes.windows(b"QZ".len()).any(|window| window == b"QZ"),
4283 "forced full redraw should emit adjacent current-frame cells"
4284 );
4285 }
4286
4287 #[test]
4288 fn full_redraw_interval_zero_disables_terminal_resync() {
4289 let mut output = Vec::new();
4290 let mut writer = TerminalWriter::with_diff_config(
4291 &mut output,
4292 ScreenMode::AltScreen,
4293 UiAnchor::Bottom,
4294 basic_caps(),
4295 RuntimeDiffConfig::default()
4296 .with_bayesian_enabled(false)
4297 .with_full_redraw_interval_frames(0),
4298 );
4299 writer.set_size(4, 2);
4300
4301 let mut buffer = Buffer::new(4, 2);
4302 buffer.set_raw(0, 0, Cell::from_char('A'));
4303
4304 writer.present_ui(&buffer, None, false).unwrap();
4305 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4306
4307 for _ in 0..5 {
4308 writer.present_ui(&buffer, None, false).unwrap();
4309 assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4310 }
4311 }
4312
4313 #[test]
4314 fn diff_decision_evidence_schema_includes_span_fields() {
4315 let evidence_path = temp_evidence_path("diff_decision_schema");
4316 let sink = EvidenceSink::from_config(
4317 &crate::evidence_sink::EvidenceSinkConfig::enabled_file(&evidence_path),
4318 )
4319 .expect("evidence sink config")
4320 .expect("evidence sink enabled");
4321
4322 let mut writer = TerminalWriter::with_diff_config(
4323 Vec::<u8>::new(),
4324 ScreenMode::AltScreen,
4325 UiAnchor::Bottom,
4326 basic_caps(),
4327 RuntimeDiffConfig::default(),
4328 )
4329 .with_evidence_sink(sink);
4330 writer.set_size(10, 3);
4331
4332 let mut buffer = Buffer::new(10, 3);
4333 buffer.set_raw(0, 0, Cell::from_char('X'));
4334 writer.present_ui(&buffer, None, false).unwrap();
4335
4336 buffer.set_raw(1, 1, Cell::from_char('Y'));
4337 writer.present_ui(&buffer, None, false).unwrap();
4338
4339 let jsonl = std::fs::read_to_string(&evidence_path).expect("read evidence jsonl");
4340 let line = jsonl
4341 .lines()
4342 .find(|line| line.contains("\"event\":\"diff_decision\""))
4343 .expect("diff_decision line");
4344 let value: serde_json::Value = serde_json::from_str(line).expect("valid json");
4345
4346 assert_eq!(
4347 value["schema_version"],
4348 crate::evidence_sink::EVIDENCE_SCHEMA_VERSION
4349 );
4350 assert_eq!(value["event"], "diff_decision");
4351 assert!(
4352 value["run_id"]
4353 .as_str()
4354 .map(|s| !s.is_empty())
4355 .unwrap_or(false),
4356 "run_id should be a non-empty string"
4357 );
4358 assert!(
4359 value["event_idx"].is_number(),
4360 "event_idx should be numeric"
4361 );
4362 assert_eq!(value["screen_mode"], "altscreen");
4363 assert!(value["cols"].is_number(), "cols should be numeric");
4364 assert!(value["rows"].is_number(), "rows should be numeric");
4365 assert!(
4366 value["span_count"].is_number(),
4367 "span_count should be numeric"
4368 );
4369 assert!(
4370 value["span_coverage_pct"].is_number(),
4371 "span_coverage_pct should be numeric"
4372 );
4373 assert!(
4374 value["tile_size"].is_number(),
4375 "tile_size should be numeric"
4376 );
4377 assert!(
4378 value["dirty_tile_count"].is_number(),
4379 "dirty_tile_count should be numeric"
4380 );
4381 assert!(
4382 value["skipped_tile_count"].is_number(),
4383 "skipped_tile_count should be numeric"
4384 );
4385 assert!(
4386 value["sat_build_cost_est"].is_number(),
4387 "sat_build_cost_est should be numeric"
4388 );
4389 assert!(
4390 value["fallback_reason"].is_string(),
4391 "fallback_reason should be string"
4392 );
4393 assert!(
4394 value["scan_cost_estimate"].is_number(),
4395 "scan_cost_estimate should be numeric"
4396 );
4397 assert!(
4398 value["max_span_len"].is_number(),
4399 "max_span_len should be numeric"
4400 );
4401 assert!(
4402 value["guard_reason"].is_string(),
4403 "guard_reason should be a string"
4404 );
4405 assert!(
4406 value["hysteresis_applied"].is_boolean(),
4407 "hysteresis_applied should be boolean"
4408 );
4409 assert!(
4410 value["hysteresis_ratio"].is_number(),
4411 "hysteresis_ratio should be numeric"
4412 );
4413 assert!(
4414 value["fallback_reason"].is_string(),
4415 "fallback_reason should be a string"
4416 );
4417 assert!(
4418 value["scan_cost_estimate"].is_number(),
4419 "scan_cost_estimate should be numeric"
4420 );
4421 }
4422
4423 #[test]
4424 fn diff_strategy_posterior_updates_with_total_cells() {
4425 let mut output = Vec::new();
4426 let mut writer = TerminalWriter::with_diff_config(
4427 &mut output,
4428 ScreenMode::AltScreen,
4429 UiAnchor::Bottom,
4430 basic_caps(),
4431 RuntimeDiffConfig::default(),
4432 );
4433 writer.set_size(10, 10);
4434
4435 let mut buffer = Buffer::new(10, 10);
4436 buffer.set_raw(0, 0, Cell::from_char('A'));
4437 writer.present_ui(&buffer, None, false).unwrap();
4438
4439 let mut buffer2 = Buffer::new(10, 10);
4440 for x in 0..10u16 {
4441 buffer2.set_raw(x, 0, Cell::from_char('X'));
4442 }
4443 writer.present_ui(&buffer2, None, false).unwrap();
4444
4445 let config = writer.diff_strategy().config().clone();
4446 let total_cells = 10usize * 10usize;
4447 let changed = 10usize;
4448 let alpha = config.prior_alpha * config.decay + changed as f64;
4449 let beta = config.prior_beta * config.decay + (total_cells - changed) as f64;
4450 let expected = alpha / (alpha + beta);
4451 let mean = writer.diff_strategy().posterior_mean();
4452 assert!(
4453 (mean - expected).abs() < 1e-9,
4454 "posterior mean should use total_cells; got {mean:.6}, expected {expected:.6}"
4455 );
4456 }
4457
4458 #[test]
4459 fn log_write_without_scroll_region_resets_diff_strategy() {
4460 let mut output = Vec::new();
4463 {
4464 let config = RuntimeDiffConfig::default();
4465 let mut writer = TerminalWriter::with_diff_config(
4466 &mut output,
4467 ScreenMode::Inline { ui_height: 5 },
4468 UiAnchor::Bottom,
4469 basic_caps(), config,
4471 );
4472 writer.set_size(80, 24);
4473
4474 let mut buffer = Buffer::new(80, 5);
4476 buffer.set_raw(0, 0, Cell::from_char('X'));
4477 writer.present_ui(&buffer, None, false).unwrap();
4478
4479 let (_alpha_before, _) = writer.diff_strategy().posterior_params();
4481
4482 buffer.set_raw(1, 1, Cell::from_char('Y'));
4484 writer.present_ui(&buffer, None, false).unwrap();
4485
4486 assert!(!writer.scroll_region_active());
4488 writer.write_log("log message\n").unwrap();
4489
4490 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4492 assert!(
4493 (alpha_after - 1.0).abs() < 0.01 && (beta_after - 19.0).abs() < 0.01,
4494 "posterior should reset to priors after log write: alpha={}, beta={}",
4495 alpha_after,
4496 beta_after
4497 );
4498 }
4499 }
4500
4501 #[test]
4502 fn log_write_with_scroll_region_preserves_diff_strategy() {
4503 let mut output = Vec::new();
4505 {
4506 let config = RuntimeDiffConfig::default();
4507 let mut writer = TerminalWriter::with_diff_config(
4508 &mut output,
4509 ScreenMode::Inline { ui_height: 5 },
4510 UiAnchor::Bottom,
4511 scroll_region_caps(), config,
4513 );
4514 writer.set_size(80, 24);
4515
4516 let mut buffer = Buffer::new(80, 5);
4518 buffer.set_raw(0, 0, Cell::from_char('X'));
4519 writer.present_ui(&buffer, None, false).unwrap();
4520
4521 buffer.set_raw(1, 1, Cell::from_char('Y'));
4522 writer.present_ui(&buffer, None, false).unwrap();
4523
4524 assert!(writer.scroll_region_active());
4525
4526 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4528
4529 writer.write_log("log message\n").unwrap();
4531
4532 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4533 assert!(
4534 (alpha_after - alpha_before).abs() < 0.01
4535 && (beta_after - beta_before).abs() < 0.01,
4536 "posterior should be preserved with scroll region: before=({}, {}), after=({}, {})",
4537 alpha_before,
4538 beta_before,
4539 alpha_after,
4540 beta_after
4541 );
4542 }
4543 }
4544
4545 #[test]
4546 fn strategy_selection_config_flags_applied() {
4547 let config = RuntimeDiffConfig::default()
4549 .with_dirty_rows_enabled(false)
4550 .with_bayesian_enabled(false);
4551
4552 let writer = TerminalWriter::with_diff_config(
4553 Vec::<u8>::new(),
4554 ScreenMode::AltScreen,
4555 UiAnchor::Bottom,
4556 basic_caps(),
4557 config,
4558 );
4559
4560 assert!(!writer.diff_config().dirty_rows_enabled);
4562 assert!(!writer.diff_config().bayesian_enabled);
4563
4564 let (alpha, beta) = writer.diff_strategy().posterior_params();
4566 assert!((alpha - 1.0).abs() < 0.01);
4568 assert!((beta - 19.0).abs() < 0.01);
4569 }
4570
4571 #[test]
4572 fn resize_respects_reset_toggle() {
4573 let config = RuntimeDiffConfig::default().with_reset_on_resize(false);
4575
4576 let mut writer = TerminalWriter::with_diff_config(
4577 Vec::<u8>::new(),
4578 ScreenMode::AltScreen,
4579 UiAnchor::Bottom,
4580 basic_caps(),
4581 config,
4582 );
4583 writer.set_size(80, 24);
4584
4585 let mut buffer = Buffer::new(80, 24);
4587 buffer.set_raw(0, 0, Cell::from_char('X'));
4588 writer.present_ui(&buffer, None, false).unwrap();
4589
4590 let mut buffer2 = Buffer::new(80, 24);
4591 buffer2.set_raw(1, 1, Cell::from_char('Y'));
4592 writer.present_ui(&buffer2, None, false).unwrap();
4593
4594 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4596
4597 writer.set_size(100, 30);
4599
4600 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4601 assert!(
4602 (alpha_after - alpha_before).abs() < 0.01 && (beta_after - beta_before).abs() < 0.01,
4603 "posterior should be preserved when reset_on_resize=false"
4604 );
4605 }
4606
4607 #[test]
4612 fn screen_mode_default_is_altscreen() {
4613 assert_eq!(ScreenMode::default(), ScreenMode::AltScreen);
4614 }
4615
4616 #[test]
4617 fn screen_mode_debug_format() {
4618 let dbg = format!("{:?}", ScreenMode::Inline { ui_height: 7 });
4619 assert!(dbg.contains("Inline"));
4620 assert!(dbg.contains('7'));
4621 }
4622
4623 #[test]
4624 fn screen_mode_inline_auto_debug_format() {
4625 let dbg = format!(
4626 "{:?}",
4627 ScreenMode::InlineAuto {
4628 min_height: 3,
4629 max_height: 10
4630 }
4631 );
4632 assert!(dbg.contains("InlineAuto"));
4633 }
4634
4635 #[test]
4636 fn screen_mode_eq_inline_auto() {
4637 let a = ScreenMode::InlineAuto {
4638 min_height: 2,
4639 max_height: 8,
4640 };
4641 let b = ScreenMode::InlineAuto {
4642 min_height: 2,
4643 max_height: 8,
4644 };
4645 assert_eq!(a, b);
4646 let c = ScreenMode::InlineAuto {
4647 min_height: 2,
4648 max_height: 9,
4649 };
4650 assert_ne!(a, c);
4651 }
4652
4653 #[test]
4654 fn ui_anchor_default_is_bottom() {
4655 assert_eq!(UiAnchor::default(), UiAnchor::Bottom);
4656 }
4657
4658 #[test]
4659 fn ui_anchor_debug_format() {
4660 assert_eq!(format!("{:?}", UiAnchor::Top), "Top");
4661 assert_eq!(format!("{:?}", UiAnchor::Bottom), "Bottom");
4662 }
4663
4664 #[test]
4669 fn width_height_accessors() {
4670 let output = Vec::new();
4671 let mut writer = TerminalWriter::new(
4672 output,
4673 ScreenMode::AltScreen,
4674 UiAnchor::Bottom,
4675 basic_caps(),
4676 );
4677 assert_eq!(writer.width(), 80);
4679 assert_eq!(writer.height(), 24);
4680
4681 writer.set_size(120, 40);
4682 assert_eq!(writer.width(), 120);
4683 assert_eq!(writer.height(), 40);
4684 }
4685
4686 #[test]
4687 fn screen_mode_accessor() {
4688 let writer = TerminalWriter::new(
4689 Vec::new(),
4690 ScreenMode::Inline { ui_height: 5 },
4691 UiAnchor::Top,
4692 basic_caps(),
4693 );
4694 assert_eq!(writer.screen_mode(), ScreenMode::Inline { ui_height: 5 });
4695 }
4696
4697 #[test]
4698 fn capabilities_accessor() {
4699 let caps = full_caps();
4700 let writer = TerminalWriter::new(Vec::new(), ScreenMode::AltScreen, UiAnchor::Bottom, caps);
4701 assert!(writer.capabilities().true_color);
4702 assert!(writer.capabilities().sync_output);
4703 }
4704
4705 #[test]
4710 fn into_inner_returns_writer() {
4711 let writer = TerminalWriter::new(
4712 Vec::new(),
4713 ScreenMode::AltScreen,
4714 UiAnchor::Bottom,
4715 basic_caps(),
4716 );
4717 let inner = writer.into_inner();
4718 assert!(inner.is_some());
4719 }
4720
4721 #[test]
4722 fn into_inner_performs_cleanup() {
4723 let mut writer = TerminalWriter::new(
4724 Vec::new(),
4725 ScreenMode::Inline { ui_height: 5 },
4726 UiAnchor::Bottom,
4727 basic_caps(),
4728 );
4729 writer.cursor_saved = true;
4730 writer.in_sync_block = false;
4731
4732 let inner = writer.into_inner().unwrap();
4733 assert!(
4735 inner
4736 .windows(CURSOR_RESTORE.len())
4737 .any(|w| w == CURSOR_RESTORE),
4738 "into_inner should perform cleanup before returning"
4739 );
4740 }
4741
4742 #[test]
4747 fn take_render_buffer_creates_new_when_no_spare() {
4748 let mut writer = TerminalWriter::new(
4749 Vec::new(),
4750 ScreenMode::AltScreen,
4751 UiAnchor::Bottom,
4752 basic_caps(),
4753 );
4754 let buf = writer.take_render_buffer(80, 24);
4755 assert_eq!(buf.width(), 80);
4756 assert_eq!(buf.height(), 24);
4757 }
4758
4759 #[test]
4760 fn take_render_buffer_reuses_spare_on_match() {
4761 let mut writer = TerminalWriter::new(
4762 Vec::new(),
4763 ScreenMode::AltScreen,
4764 UiAnchor::Bottom,
4765 basic_caps(),
4766 );
4767 writer.spare_buffer = Some(Buffer::new(80, 24));
4769 assert!(writer.spare_buffer.is_some());
4770
4771 let buf = writer.take_render_buffer(80, 24);
4772 assert_eq!(buf.width(), 80);
4773 assert_eq!(buf.height(), 24);
4774 assert!(writer.spare_buffer.is_none());
4776 }
4777
4778 #[test]
4779 fn take_render_buffer_ignores_spare_on_size_mismatch() {
4780 let mut writer = TerminalWriter::new(
4781 Vec::new(),
4782 ScreenMode::AltScreen,
4783 UiAnchor::Bottom,
4784 basic_caps(),
4785 );
4786 writer.spare_buffer = Some(Buffer::new(80, 24));
4787
4788 let buf = writer.take_render_buffer(100, 30);
4790 assert_eq!(buf.width(), 100);
4791 assert_eq!(buf.height(), 30);
4792 }
4793
4794 #[test]
4799 fn gc_with_no_prev_buffer() {
4800 let mut writer = TerminalWriter::new(
4801 Vec::new(),
4802 ScreenMode::AltScreen,
4803 UiAnchor::Bottom,
4804 basic_caps(),
4805 );
4806 assert!(writer.prev_buffer.is_none());
4807 writer.gc(None);
4809 }
4810
4811 #[test]
4812 fn gc_with_prev_buffer() {
4813 let mut writer = TerminalWriter::new(
4814 Vec::new(),
4815 ScreenMode::AltScreen,
4816 UiAnchor::Bottom,
4817 basic_caps(),
4818 );
4819 writer.prev_buffer = Some(Buffer::new(10, 5));
4820 writer.gc(None);
4822 }
4823
4824 #[test]
4829 fn hide_cursor_emits_sequence() {
4830 let mut output = Vec::new();
4831 {
4832 let mut writer = TerminalWriter::new(
4833 &mut output,
4834 ScreenMode::AltScreen,
4835 UiAnchor::Bottom,
4836 basic_caps(),
4837 );
4838 writer.hide_cursor().unwrap();
4839 }
4840 assert!(
4841 output.windows(6).any(|w| w == b"\x1b[?25l"),
4842 "hide_cursor should emit cursor hide sequence"
4843 );
4844 }
4845
4846 #[test]
4847 fn show_cursor_emits_sequence() {
4848 let mut output = Vec::new();
4849 {
4850 let mut writer = TerminalWriter::new(
4851 &mut output,
4852 ScreenMode::AltScreen,
4853 UiAnchor::Bottom,
4854 basic_caps(),
4855 );
4856 writer.hide_cursor().unwrap();
4858 writer.show_cursor().unwrap();
4859 }
4860 assert!(
4861 output.windows(6).any(|w| w == b"\x1b[?25h"),
4862 "show_cursor should emit cursor show sequence"
4863 );
4864 }
4865
4866 #[test]
4867 fn hide_cursor_idempotent() {
4868 use std::io::Cursor;
4870 let mut writer = TerminalWriter::new(
4871 Cursor::new(Vec::new()),
4872 ScreenMode::AltScreen,
4873 UiAnchor::Bottom,
4874 basic_caps(),
4875 );
4876 writer.hide_cursor().unwrap();
4877 let inner = writer.into_inner().unwrap().into_inner();
4878 let hide_count = inner.windows(6).filter(|w| *w == b"\x1b[?25l").count();
4879 assert_eq!(
4881 hide_count, 1,
4882 "hide_cursor called once should emit exactly one hide sequence"
4883 );
4884 }
4885
4886 #[test]
4887 fn show_cursor_idempotent_when_already_visible() {
4888 use std::io::Cursor;
4889 let mut writer = TerminalWriter::new(
4890 Cursor::new(Vec::new()),
4891 ScreenMode::AltScreen,
4892 UiAnchor::Bottom,
4893 basic_caps(),
4894 );
4895 writer.show_cursor().unwrap();
4897 let inner = writer.into_inner().unwrap().into_inner();
4898 let show_count = inner.windows(6).filter(|w| *w == b"\x1b[?25h").count();
4900 assert!(
4901 show_count <= 1,
4902 "show_cursor when already visible should not add extra show sequences"
4903 );
4904 }
4905
4906 #[test]
4911 fn pool_accessor() {
4912 let writer = TerminalWriter::new(
4913 Vec::new(),
4914 ScreenMode::AltScreen,
4915 UiAnchor::Bottom,
4916 basic_caps(),
4917 );
4918 let _pool = writer.pool();
4920 }
4921
4922 #[test]
4923 fn pool_mut_accessor() {
4924 let mut writer = TerminalWriter::new(
4925 Vec::new(),
4926 ScreenMode::AltScreen,
4927 UiAnchor::Bottom,
4928 basic_caps(),
4929 );
4930 let _pool = writer.pool_mut();
4931 }
4932
4933 #[test]
4934 fn links_accessor() {
4935 let writer = TerminalWriter::new(
4936 Vec::new(),
4937 ScreenMode::AltScreen,
4938 UiAnchor::Bottom,
4939 basic_caps(),
4940 );
4941 let _links = writer.links();
4942 }
4943
4944 #[test]
4945 fn links_mut_accessor() {
4946 let mut writer = TerminalWriter::new(
4947 Vec::new(),
4948 ScreenMode::AltScreen,
4949 UiAnchor::Bottom,
4950 basic_caps(),
4951 );
4952 let _links = writer.links_mut();
4953 }
4954
4955 #[test]
4956 fn pool_and_links_mut_accessor() {
4957 let mut writer = TerminalWriter::new(
4958 Vec::new(),
4959 ScreenMode::AltScreen,
4960 UiAnchor::Bottom,
4961 basic_caps(),
4962 );
4963 let (_pool, _links) = writer.pool_and_links_mut();
4964 }
4965
4966 #[test]
4971 fn sanitize_auto_bounds_normal() {
4972 assert_eq!(sanitize_auto_bounds(3, 10), (3, 10));
4973 }
4974
4975 #[test]
4976 fn sanitize_auto_bounds_zero_min() {
4977 assert_eq!(sanitize_auto_bounds(0, 10), (1, 10));
4979 }
4980
4981 #[test]
4982 fn sanitize_auto_bounds_max_less_than_min() {
4983 assert_eq!(sanitize_auto_bounds(5, 3), (5, 5));
4985 }
4986
4987 #[test]
4988 fn sanitize_auto_bounds_both_zero() {
4989 assert_eq!(sanitize_auto_bounds(0, 0), (1, 1));
4990 }
4991
4992 #[test]
4993 fn diff_strategy_str_variants() {
4994 assert_eq!(diff_strategy_str(DiffStrategy::Full), "full");
4995 assert_eq!(diff_strategy_str(DiffStrategy::DirtyRows), "dirty");
4996 assert_eq!(diff_strategy_str(DiffStrategy::FullRedraw), "redraw");
4997 }
4998
4999 #[test]
5000 fn ui_anchor_str_variants() {
5001 assert_eq!(ui_anchor_str(UiAnchor::Bottom), "bottom");
5002 assert_eq!(ui_anchor_str(UiAnchor::Top), "top");
5003 }
5004
5005 #[test]
5006 fn json_escape_plain_text() {
5007 assert_eq!(json_escape("hello"), "hello");
5008 }
5009
5010 #[test]
5011 fn json_escape_special_chars() {
5012 assert_eq!(json_escape(r#"a"b"#), r#"a\"b"#);
5013 assert_eq!(json_escape("a\\b"), r#"a\\b"#);
5014 assert_eq!(json_escape("a\nb"), r#"a\nb"#);
5015 assert_eq!(json_escape("a\rb"), r#"a\rb"#);
5016 assert_eq!(json_escape("a\tb"), r#"a\tb"#);
5017 }
5018
5019 #[test]
5020 fn json_escape_control_chars() {
5021 let s = String::from("\x00\x01\x1f");
5022 let escaped = json_escape(&s);
5023 assert!(escaped.contains("\\u0000"));
5024 assert!(escaped.contains("\\u0001"));
5025 assert!(escaped.contains("\\u001F"));
5026 }
5027
5028 #[test]
5029 fn json_escape_unicode_passthrough() {
5030 assert_eq!(json_escape("caf\u{00e9}"), "caf\u{00e9}");
5031 assert_eq!(json_escape("\u{1f600}"), "\u{1f600}");
5032 }
5033
5034 #[test]
5039 fn counting_writer_into_inner() {
5040 let mut cw = CountingWriter::new(Vec::new());
5041 cw.write_all(b"data").unwrap();
5042 let inner = cw.into_inner();
5043 assert_eq!(inner, b"data");
5044 }
5045
5046 fn zero_span_stats() -> DirtySpanStats {
5051 DirtySpanStats {
5052 rows_full_dirty: 0,
5053 rows_with_spans: 0,
5054 total_spans: 0,
5055 overflows: 0,
5056 span_coverage_cells: 0,
5057 max_span_len: 0,
5058 max_spans_per_row: 4,
5059 }
5060 }
5061
5062 #[test]
5063 fn estimate_diff_scan_cost_full_strategy() {
5064 let stats = zero_span_stats();
5065 let (cost, label) = estimate_diff_scan_cost(DiffStrategy::Full, 0, 80, 24, &stats, None);
5066 assert_eq!(cost, 80 * 24);
5067 assert_eq!(label, "full_strategy");
5068 }
5069
5070 #[test]
5071 fn estimate_diff_scan_cost_full_redraw() {
5072 let stats = zero_span_stats();
5073 let (cost, label) =
5074 estimate_diff_scan_cost(DiffStrategy::FullRedraw, 5, 80, 24, &stats, None);
5075 assert_eq!(cost, 0);
5076 assert_eq!(label, "full_redraw");
5077 }
5078
5079 #[test]
5080 fn estimate_diff_scan_cost_dirty_rows_no_dirty() {
5081 let stats = zero_span_stats();
5082 let (cost, label) =
5083 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 0, 80, 24, &stats, None);
5084 assert_eq!(cost, 0);
5085 assert_eq!(label, "no_dirty_rows");
5086 }
5087
5088 #[test]
5089 fn estimate_diff_scan_cost_dirty_rows_with_span_coverage() {
5090 let mut stats = zero_span_stats();
5091 stats.span_coverage_cells = 100;
5092 let (cost, label) =
5093 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5094 assert_eq!(cost, 100);
5095 assert_eq!(label, "none");
5096 }
5097
5098 #[test]
5099 fn estimate_diff_scan_cost_dirty_rows_no_spans() {
5100 let stats = zero_span_stats();
5101 let (cost, label) =
5102 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5103 assert_eq!(cost, 5 * 80);
5104 assert_eq!(label, "no_spans");
5105 }
5106
5107 #[test]
5108 fn estimate_diff_scan_cost_dirty_rows_overflow_with_span() {
5109 let mut stats = zero_span_stats();
5110 stats.span_coverage_cells = 150;
5111 stats.overflows = 1;
5112 let (cost, label) =
5113 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5114 assert_eq!(cost, 150);
5115 assert_eq!(label, "span_overflow");
5116 }
5117
5118 #[test]
5119 fn estimate_diff_scan_cost_dirty_rows_overflow_no_span() {
5120 let mut stats = zero_span_stats();
5121 stats.overflows = 1;
5122 let (cost, label) =
5123 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5124 assert_eq!(cost, 5 * 80);
5125 assert_eq!(label, "span_overflow");
5126 }
5127
5128 #[test]
5129 fn estimate_diff_scan_cost_tile_skip() {
5130 let stats = zero_span_stats();
5131 let tile = TileDiffStats {
5132 width: 80,
5133 height: 24,
5134 tile_w: 16,
5135 tile_h: 8,
5136 tiles_x: 5,
5137 tiles_y: 3,
5138 total_tiles: 15,
5139 dirty_cells: 10,
5140 dirty_tiles: 2,
5141 dirty_cell_ratio: 0.005,
5142 dirty_tile_ratio: 0.13,
5143 scanned_tiles: 2,
5144 skipped_tiles: 13,
5145 sat_build_cells: 1920,
5146 scan_cells_estimate: 42,
5147 fallback: None,
5148 };
5149 let (cost, label) =
5150 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
5151 assert_eq!(cost, 42);
5152 assert_eq!(label, "tile_skip");
5153 }
5154
5155 #[test]
5156 fn estimate_diff_scan_cost_tile_with_fallback_uses_spans() {
5157 let mut stats = zero_span_stats();
5158 stats.span_coverage_cells = 200;
5159 let tile = TileDiffStats {
5160 width: 80,
5161 height: 24,
5162 tile_w: 16,
5163 tile_h: 8,
5164 tiles_x: 5,
5165 tiles_y: 3,
5166 total_tiles: 15,
5167 dirty_cells: 10,
5168 dirty_tiles: 2,
5169 dirty_cell_ratio: 0.005,
5170 dirty_tile_ratio: 0.13,
5171 scanned_tiles: 2,
5172 skipped_tiles: 13,
5173 sat_build_cells: 1920,
5174 scan_cells_estimate: 42,
5175 fallback: Some(TileDiffFallback::SmallScreen),
5176 };
5177 let (cost, label) =
5178 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
5179 assert_eq!(cost, 200);
5181 assert_eq!(label, "none");
5182 }
5183
5184 #[test]
5189 fn inline_auto_bounds_accessor() {
5190 let mut writer = TerminalWriter::new(
5191 Vec::new(),
5192 ScreenMode::InlineAuto {
5193 min_height: 3,
5194 max_height: 10,
5195 },
5196 UiAnchor::Bottom,
5197 basic_caps(),
5198 );
5199 writer.set_size(80, 24);
5200 let bounds = writer.inline_auto_bounds();
5201 assert_eq!(bounds, Some((3, 10)));
5202 }
5203
5204 #[test]
5205 fn inline_auto_bounds_clamped_to_terminal() {
5206 let mut writer = TerminalWriter::new(
5207 Vec::new(),
5208 ScreenMode::InlineAuto {
5209 min_height: 3,
5210 max_height: 50,
5211 },
5212 UiAnchor::Bottom,
5213 basic_caps(),
5214 );
5215 writer.set_size(80, 20);
5216 let bounds = writer.inline_auto_bounds();
5217 assert_eq!(bounds, Some((3, 20)));
5218 }
5219
5220 #[test]
5221 fn inline_auto_bounds_returns_none_for_non_auto() {
5222 let writer = TerminalWriter::new(
5223 Vec::new(),
5224 ScreenMode::Inline { ui_height: 5 },
5225 UiAnchor::Bottom,
5226 basic_caps(),
5227 );
5228 assert_eq!(writer.inline_auto_bounds(), None);
5229
5230 let writer2 = TerminalWriter::new(
5231 Vec::new(),
5232 ScreenMode::AltScreen,
5233 UiAnchor::Bottom,
5234 basic_caps(),
5235 );
5236 assert_eq!(writer2.inline_auto_bounds(), None);
5237 }
5238
5239 #[test]
5240 fn auto_ui_height_returns_none_for_non_auto() {
5241 let writer = TerminalWriter::new(
5242 Vec::new(),
5243 ScreenMode::Inline { ui_height: 5 },
5244 UiAnchor::Bottom,
5245 basic_caps(),
5246 );
5247 assert_eq!(writer.auto_ui_height(), None);
5248 }
5249
5250 #[test]
5251 fn render_height_hint_altscreen() {
5252 let mut writer = TerminalWriter::new(
5253 Vec::new(),
5254 ScreenMode::AltScreen,
5255 UiAnchor::Bottom,
5256 basic_caps(),
5257 );
5258 writer.set_size(80, 24);
5259 assert_eq!(writer.render_height_hint(), 24);
5260 }
5261
5262 #[test]
5263 fn render_height_hint_inline_fixed() {
5264 let writer = TerminalWriter::new(
5265 Vec::new(),
5266 ScreenMode::Inline { ui_height: 7 },
5267 UiAnchor::Bottom,
5268 basic_caps(),
5269 );
5270 assert_eq!(writer.render_height_hint(), 7);
5271 }
5272
5273 #[test]
5278 fn runtime_diff_config_tile_skip_toggle() {
5279 let config = RuntimeDiffConfig::new().with_tile_skip_enabled(false);
5280 assert!(!config.tile_diff_config.enabled);
5281 }
5282
5283 #[test]
5284 fn runtime_diff_config_dirty_spans_toggle() {
5285 let config = RuntimeDiffConfig::new().with_dirty_spans_enabled(false);
5286 assert!(!config.dirty_span_config.enabled);
5287 }
5288
5289 #[test]
5294 fn present_ui_altscreen_no_cursor_save_restore() {
5295 let mut output = Vec::new();
5296 {
5297 let mut writer = TerminalWriter::new(
5298 &mut output,
5299 ScreenMode::AltScreen,
5300 UiAnchor::Bottom,
5301 basic_caps(),
5302 );
5303 writer.set_size(10, 5);
5304 let buffer = Buffer::new(10, 5);
5305 writer.present_ui(&buffer, None, true).unwrap();
5306 }
5307
5308 let save_count = output
5310 .windows(CURSOR_SAVE.len())
5311 .filter(|w| *w == CURSOR_SAVE)
5312 .count();
5313 assert_eq!(save_count, 0, "AltScreen should not save cursor");
5314 }
5315
5316 #[test]
5317 fn clear_screen_emits_ed2() {
5318 let mut output = Vec::new();
5319 {
5320 let mut writer = TerminalWriter::new(
5321 &mut output,
5322 ScreenMode::AltScreen,
5323 UiAnchor::Bottom,
5324 basic_caps(),
5325 );
5326 writer.clear_screen().unwrap();
5327 }
5328 assert!(
5329 output.windows(4).any(|w| w == b"\x1b[2J"),
5330 "clear_screen should emit ED2 sequence"
5331 );
5332 }
5333
5334 #[test]
5335 fn clear_screen_resets_active_scroll_region_before_clearing() {
5336 let mut output = Vec::new();
5337 {
5338 let mut writer = TerminalWriter::new(
5339 &mut output,
5340 ScreenMode::Inline { ui_height: 5 },
5341 UiAnchor::Bottom,
5342 scroll_region_caps(),
5343 );
5344 writer.set_size(80, 24);
5345
5346 let buffer = Buffer::new(80, 5);
5347 writer.present_ui(&buffer, None, true).unwrap();
5348 assert!(writer.scroll_region_active());
5349
5350 writer.clear_screen().unwrap();
5351 assert!(
5352 !writer.scroll_region_active(),
5353 "clear_screen should leave no active scroll region"
5354 );
5355 }
5356
5357 let reset_idx = output
5358 .windows(b"\x1b[r".len())
5359 .position(|w| w == b"\x1b[r")
5360 .expect("expected scroll-region reset");
5361 let clear_idx = output
5362 .windows(b"\x1b[2J".len())
5363 .position(|w| w == b"\x1b[2J")
5364 .expect("expected full clear");
5365 assert!(
5366 reset_idx < clear_idx,
5367 "clear_screen should reset DECSTBM before full-screen clear"
5368 );
5369 }
5370
5371 #[test]
5372 fn clear_screen_restores_saved_cursor_before_clearing() {
5373 let mut output = Vec::new();
5374 {
5375 let mut writer = TerminalWriter::new(
5376 &mut output,
5377 ScreenMode::Inline { ui_height: 5 },
5378 UiAnchor::Bottom,
5379 basic_caps(),
5380 );
5381 writer.cursor_saved = true;
5382
5383 writer.clear_screen().unwrap();
5384 assert!(
5385 !writer.cursor_saved,
5386 "clear_screen should clear stale saved-cursor state"
5387 );
5388 }
5389
5390 let restore_idx = output
5391 .windows(CURSOR_RESTORE.len())
5392 .position(|w| w == CURSOR_RESTORE)
5393 .expect("expected cursor restore");
5394 let clear_idx = output
5395 .windows(b"\x1b[2J".len())
5396 .position(|w| w == b"\x1b[2J")
5397 .expect("expected full clear");
5398 assert!(
5399 restore_idx < clear_idx,
5400 "clear_screen should restore any saved cursor before clearing"
5401 );
5402 }
5403
5404 #[test]
5405 fn clear_screen_closes_stale_sync_block_before_clearing() {
5406 let mut output = Vec::new();
5407 {
5408 let mut writer = TerminalWriter::new(
5409 &mut output,
5410 ScreenMode::Inline { ui_height: 5 },
5411 UiAnchor::Bottom,
5412 full_caps(),
5413 );
5414 writer.in_sync_block = true;
5415
5416 writer.clear_screen().unwrap();
5417 assert!(
5418 !writer.in_sync_block,
5419 "clear_screen should clear stale sync-block state"
5420 );
5421 }
5422
5423 let sync_end_idx = output
5424 .windows(SYNC_END.len())
5425 .position(|w| w == SYNC_END)
5426 .expect("expected sync end");
5427 let clear_idx = output
5428 .windows(b"\x1b[2J".len())
5429 .position(|w| w == b"\x1b[2J")
5430 .expect("expected full clear");
5431 assert!(
5432 sync_end_idx < clear_idx,
5433 "clear_screen should end any open sync block before clearing"
5434 );
5435 }
5436
5437 #[test]
5438 fn clear_screen_skips_sync_end_in_mux_while_clearing_stale_state() {
5439 let mut output = Vec::new();
5440 {
5441 let mut writer = TerminalWriter::new(
5442 &mut output,
5443 ScreenMode::Inline { ui_height: 5 },
5444 UiAnchor::Bottom,
5445 mux_caps(),
5446 );
5447 writer.in_sync_block = true;
5448
5449 writer.clear_screen().unwrap();
5450 assert!(
5451 !writer.in_sync_block,
5452 "clear_screen should clear stale sync state even when sync output is disabled"
5453 );
5454 }
5455
5456 assert!(
5457 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
5458 "clear_screen must not emit sync_end in mux environments"
5459 );
5460 assert!(
5461 output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"),
5462 "clear_screen should still clear the screen"
5463 );
5464 }
5465
5466 #[test]
5467 fn clear_screen_invalidates_cached_state_even_when_flush_fails() {
5468 let state = Rc::new(RefCell::new(FaultState::default()));
5469 let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
5470 let mut writer = TerminalWriter::new(
5471 writer_backend,
5472 ScreenMode::Inline { ui_height: 5 },
5473 UiAnchor::Bottom,
5474 basic_caps(),
5475 );
5476 writer.cursor_saved = true;
5477 writer.prev_buffer = Some(Buffer::new(4, 2));
5478 writer.last_inline_region = Some(InlineRegion {
5479 start: 19,
5480 height: 5,
5481 });
5482 writer.last_diff_strategy = Some(DiffStrategy::DirtyRows);
5483
5484 let err = writer
5485 .clear_screen()
5486 .expect_err("expected injected flush write failure");
5487 assert_eq!(err.kind(), io::ErrorKind::Other);
5488 assert!(state.borrow().injected_failure_triggered);
5489 assert!(
5490 writer.prev_buffer.is_none(),
5491 "clear_screen should invalidate cached frame state after flush failure"
5492 );
5493 assert!(
5494 writer.last_inline_region.is_none(),
5495 "clear_screen should drop inline region cache after flush failure"
5496 );
5497 assert!(
5498 writer.last_diff_strategy.is_none(),
5499 "clear_screen should reset diff strategy after flush failure"
5500 );
5501 }
5502
5503 #[test]
5504 fn present_ui_retry_after_write_failure_forces_repaint() {
5505 let state = Rc::new(RefCell::new(FaultState::default()));
5506 let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
5507 let mut writer = TerminalWriter::new(
5508 writer_backend,
5509 ScreenMode::AltScreen,
5510 UiAnchor::Bottom,
5511 basic_caps(),
5512 );
5513 writer.set_size(4, 2);
5514
5515 let mut buffer = Buffer::new(4, 2);
5516 buffer.set_raw(0, 0, Cell::from_char('A'));
5517
5518 let err = writer
5519 .present_ui(&buffer, None, true)
5520 .expect_err("first present should hit the injected write fault");
5521 assert_eq!(err.kind(), io::ErrorKind::Other);
5522 assert!(
5523 writer.prev_buffer.is_none(),
5524 "failed present must not advance the diff baseline"
5525 );
5526
5527 writer
5528 .present_ui(&buffer, None, true)
5529 .expect("retry after transient failure should succeed");
5530
5531 let bytes = state.borrow().bytes.clone();
5532 assert!(
5533 bytes.contains(&b'A'),
5534 "retry should emit the missing cell content after a failed present"
5535 );
5536 }
5537
5538 #[test]
5539 fn present_ui_write_failure_with_existing_baseline_invalidates_diff_state() {
5540 let state = Rc::new(RefCell::new(FaultState::default()));
5541 let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
5542 let mut writer = TerminalWriter::new(
5543 writer_backend,
5544 ScreenMode::AltScreen,
5545 UiAnchor::Bottom,
5546 basic_caps(),
5547 );
5548 writer.set_size(4, 2);
5549
5550 let mut previous = Buffer::new(4, 2);
5551 previous.set_raw(0, 0, Cell::from_char('A'));
5552 writer.prev_buffer = Some(previous);
5553 writer.last_inline_region = Some(InlineRegion {
5554 start: 0,
5555 height: 2,
5556 });
5557 writer.last_diff_strategy = Some(DiffStrategy::DirtyRows);
5558 writer.frames_since_full_redraw = 7;
5559
5560 let mut buffer = Buffer::new(4, 2);
5561 buffer.set_raw(0, 0, Cell::from_char('B'));
5562
5563 let err = writer
5564 .present_ui(&buffer, None, true)
5565 .expect_err("present should hit the injected write fault");
5566 assert_eq!(err.kind(), io::ErrorKind::Other);
5567 assert!(
5568 writer.prev_buffer.is_none(),
5569 "failed present with a prior baseline must force the next frame to repaint"
5570 );
5571 assert!(
5572 writer.last_inline_region.is_none(),
5573 "failed present must drop inline-region assumptions"
5574 );
5575 assert_eq!(writer.last_diff_strategy(), None);
5576 assert_eq!(writer.frames_since_full_redraw, 0);
5577
5578 writer
5579 .present_ui(&buffer, None, true)
5580 .expect("retry after transient failure should succeed");
5581 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
5582 }
5583
5584 #[test]
5585 fn set_size_resets_scroll_region_and_spare_buffer() {
5586 let output = Vec::new();
5587 let mut writer = TerminalWriter::new(
5588 output,
5589 ScreenMode::Inline { ui_height: 5 },
5590 UiAnchor::Bottom,
5591 basic_caps(),
5592 );
5593 writer.spare_buffer = Some(Buffer::new(80, 24));
5594 writer.set_size(100, 30);
5595 assert!(writer.spare_buffer.is_none());
5596 }
5597
5598 static GAUGE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
5605
5606 #[test]
5607 fn inline_active_widgets_gauge_increments_for_inline_mode() {
5608 let _lock = GAUGE_TEST_LOCK
5609 .lock()
5610 .unwrap_or_else(|err| err.into_inner());
5611
5612 for _ in 0..64 {
5615 let before = inline_active_widgets();
5616 let writer = TerminalWriter::new(
5617 Vec::new(),
5618 ScreenMode::Inline { ui_height: 5 },
5619 UiAnchor::Bottom,
5620 basic_caps(),
5621 );
5622 let after_create = inline_active_widgets();
5623 drop(writer);
5624 let after_drop = inline_active_widgets();
5625
5626 if after_create == before.saturating_add(1) && after_drop == before {
5627 return;
5628 }
5629 std::thread::yield_now();
5630 }
5631
5632 panic!("failed to observe uncontended inline gauge +1/-1 transition");
5633 }
5634
5635 #[test]
5636 fn inline_active_widgets_gauge_increments_for_inline_auto_mode() {
5637 let _lock = GAUGE_TEST_LOCK
5638 .lock()
5639 .unwrap_or_else(|err| err.into_inner());
5640
5641 for _ in 0..64 {
5642 let before = inline_active_widgets();
5643 let writer = TerminalWriter::new(
5644 Vec::new(),
5645 ScreenMode::InlineAuto {
5646 min_height: 2,
5647 max_height: 10,
5648 },
5649 UiAnchor::Bottom,
5650 basic_caps(),
5651 );
5652 let after_create = inline_active_widgets();
5653 drop(writer);
5654 let after_drop = inline_active_widgets();
5655
5656 if after_create == before.saturating_add(1) && after_drop == before {
5657 return;
5658 }
5659 std::thread::yield_now();
5660 }
5661
5662 panic!("failed to observe uncontended inline-auto gauge +1/-1 transition");
5663 }
5664
5665 #[test]
5666 fn inline_active_widgets_gauge_unchanged_for_altscreen() {
5667 let _lock = GAUGE_TEST_LOCK
5668 .lock()
5669 .unwrap_or_else(|err| err.into_inner());
5670
5671 for _ in 0..64 {
5672 let before = inline_active_widgets();
5673 let writer = TerminalWriter::new(
5674 Vec::new(),
5675 ScreenMode::AltScreen,
5676 UiAnchor::Bottom,
5677 basic_caps(),
5678 );
5679 let after_create = inline_active_widgets();
5680 drop(writer);
5681 let after_drop = inline_active_widgets();
5682
5683 if after_create == before && after_drop == before {
5684 return;
5685 }
5686 std::thread::yield_now();
5687 }
5688
5689 panic!("failed to observe stable altscreen gauge behavior");
5690 }
5691
5692 const ALTSCREEN_ENTER: &[u8] = b"\x1b[?1049h";
5699
5700 const ALTSCREEN_EXIT: &[u8] = b"\x1b[?1049l";
5702
5703 fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
5705 haystack.windows(needle.len()).any(|w| w == needle)
5706 }
5707
5708 #[test]
5709 fn inline_render_never_emits_altscreen_enter() {
5710 let mut output = Vec::new();
5712 {
5713 let mut writer = TerminalWriter::new(
5714 &mut output,
5715 ScreenMode::Inline { ui_height: 5 },
5716 UiAnchor::Bottom,
5717 basic_caps(),
5718 );
5719 writer.set_size(80, 24);
5720
5721 let buffer = Buffer::new(80, 5);
5722 writer.present_ui(&buffer, None, true).unwrap();
5723 writer.write_log("hello\n").unwrap();
5724 writer.present_ui(&buffer, None, true).unwrap();
5726 }
5727
5728 assert!(
5729 !contains_bytes(&output, ALTSCREEN_ENTER),
5730 "inline mode must never emit CSI ?1049h (alternate screen enter)"
5731 );
5732 assert!(
5733 !contains_bytes(&output, ALTSCREEN_EXIT),
5734 "inline mode must never emit CSI ?1049l (alternate screen exit)"
5735 );
5736 }
5737
5738 #[test]
5739 fn inline_auto_render_never_emits_altscreen_enter() {
5740 let mut output = Vec::new();
5741 {
5742 let mut writer = TerminalWriter::new(
5743 &mut output,
5744 ScreenMode::InlineAuto {
5745 min_height: 3,
5746 max_height: 10,
5747 },
5748 UiAnchor::Bottom,
5749 basic_caps(),
5750 );
5751 writer.set_size(80, 24);
5752
5753 let buffer = Buffer::new(80, 5);
5754 writer.present_ui(&buffer, None, true).unwrap();
5755 }
5756
5757 assert!(
5758 !contains_bytes(&output, ALTSCREEN_ENTER),
5759 "InlineAuto mode must never emit CSI ?1049h"
5760 );
5761 }
5762
5763 #[test]
5764 fn inline_scrollback_preserved_after_present() {
5765 let mut output = Vec::new();
5770 {
5771 let mut writer = TerminalWriter::new(
5772 &mut output,
5773 ScreenMode::Inline { ui_height: 5 },
5774 UiAnchor::Bottom,
5775 basic_caps(),
5776 );
5777 writer.set_size(80, 24);
5778
5779 writer.write_log("scrollback line A\n").unwrap();
5780 writer.write_log("scrollback line B\n").unwrap();
5781
5782 let buffer = Buffer::new(80, 5);
5783 writer.present_ui(&buffer, None, true).unwrap();
5784
5785 writer.write_log("scrollback line C\n").unwrap();
5787 }
5788
5789 let text = String::from_utf8_lossy(&output);
5790 assert!(text.contains("scrollback line A"), "first log must survive");
5791 assert!(
5792 text.contains("scrollback line B"),
5793 "second log must survive"
5794 );
5795 assert!(
5796 text.contains("scrollback line C"),
5797 "post-render log must survive"
5798 );
5799
5800 assert!(
5803 contains_bytes(&output, CURSOR_SAVE),
5804 "present_ui must save cursor to protect scrollback"
5805 );
5806 assert!(
5807 contains_bytes(&output, CURSOR_RESTORE),
5808 "present_ui must restore cursor to protect scrollback"
5809 );
5810 }
5811
5812 #[test]
5813 fn multiple_inline_writers_coexist() {
5814 let mut writer_a = TerminalWriter::new(
5818 Vec::new(),
5819 ScreenMode::Inline { ui_height: 3 },
5820 UiAnchor::Bottom,
5821 basic_caps(),
5822 );
5823 writer_a.set_size(40, 12);
5824
5825 let mut writer_b = TerminalWriter::new(
5826 Vec::new(),
5827 ScreenMode::Inline { ui_height: 5 },
5828 UiAnchor::Bottom,
5829 basic_caps(),
5830 );
5831 writer_b.set_size(80, 24);
5832
5833 let buf_a = Buffer::new(40, 3);
5835 let buf_b = Buffer::new(80, 5);
5836 writer_a.present_ui(&buf_a, None, true).unwrap();
5837 writer_b.present_ui(&buf_b, None, true).unwrap();
5838
5839 writer_a.present_ui(&buf_a, None, true).unwrap();
5841 writer_b.present_ui(&buf_b, None, true).unwrap();
5842
5843 drop(writer_a);
5845 drop(writer_b);
5846 }
5847
5848 #[test]
5849 fn multiple_inline_writers_gauge_tracks_both() {
5850 let _lock = GAUGE_TEST_LOCK
5852 .lock()
5853 .unwrap_or_else(|err| err.into_inner());
5854
5855 for _ in 0..64 {
5856 let before = inline_active_widgets();
5857 let writer_a = TerminalWriter::new(
5858 Vec::new(),
5859 ScreenMode::Inline { ui_height: 3 },
5860 UiAnchor::Bottom,
5861 basic_caps(),
5862 );
5863 let after_a = inline_active_widgets();
5864
5865 let writer_b = TerminalWriter::new(
5866 Vec::new(),
5867 ScreenMode::Inline { ui_height: 5 },
5868 UiAnchor::Bottom,
5869 basic_caps(),
5870 );
5871 let after_b = inline_active_widgets();
5872
5873 drop(writer_a);
5874 let after_drop_a = inline_active_widgets();
5875
5876 drop(writer_b);
5877 let after_drop_b = inline_active_widgets();
5878
5879 if after_a == before.saturating_add(1)
5880 && after_b == before.saturating_add(2)
5881 && after_drop_a == before.saturating_add(1)
5882 && after_drop_b == before
5883 {
5884 return;
5885 }
5886 std::thread::yield_now();
5887 }
5888
5889 panic!("failed to observe uncontended two-writer gauge transitions");
5890 }
5891
5892 #[test]
5893 fn resize_during_inline_mode_preserves_scrollback() {
5894 let mut output = Vec::new();
5897 {
5898 let mut writer = TerminalWriter::new(
5899 &mut output,
5900 ScreenMode::Inline { ui_height: 5 },
5901 UiAnchor::Bottom,
5902 basic_caps(),
5903 );
5904 writer.set_size(80, 24);
5905
5906 let buffer = Buffer::new(80, 5);
5907 writer.present_ui(&buffer, None, true).unwrap();
5908
5909 writer.set_size(100, 30);
5911 assert_eq!(writer.ui_start_row(), 25); let buffer2 = Buffer::new(100, 5);
5915 writer.present_ui(&buffer2, None, true).unwrap();
5916
5917 writer.write_log("post-resize log\n").unwrap();
5919 }
5920
5921 let text = String::from_utf8_lossy(&output);
5922 assert!(text.contains("post-resize log"));
5923 assert!(
5924 !contains_bytes(&output, ALTSCREEN_ENTER),
5925 "resize must not trigger alternate screen"
5926 );
5927 }
5928
5929 #[test]
5930 fn resize_shrink_during_inline_mode_clamps_correctly() {
5931 let mut output = Vec::new();
5934 {
5935 let mut writer = TerminalWriter::new(
5936 &mut output,
5937 ScreenMode::Inline { ui_height: 10 },
5938 UiAnchor::Bottom,
5939 basic_caps(),
5940 );
5941 writer.set_size(80, 24);
5942 assert_eq!(writer.ui_start_row(), 14);
5943
5944 writer.set_size(80, 8);
5946 assert_eq!(writer.ui_start_row(), 0); let buffer = Buffer::new(80, 8);
5950 writer.present_ui(&buffer, None, true).unwrap();
5951 }
5952
5953 assert!(
5954 !contains_bytes(&output, ALTSCREEN_ENTER),
5955 "shrunken terminal must not switch to altscreen"
5956 );
5957 }
5958
5959 #[test]
5960 fn inline_render_emits_tracing_span_fields() {
5961 use std::sync::Arc;
5965 use std::sync::atomic::AtomicBool;
5966
5967 struct SpanChecker {
5968 saw_inline_render: Arc<AtomicBool>,
5969 }
5970
5971 impl tracing::Subscriber for SpanChecker {
5972 fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
5973 true
5974 }
5975 fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
5976 if span.metadata().name() == "inline.render" {
5977 self.saw_inline_render
5978 .store(true, std::sync::atomic::Ordering::SeqCst);
5979 }
5980 tracing::span::Id::from_u64(1)
5981 }
5982 fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
5983 fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
5984 }
5985 fn event(&self, _event: &tracing::Event<'_>) {}
5986 fn enter(&self, _span: &tracing::span::Id) {}
5987 fn exit(&self, _span: &tracing::span::Id) {}
5988 }
5989
5990 let saw_it = Arc::new(AtomicBool::new(false));
5991 let subscriber = SpanChecker {
5992 saw_inline_render: Arc::clone(&saw_it),
5993 };
5994
5995 let _guard = tracing::subscriber::set_default(subscriber);
5996
5997 let mut output = Vec::new();
5998 {
5999 let mut writer = TerminalWriter::new(
6000 &mut output,
6001 ScreenMode::Inline { ui_height: 5 },
6002 UiAnchor::Bottom,
6003 basic_caps(),
6004 );
6005 writer.set_size(80, 24);
6006
6007 let buffer = Buffer::new(80, 5);
6008 writer.present_ui(&buffer, None, true).unwrap();
6009 }
6010
6011 assert!(
6012 saw_it.load(std::sync::atomic::Ordering::SeqCst),
6013 "present_ui in inline mode must emit an inline.render tracing span"
6014 );
6015 }
6016
6017 #[test]
6018 fn inline_render_no_altscreen_with_scroll_region_strategy() {
6019 let mut output = Vec::new();
6021 {
6022 let mut writer = TerminalWriter::new(
6023 &mut output,
6024 ScreenMode::Inline { ui_height: 5 },
6025 UiAnchor::Bottom,
6026 scroll_region_caps(),
6027 );
6028 writer.set_size(80, 24);
6029
6030 let buffer = Buffer::new(80, 5);
6031 writer.present_ui(&buffer, None, true).unwrap();
6032 writer.present_ui(&buffer, None, true).unwrap();
6033 }
6034
6035 assert!(
6036 !contains_bytes(&output, ALTSCREEN_ENTER),
6037 "scroll region strategy must never emit altscreen enter"
6038 );
6039 }
6040
6041 #[test]
6042 fn inline_render_no_altscreen_with_hybrid_strategy() {
6043 let mut output = Vec::new();
6044 {
6045 let mut writer = TerminalWriter::new(
6046 &mut output,
6047 ScreenMode::Inline { ui_height: 5 },
6048 UiAnchor::Bottom,
6049 hybrid_caps(),
6050 );
6051 writer.set_size(80, 24);
6052
6053 let buffer = Buffer::new(80, 5);
6054 writer.present_ui(&buffer, None, true).unwrap();
6055 }
6056
6057 assert!(
6058 !contains_bytes(&output, ALTSCREEN_ENTER),
6059 "hybrid strategy must never emit altscreen enter"
6060 );
6061 }
6062
6063 #[test]
6064 fn inline_render_no_altscreen_with_mux_strategy() {
6065 let mut output = Vec::new();
6066 {
6067 let mut writer = TerminalWriter::new(
6068 &mut output,
6069 ScreenMode::Inline { ui_height: 5 },
6070 UiAnchor::Bottom,
6071 mux_caps(),
6072 );
6073 writer.set_size(80, 24);
6074
6075 let buffer = Buffer::new(80, 5);
6076 writer.present_ui(&buffer, None, true).unwrap();
6077 }
6078
6079 assert!(
6080 !contains_bytes(&output, ALTSCREEN_ENTER),
6081 "mux (overlay) strategy must never emit altscreen enter"
6082 );
6083 }
6084
6085 #[test]
6086 fn test_altscreen_wide_char_rendering() {
6087 use ftui_render::cell::Cell;
6088 let mut output = Vec::new();
6089 {
6090 let mut writer = TerminalWriter::new(
6091 &mut output,
6092 ScreenMode::AltScreen,
6093 UiAnchor::Bottom,
6094 basic_caps(),
6095 );
6096 writer.set_size(10, 5);
6097
6098 let mut buf = Buffer::new(10, 1);
6099 buf.set_raw(0, 0, Cell::from_char('中'));
6101 buf.set(0, 0, Cell::from_char('中')); let prev = Buffer::new(10, 1);
6110 writer.prev_buffer = Some(prev);
6111
6112 writer.present_ui(&buf, None, true).unwrap();
6113 }
6114
6115 let output_str = String::from_utf8_lossy(&output);
6116
6117 assert!(output_str.contains('中'));
6119
6120 let bytes = output_str.as_bytes();
6123 let pos = bytes.windows(3).position(|w| w == "中".as_bytes());
6124 assert!(pos.is_some());
6125
6126 let after = pos.unwrap() + 3;
6128 if after < bytes.len() {
6129 assert_ne!(
6131 bytes[after], 0x20,
6132 "Wide char continuation clobbered with space"
6133 );
6134 }
6135 }
6136
6137 #[test]
6149 fn noninterference_inline_gauge_balanced_across_lifecycle() {
6150 let _lock = GAUGE_TEST_LOCK
6151 .lock()
6152 .unwrap_or_else(|err| err.into_inner());
6153
6154 for _ in 0..64 {
6155 let before = inline_active_widgets();
6156 let writer = TerminalWriter::new(
6157 Vec::new(),
6158 ScreenMode::Inline { ui_height: 3 },
6159 UiAnchor::Bottom,
6160 basic_caps(),
6161 );
6162 let during = inline_active_widgets();
6163 drop(writer);
6164 let after = inline_active_widgets();
6165
6166 if during == before.saturating_add(1) && after == before {
6167 return;
6168 }
6169 std::thread::yield_now();
6170 }
6171
6172 panic!("failed to observe stable inline lifecycle gauge transition");
6173 }
6174
6175 #[test]
6180 fn noninterference_altscreen_does_not_affect_inline_gauge() {
6181 let _lock = GAUGE_TEST_LOCK
6182 .lock()
6183 .unwrap_or_else(|err| err.into_inner());
6184
6185 let mut observed_stable = false;
6191 for _ in 0..64 {
6192 let before = inline_active_widgets();
6193 drop(TerminalWriter::new(
6194 Vec::new(),
6195 ScreenMode::AltScreen,
6196 UiAnchor::Bottom,
6197 basic_caps(),
6198 ));
6199 let after = inline_active_widgets();
6200
6201 if after == before {
6202 observed_stable = true;
6203 break;
6204 }
6205 std::thread::yield_now();
6206 }
6207
6208 assert!(
6209 observed_stable,
6210 "failed to observe stable altscreen lifecycle gauge transition"
6211 );
6212
6213 assert!(
6215 !matches!(
6216 ScreenMode::AltScreen,
6217 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
6218 ),
6219 "AltScreen must not match inline patterns"
6220 );
6221 }
6222
6223 #[test]
6225 fn noninterference_inline_auto_gauge_balanced() {
6226 let _lock = GAUGE_TEST_LOCK
6227 .lock()
6228 .unwrap_or_else(|err| err.into_inner());
6229
6230 for _ in 0..64 {
6231 let before = inline_active_widgets();
6232 let writer = TerminalWriter::new(
6233 Vec::new(),
6234 ScreenMode::InlineAuto {
6235 min_height: 3,
6236 max_height: 10,
6237 },
6238 UiAnchor::Bottom,
6239 basic_caps(),
6240 );
6241 let during = inline_active_widgets();
6242 drop(writer);
6243 let after = inline_active_widgets();
6244
6245 if during == before.saturating_add(1) && after == before {
6246 return;
6247 }
6248 std::thread::yield_now();
6249 }
6250
6251 panic!("failed to observe stable inline-auto lifecycle gauge transition");
6252 }
6253
6254 #[test]
6257 fn noninterference_into_inner_performs_cleanup() {
6258 let _lock = GAUGE_TEST_LOCK
6259 .lock()
6260 .unwrap_or_else(|err| err.into_inner());
6261
6262 let cursor_show = b"\x1b[?25h";
6263 for _ in 0..64 {
6264 let before_gauge = inline_active_widgets();
6265
6266 let mut writer = TerminalWriter::new(
6267 Vec::new(),
6268 ScreenMode::Inline { ui_height: 5 },
6269 UiAnchor::Bottom,
6270 basic_caps(),
6271 );
6272 writer.set_size(80, 24);
6273
6274 let during_gauge = inline_active_widgets();
6275 let output = writer.into_inner().expect("should return writer");
6276 let after_gauge = inline_active_widgets();
6277
6278 if during_gauge == before_gauge.saturating_add(1) && after_gauge == before_gauge {
6279 assert!(
6280 output.windows(cursor_show.len()).any(|w| w == cursor_show),
6281 "into_inner must emit cursor show during cleanup"
6282 );
6283 return;
6284 }
6285 std::thread::yield_now();
6286 }
6287
6288 panic!("failed to observe stable into_inner gauge transition");
6289 }
6290
6291 #[test]
6294 fn noninterference_inline_cleanup_restores_cursor_after_present() {
6295 let mut output = Vec::new();
6296 {
6297 let mut writer = TerminalWriter::new(
6298 &mut output,
6299 ScreenMode::Inline { ui_height: 5 },
6300 UiAnchor::Bottom,
6301 basic_caps(),
6302 );
6303 writer.set_size(80, 24);
6304
6305 let buffer = Buffer::new(80, 5);
6306 writer.present_ui(&buffer, None, true).unwrap();
6307
6308 }
6310
6311 let saves = output
6313 .windows(CURSOR_SAVE.len())
6314 .filter(|w| *w == CURSOR_SAVE)
6315 .count();
6316 let restores = output
6317 .windows(CURSOR_RESTORE.len())
6318 .filter(|w| *w == CURSOR_RESTORE)
6319 .count();
6320
6321 assert!(saves > 0, "present must save cursor");
6322 assert!(
6323 restores >= saves,
6324 "cleanup must ensure all cursor saves are restored: {saves} saves, {restores} restores"
6325 );
6326 }
6327
6328 #[test]
6331 fn noninterference_altscreen_cleanup_minimal() {
6332 let mut output = Vec::new();
6333 {
6334 let mut writer = TerminalWriter::new(
6335 &mut output,
6336 ScreenMode::AltScreen,
6337 UiAnchor::Bottom,
6338 basic_caps(),
6339 );
6340 writer.set_size(80, 24);
6341
6342 let mut buffer = Buffer::new(80, 24);
6343 buffer.set_raw(0, 0, Cell::from_char('A'));
6344 writer.present_ui(&buffer, None, true).unwrap();
6345 }
6346
6347 let cursor_show = b"\x1b[?25h";
6349 assert!(
6350 output.windows(cursor_show.len()).any(|w| w == cursor_show),
6351 "AltScreen cleanup must show cursor"
6352 );
6353
6354 }
6358
6359 #[test]
6363 fn noninterference_rapid_present_log_interleave() {
6364 let mut output = Vec::new();
6365 {
6366 let mut writer = TerminalWriter::new(
6367 &mut output,
6368 ScreenMode::Inline { ui_height: 3 },
6369 UiAnchor::Bottom,
6370 basic_caps(),
6371 );
6372 writer.set_size(40, 12);
6373
6374 for i in 0..10 {
6375 let mut buffer = Buffer::new(40, 3);
6376 buffer.set_raw(0, 0, Cell::from_char(char::from(b'A' + (i % 26))));
6377 writer.present_ui(&buffer, None, true).unwrap();
6378 writer.write_log(&format!("log-{i}")).unwrap();
6379 }
6380 }
6381
6382 let cursor_show = b"\x1b[?25h";
6384 assert!(
6385 output.windows(cursor_show.len()).any(|w| w == cursor_show),
6386 "cleanup must complete after rapid interleaving"
6387 );
6388
6389 let output_len = output.len();
6392 if output_len > 1 {
6393 let last_esc = output.iter().rposition(|&b| b == 0x1b);
6394 if let Some(pos) = last_esc {
6395 if output_len - pos < 10 {
6397 assert!(
6399 output_len - pos >= 3,
6400 "truncated escape sequence at end of output"
6401 );
6402 }
6403 }
6404 }
6405 }
6406
6407 #[test]
6410 fn noninterference_resize_between_presents_clears_diff_state() {
6411 let mut output = Vec::new();
6412 {
6413 let mut writer = TerminalWriter::new(
6414 &mut output,
6415 ScreenMode::Inline { ui_height: 5 },
6416 UiAnchor::Bottom,
6417 basic_caps(),
6418 );
6419 writer.set_size(80, 24);
6420
6421 let buffer1 = Buffer::new(80, 5);
6423 writer.present_ui(&buffer1, None, true).unwrap();
6424
6425 writer.set_size(120, 30);
6427 assert!(
6428 writer.prev_buffer.is_none(),
6429 "set_size must clear prev_buffer to invalidate diff"
6430 );
6431
6432 let buffer2 = Buffer::new(120, 5);
6434 writer.present_ui(&buffer2, None, true).unwrap();
6435 }
6436
6437 let cursor_show = b"\x1b[?25h";
6439 assert!(
6440 output.windows(cursor_show.len()).any(|w| w == cursor_show),
6441 "output must be valid after resize"
6442 );
6443 }
6444
6445 #[test]
6449 fn noninterference_sequential_writers_clean_handoff() {
6450 let mut output = Vec::new();
6451
6452 {
6454 let mut writer = TerminalWriter::new(
6455 &mut output,
6456 ScreenMode::Inline { ui_height: 3 },
6457 UiAnchor::Bottom,
6458 basic_caps(),
6459 );
6460 writer.set_size(40, 12);
6461 let buffer = Buffer::new(40, 3);
6462 writer.present_ui(&buffer, None, true).unwrap();
6463 }
6465
6466 let inline_end = output.len();
6467
6468 {
6470 let mut writer = TerminalWriter::new(
6471 &mut output,
6472 ScreenMode::AltScreen,
6473 UiAnchor::Bottom,
6474 basic_caps(),
6475 );
6476 writer.set_size(40, 12);
6477 let mut buffer = Buffer::new(40, 12);
6478 buffer.set_raw(0, 0, Cell::from_char('Z'));
6479 writer.present_ui(&buffer, None, true).unwrap();
6480 }
6482
6483 let cursor_show = b"\x1b[?25h";
6485 let first_show = output[..inline_end]
6486 .windows(cursor_show.len())
6487 .any(|w| w == cursor_show);
6488 let second_show = output[inline_end..]
6489 .windows(cursor_show.len())
6490 .any(|w| w == cursor_show);
6491
6492 assert!(first_show, "first writer must show cursor on cleanup");
6493 assert!(second_show, "second writer must show cursor on cleanup");
6494 }
6495
6496 #[test]
6499 fn noninterference_present_ui_owned_matches_present_ui() {
6500 let mut output_borrowed = Vec::new();
6501 let mut output_owned = Vec::new();
6502
6503 let mut buffer = Buffer::new(20, 5);
6504 buffer.set_raw(0, 0, Cell::from_char('H'));
6505 buffer.set_raw(1, 0, Cell::from_char('i'));
6506
6507 {
6509 let mut writer = TerminalWriter::new(
6510 &mut output_borrowed,
6511 ScreenMode::Inline { ui_height: 5 },
6512 UiAnchor::Bottom,
6513 basic_caps(),
6514 );
6515 writer.set_size(20, 10);
6516 writer.present_ui(&buffer, None, true).unwrap();
6517 }
6518
6519 {
6521 let mut writer = TerminalWriter::new(
6522 &mut output_owned,
6523 ScreenMode::Inline { ui_height: 5 },
6524 UiAnchor::Bottom,
6525 basic_caps(),
6526 );
6527 writer.set_size(20, 10);
6528 writer.present_ui_owned(buffer, None, true).unwrap();
6529 }
6530
6531 assert!(
6533 output_borrowed
6534 .windows(CURSOR_SAVE.len())
6535 .any(|w| w == CURSOR_SAVE),
6536 "borrowed path must save cursor"
6537 );
6538 assert!(
6539 output_owned
6540 .windows(CURSOR_SAVE.len())
6541 .any(|w| w == CURSOR_SAVE),
6542 "owned path must save cursor"
6543 );
6544
6545 assert!(
6547 output_borrowed.windows(1).any(|w| w == b"H"),
6548 "borrowed path must render content"
6549 );
6550 assert!(
6551 output_owned.windows(1).any(|w| w == b"H"),
6552 "owned path must render content"
6553 );
6554 }
6555
6556 #[test]
6559 fn noninterference_write_log_mode_behavior_preserved() {
6560 let mut inline_output = Vec::new();
6562 {
6563 let mut writer = TerminalWriter::new(
6564 &mut inline_output,
6565 ScreenMode::Inline { ui_height: 3 },
6566 UiAnchor::Bottom,
6567 basic_caps(),
6568 );
6569 writer.set_size(40, 12);
6570 writer.write_log("hello").unwrap();
6571 }
6572 assert!(
6573 inline_output.windows(5).any(|w| w == b"hello"),
6574 "inline write_log must produce output"
6575 );
6576
6577 let mut alt_output = Vec::new();
6579 {
6580 let mut writer = TerminalWriter::new(
6581 &mut alt_output,
6582 ScreenMode::AltScreen,
6583 UiAnchor::Bottom,
6584 basic_caps(),
6585 );
6586 writer.set_size(40, 12);
6587 writer.write_log("hello").unwrap();
6588 }
6589 let has_hello = alt_output.windows(5).any(|w| w == b"hello");
6592 assert!(!has_hello, "AltScreen write_log must be silent (no-op)");
6593 }
6594
6595 #[test]
6598 fn noninterference_sync_output_balanced_across_multiple_presents() {
6599 let mut output = Vec::new();
6600 {
6601 let mut writer = TerminalWriter::new(
6602 &mut output,
6603 ScreenMode::Inline { ui_height: 3 },
6604 UiAnchor::Bottom,
6605 full_caps(),
6606 );
6607 writer.set_size(20, 10);
6608
6609 for _ in 0..5 {
6610 let buffer = Buffer::new(20, 3);
6611 writer.present_ui(&buffer, None, true).unwrap();
6612 }
6613 }
6615
6616 let begins = output
6617 .windows(SYNC_BEGIN.len())
6618 .filter(|w| *w == SYNC_BEGIN)
6619 .count();
6620 let ends = output
6621 .windows(SYNC_END.len())
6622 .filter(|w| *w == SYNC_END)
6623 .count();
6624
6625 assert!(begins > 0, "sync-capable writer must emit SYNC_BEGIN");
6626 assert_eq!(
6627 begins, ends,
6628 "sync blocks must be balanced: {begins} begins, {ends} ends"
6629 );
6630 }
6631
6632 #[test]
6635 fn noninterference_inline_auto_height_clamped_without_panic() {
6636 let mut output = Vec::new();
6637 {
6638 let mut writer = TerminalWriter::new(
6639 &mut output,
6640 ScreenMode::InlineAuto {
6641 min_height: 3,
6642 max_height: 100,
6643 },
6644 UiAnchor::Bottom,
6645 basic_caps(),
6646 );
6647 writer.set_size(80, 10);
6649
6650 let effective = writer.effective_ui_height();
6652 assert!(
6653 effective <= 10,
6654 "InlineAuto effective_ui_height must clamp to terminal height, got {effective}"
6655 );
6656
6657 let buffer = Buffer::new(80, effective);
6658 writer.present_ui(&buffer, None, true).unwrap();
6659 }
6660 }
6661
6662 #[test]
6665 fn noninterference_inline_oversized_height_no_panic() {
6666 let mut output = Vec::new();
6667 {
6668 let mut writer = TerminalWriter::new(
6669 &mut output,
6670 ScreenMode::Inline { ui_height: 100 },
6671 UiAnchor::Bottom,
6672 basic_caps(),
6673 );
6674 writer.set_size(80, 10);
6675
6676 let buffer = Buffer::new(80, 10);
6679 let result = writer.present_ui(&buffer, None, true);
6681 assert!(
6682 result.is_ok(),
6683 "present_ui must not panic with oversized ui_height"
6684 );
6685 }
6686 }
6687}