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