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