1#![forbid(unsafe_code)]
2
3use std::io::{self, BufWriter, Write};
54use std::sync::atomic::{AtomicU32, Ordering};
55use web_time::Instant;
56
57static INLINE_ACTIVE_WIDGETS: AtomicU32 = AtomicU32::new(0);
62
63pub fn inline_active_widgets() -> u32 {
65 INLINE_ACTIVE_WIDGETS.load(Ordering::Relaxed)
66}
67
68use crate::evidence_sink::EvidenceSink;
69use crate::evidence_telemetry::{DiffDecisionSnapshot, set_diff_snapshot};
70use crate::render_trace::{
71 RenderTraceFrame, RenderTraceRecorder, build_diff_runs_payload, build_full_buffer_payload,
72};
73use ftui_core::inline_mode::{InlineStrategy, sanitize_overlay_log_line};
74#[cfg(test)]
75use ftui_core::terminal_capabilities::ColorDepth;
76use ftui_core::terminal_capabilities::TerminalCapabilities;
77use ftui_core::terminal_session::terminal_output_lock;
78use ftui_render::buffer::{Buffer, DirtySpanConfig, DirtySpanStats};
79use ftui_render::counting_writer::CountingWriter;
80use ftui_render::diff::{BufferDiff, TileDiffConfig, TileDiffFallback, TileDiffStats};
81use ftui_render::diff_strategy::{DiffStrategy, DiffStrategyConfig, DiffStrategySelector};
82use ftui_render::grapheme_pool::GraphemePool;
83use ftui_render::link_registry::LinkRegistry;
84use ftui_render::presenter::Presenter;
85use ftui_render::render_certificate::{
86 RenderCertificate, RenderCertificateInputs, RenderCertificateLevel, evaluate_render_certificate,
87};
88use ftui_render::sanitize::sanitize;
89use tracing::{debug_span, info, info_span, trace, warn};
90
91#[allow(dead_code)] const BUFFER_CAPACITY: usize = 64 * 1024;
94
95const CURSOR_SAVE: &[u8] = b"\x1b7";
97
98const CURSOR_RESTORE: &[u8] = b"\x1b8";
100
101const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
103
104const SYNC_END: &[u8] = b"\x1b[?2026l";
106
107const ERASE_LINE: &[u8] = b"\x1b[2K";
109const SGR_BG_DEFAULT: &[u8] = b"\x1b[49m";
111
112#[allow(dead_code)] const FULL_REDRAW_PROBE_INTERVAL: u64 = 60;
115
116fn default_diff_run_id() -> String {
121 format!("diff-{}", std::process::id())
122}
123
124fn diff_strategy_str(strategy: DiffStrategy) -> &'static str {
125 match strategy {
126 DiffStrategy::Full => "full",
127 DiffStrategy::DirtyRows => "dirty",
128 DiffStrategy::FullRedraw => "redraw",
129 }
130}
131
132fn inline_strategy_str(strategy: InlineStrategy) -> &'static str {
133 match strategy {
134 InlineStrategy::ScrollRegion => "scroll_region",
135 InlineStrategy::OverlayRedraw => "overlay_redraw",
136 InlineStrategy::Hybrid => "hybrid",
137 }
138}
139
140fn ui_anchor_str(anchor: UiAnchor) -> &'static str {
141 match anchor {
142 UiAnchor::Bottom => "bottom",
143 UiAnchor::Top => "top",
144 }
145}
146
147#[allow(dead_code)]
148#[inline]
149fn json_escape(value: &str) -> String {
150 let mut out = String::with_capacity(value.len());
151 for ch in value.chars() {
152 match ch {
153 '"' => out.push_str("\\\""),
154 '\\' => out.push_str("\\\\"),
155 '\n' => out.push_str("\\n"),
156 '\r' => out.push_str("\\r"),
157 '\t' => out.push_str("\\t"),
158 c if c.is_control() => {
159 use std::fmt::Write as _;
160 let _ = write!(out, "\\u{:04X}", c as u32);
161 }
162 _ => out.push(ch),
163 }
164 }
165 out
166}
167
168#[allow(dead_code)]
169fn estimate_diff_scan_cost(
170 strategy: DiffStrategy,
171 dirty_rows: usize,
172 width: usize,
173 height: usize,
174 span_stats: &DirtySpanStats,
175 tile_stats: Option<TileDiffStats>,
176) -> (usize, &'static str) {
177 match strategy {
178 DiffStrategy::Full => (width.saturating_mul(height), "full_strategy"),
179 DiffStrategy::FullRedraw => (0, "full_redraw"),
180 DiffStrategy::DirtyRows => {
181 if dirty_rows == 0 {
182 return (0, "no_dirty_rows");
183 }
184 if let Some(tile_stats) = tile_stats
185 && tile_stats.fallback.is_none()
186 {
187 return (tile_stats.scan_cells_estimate, "tile_skip");
188 }
189 let span_cells = span_stats.span_coverage_cells;
190 if span_stats.overflows > 0 {
191 let estimate = if span_cells > 0 {
192 span_cells
193 } else {
194 dirty_rows.saturating_mul(width)
195 };
196 return (estimate, "span_overflow");
197 }
198 if span_cells > 0 {
199 (span_cells, "none")
200 } else {
201 (dirty_rows.saturating_mul(width), "no_spans")
202 }
203 }
204 }
205}
206
207fn sanitize_auto_bounds(min_height: u16, max_height: u16) -> (u16, u16) {
208 let min = min_height.max(1);
209 let max = max_height.max(min);
210 (min, max)
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub enum ScreenMode {
216 Inline {
218 ui_height: u16,
220 },
221 InlineAuto {
225 min_height: u16,
227 max_height: u16,
229 },
230 #[default]
232 AltScreen,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237pub enum UiAnchor {
238 #[default]
240 Bottom,
241 Top,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246struct InlineRegion {
247 start: u16,
248 height: u16,
249}
250
251struct DiffDecision {
252 #[allow(dead_code)] strategy: DiffStrategy,
254 has_diff: bool,
255}
256
257#[derive(Debug, Clone, Copy)]
258#[allow(dead_code)]
259struct EmitStats {
260 diff_cells: usize,
261 diff_runs: usize,
262}
263
264#[derive(Debug, Clone, Copy)]
265#[allow(dead_code)]
266struct FrameEmitStats {
267 diff_strategy: DiffStrategy,
268 diff_cells: usize,
269 diff_runs: usize,
270 ui_height: u16,
271}
272
273#[derive(Debug, Clone, Copy)]
274#[allow(dead_code)]
275pub struct PresentTimings {
276 pub diff_us: u64,
277}
278
279#[derive(Debug, Clone)]
308pub struct RuntimeDiffConfig {
309 pub bayesian_enabled: bool,
319
320 pub dirty_rows_enabled: bool,
327
328 pub certified_skips: bool,
336
337 pub dirty_span_config: DirtySpanConfig,
341
342 pub tile_diff_config: TileDiffConfig,
346
347 pub reset_on_resize: bool,
354
355 pub reset_on_invalidation: bool,
362
363 pub strategy_config: DiffStrategyConfig,
367
368 pub full_redraw_interval_frames: u64,
376
377 pub full_redraw_max_interval: Option<std::time::Duration>,
390}
391
392impl Default for RuntimeDiffConfig {
393 fn default() -> Self {
394 Self {
395 bayesian_enabled: true,
396 dirty_rows_enabled: true,
397 certified_skips: true,
398 dirty_span_config: DirtySpanConfig::default(),
399 tile_diff_config: TileDiffConfig::default(),
400 reset_on_resize: true,
401 reset_on_invalidation: true,
402 strategy_config: DiffStrategyConfig::default(),
403 full_redraw_interval_frames: 240,
404 full_redraw_max_interval: None,
405 }
406 }
407}
408
409impl RuntimeDiffConfig {
410 pub fn new() -> Self {
412 Self::default()
413 }
414
415 #[must_use]
417 pub fn with_bayesian_enabled(mut self, enabled: bool) -> Self {
418 self.bayesian_enabled = enabled;
419 self
420 }
421
422 #[must_use]
424 pub fn with_dirty_rows_enabled(mut self, enabled: bool) -> Self {
425 self.dirty_rows_enabled = enabled;
426 self
427 }
428
429 #[must_use]
431 pub fn with_dirty_spans_enabled(mut self, enabled: bool) -> Self {
432 self.dirty_span_config = self.dirty_span_config.with_enabled(enabled);
433 self
434 }
435
436 #[must_use]
438 pub fn with_dirty_span_config(mut self, config: DirtySpanConfig) -> Self {
439 self.dirty_span_config = config;
440 self
441 }
442
443 #[must_use]
445 pub fn with_tile_skip_enabled(mut self, enabled: bool) -> Self {
446 self.tile_diff_config = self.tile_diff_config.with_enabled(enabled);
447 self
448 }
449
450 #[must_use]
452 pub fn with_tile_diff_config(mut self, config: TileDiffConfig) -> Self {
453 self.tile_diff_config = config;
454 self
455 }
456
457 #[must_use]
459 pub fn with_reset_on_resize(mut self, enabled: bool) -> Self {
460 self.reset_on_resize = enabled;
461 self
462 }
463
464 #[must_use]
466 pub fn with_reset_on_invalidation(mut self, enabled: bool) -> Self {
467 self.reset_on_invalidation = enabled;
468 self
469 }
470
471 #[must_use]
473 pub fn with_strategy_config(mut self, config: DiffStrategyConfig) -> Self {
474 self.strategy_config = config;
475 self
476 }
477
478 #[must_use]
482 pub fn with_full_redraw_interval_frames(mut self, frames: u64) -> Self {
483 self.full_redraw_interval_frames = frames;
484 self
485 }
486
487 #[must_use]
496 pub fn with_full_redraw_max_interval(mut self, interval: Option<std::time::Duration>) -> Self {
497 self.full_redraw_max_interval = interval;
498 self
499 }
500}
501
502pub struct TerminalWriter<W: Write> {
507 presenter: Option<Presenter<W>>,
511 screen_mode: ScreenMode,
513 auto_ui_height: Option<u16>,
515 ui_anchor: UiAnchor,
517 prev_buffer: Option<Buffer>,
519 spare_buffer: Option<Buffer>,
521 clone_buf: Option<Buffer>,
524 pool: GraphemePool,
526 links: LinkRegistry,
528 capabilities: TerminalCapabilities,
530 term_width: u16,
532 term_height: u16,
534 in_sync_block: bool,
536 cursor_saved: bool,
538 cursor_visible: bool,
540 inline_strategy: InlineStrategy,
542 scroll_region_active: bool,
544 last_inline_region: Option<InlineRegion>,
546 diff_strategy: DiffStrategySelector,
548 diff_scratch: BufferDiff,
550 full_redraw_probe: u64,
552 frames_since_full_redraw: u64,
554 last_full_redraw_at: Option<Instant>,
559 #[allow(dead_code)] diff_config: RuntimeDiffConfig,
562 evidence_sink: Option<EvidenceSink>,
564 #[allow(dead_code)]
566 last_certificate: Option<RenderCertificate>,
568 diff_evidence_run_id: String,
569 #[allow(dead_code)]
571 diff_evidence_idx: u64,
572 last_diff_strategy: Option<DiffStrategy>,
574 render_trace: Option<RenderTraceRecorder>,
576 timing_enabled: bool,
578 last_present_timings: Option<PresentTimings>,
580}
581
582impl<W: Write> TerminalWriter<W> {
583 pub fn new(
592 writer: W,
593 screen_mode: ScreenMode,
594 ui_anchor: UiAnchor,
595 capabilities: TerminalCapabilities,
596 ) -> Self {
597 Self::with_diff_config(
598 writer,
599 screen_mode,
600 ui_anchor,
601 capabilities,
602 RuntimeDiffConfig::default(),
603 )
604 }
605
606 pub fn with_diff_config(
635 writer: W,
636 screen_mode: ScreenMode,
637 ui_anchor: UiAnchor,
638 capabilities: TerminalCapabilities,
639 diff_config: RuntimeDiffConfig,
640 ) -> Self {
641 let inline_strategy = InlineStrategy::select(&capabilities);
642 let auto_ui_height = None;
643 let diff_strategy = DiffStrategySelector::new(diff_config.strategy_config.clone());
644
645 let is_inline = matches!(
650 screen_mode,
651 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
652 );
653 if is_inline {
654 INLINE_ACTIVE_WIDGETS.fetch_add(1, Ordering::SeqCst);
655 }
656
657 match screen_mode {
659 ScreenMode::Inline { ui_height } => {
660 info!(
661 inline_height = ui_height,
662 render_mode = %inline_strategy_str(inline_strategy),
663 "inline mode activated"
664 );
665 }
666 ScreenMode::InlineAuto {
667 min_height,
668 max_height,
669 } => {
670 info!(
671 min_height,
672 max_height,
673 render_mode = %inline_strategy_str(inline_strategy),
674 "inline auto mode activated"
675 );
676 }
677 ScreenMode::AltScreen => {}
678 }
679
680 let mut diff_scratch = BufferDiff::new();
681 diff_scratch
682 .tile_config_mut()
683 .clone_from(&diff_config.tile_diff_config);
684
685 let presenter = Presenter::new(writer, capabilities);
686
687 Self {
688 presenter: Some(presenter),
689 screen_mode,
690 auto_ui_height,
691 ui_anchor,
692 prev_buffer: None,
693 spare_buffer: None,
694 clone_buf: None,
695 pool: GraphemePool::new(),
696 links: LinkRegistry::new(),
697 capabilities,
698 term_width: 80,
699 term_height: 24,
700 in_sync_block: false,
701 cursor_saved: false,
702 cursor_visible: true,
703 inline_strategy,
704 scroll_region_active: false,
705 last_inline_region: None,
706 diff_strategy,
707 diff_scratch,
708 full_redraw_probe: 0,
709 frames_since_full_redraw: 0,
710 last_full_redraw_at: None,
711 diff_config,
712 evidence_sink: None,
713 diff_evidence_run_id: default_diff_run_id(),
714 diff_evidence_idx: 0,
715 last_diff_strategy: None,
716 last_certificate: None,
717 render_trace: None,
718 timing_enabled: false,
719 last_present_timings: None,
720 }
721 }
722
723 #[inline]
729 fn writer(&mut self) -> &mut CountingWriter<BufWriter<W>> {
730 self.presenter_mut().counting_writer_mut()
731 }
732
733 #[inline]
739 fn presenter_mut(&mut self) -> &mut Presenter<W> {
740 self.presenter
741 .as_mut()
742 .expect("presenter has been consumed")
743 }
744
745 fn reset_diff_strategy(&mut self) {
747 if self.diff_config.reset_on_invalidation {
748 self.diff_strategy.reset();
749 }
750 self.full_redraw_probe = 0;
751 self.frames_since_full_redraw = 0;
752 self.last_diff_strategy = None;
753 }
754
755 #[allow(dead_code)] fn reset_diff_on_resize(&mut self) {
758 if self.diff_config.reset_on_resize {
759 self.diff_strategy.reset();
760 }
761 self.full_redraw_probe = 0;
762 self.frames_since_full_redraw = 0;
763 self.last_diff_strategy = None;
764 }
765
766 pub fn diff_config(&self) -> &RuntimeDiffConfig {
768 &self.diff_config
769 }
770
771 pub(crate) fn set_timing_enabled(&mut self, enabled: bool) {
773 self.timing_enabled = enabled;
774 if !enabled {
775 self.last_present_timings = None;
776 }
777 }
778
779 pub(crate) fn take_last_present_timings(&mut self) -> Option<PresentTimings> {
781 self.last_present_timings.take()
782 }
783
784 #[must_use]
786 pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
787 self.evidence_sink = Some(sink);
788 self
789 }
790
791 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
793 self.evidence_sink = sink;
794 }
795
796 #[must_use]
798 pub fn with_render_trace(mut self, recorder: RenderTraceRecorder) -> Self {
799 self.render_trace = Some(recorder);
800 self
801 }
802
803 pub fn set_render_trace(&mut self, recorder: Option<RenderTraceRecorder>) {
805 self.render_trace = recorder;
806 }
807
808 pub fn diff_strategy_mut(&mut self) -> &mut DiffStrategySelector {
812 &mut self.diff_strategy
813 }
814
815 pub fn diff_strategy(&self) -> &DiffStrategySelector {
817 &self.diff_strategy
818 }
819
820 pub fn last_diff_strategy(&self) -> Option<DiffStrategy> {
822 self.last_diff_strategy
823 }
824
825 #[must_use]
829 pub fn last_render_certificate(&self) -> Option<&RenderCertificate> {
830 self.last_certificate.as_ref()
831 }
832
833 pub fn set_size(&mut self, width: u16, height: u16) {
837 let _output_guard = terminal_output_lock();
840 self.term_width = width;
841 self.term_height = height;
842 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. }) {
843 self.auto_ui_height = None;
844 }
845 self.prev_buffer = None;
847 self.last_inline_region = None;
851 self.spare_buffer = None;
852 self.clone_buf = None;
853 self.reset_diff_on_resize();
854 if self.scroll_region_active {
856 let _ = self.deactivate_scroll_region();
857 }
858 }
859
860 pub fn take_render_buffer(&mut self, width: u16, height: u16) -> Buffer {
864 if let Some(mut buffer) = self.spare_buffer.take()
865 && buffer.width() == width
866 && buffer.height() == height
867 {
868 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
869 buffer.reset_for_frame();
870 return buffer;
871 }
872
873 let mut buffer = Buffer::new(width, height);
874 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
875 buffer
876 }
877
878 #[inline]
880 pub fn width(&self) -> u16 {
881 self.term_width
882 }
883
884 #[inline]
886 pub fn height(&self) -> u16 {
887 self.term_height
888 }
889
890 #[inline]
892 pub fn screen_mode(&self) -> ScreenMode {
893 self.screen_mode
894 }
895
896 pub fn render_height_hint(&self) -> u16 {
901 match self.screen_mode {
902 ScreenMode::Inline { ui_height } => ui_height,
903 ScreenMode::InlineAuto {
904 min_height,
905 max_height,
906 } => {
907 let (min, max) = sanitize_auto_bounds(min_height, max_height);
908 let max = max.min(self.term_height);
909 let min = min.min(max);
910 if let Some(current) = self.auto_ui_height {
911 current.clamp(min, max).min(self.term_height).max(min)
912 } else {
913 max.max(min)
914 }
915 }
916 ScreenMode::AltScreen => self.term_height,
917 }
918 }
919
920 pub fn inline_auto_bounds(&self) -> Option<(u16, u16)> {
922 match self.screen_mode {
923 ScreenMode::InlineAuto {
924 min_height,
925 max_height,
926 } => {
927 let (min, max) = sanitize_auto_bounds(min_height, max_height);
928 Some((min.min(self.term_height), max.min(self.term_height)))
929 }
930 _ => None,
931 }
932 }
933
934 pub fn auto_ui_height(&self) -> Option<u16> {
936 match self.screen_mode {
937 ScreenMode::InlineAuto { .. } => self.auto_ui_height,
938 _ => None,
939 }
940 }
941
942 pub fn set_auto_ui_height(&mut self, height: u16) {
944 if let ScreenMode::InlineAuto {
945 min_height,
946 max_height,
947 } = self.screen_mode
948 {
949 let (min, max) = sanitize_auto_bounds(min_height, max_height);
950 let max = max.min(self.term_height);
951 let min = min.min(max);
952 let clamped = height.clamp(min, max);
953 let previous_effective = self.auto_ui_height.unwrap_or(min);
954 if self.auto_ui_height != Some(clamped) {
955 self.auto_ui_height = Some(clamped);
956 if clamped != previous_effective {
957 self.prev_buffer = None;
958 self.reset_diff_strategy();
959 if self.scroll_region_active {
960 let _ = self.deactivate_scroll_region();
961 }
962 }
963 }
964 }
965 }
966
967 pub fn clear_auto_ui_height(&mut self) {
969 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. })
970 && self.auto_ui_height.is_some()
971 {
972 self.auto_ui_height = None;
973 self.prev_buffer = None;
974 self.reset_diff_strategy();
975 if self.scroll_region_active {
976 let _ = self.deactivate_scroll_region();
977 }
978 }
979 }
980
981 fn effective_ui_height(&self) -> u16 {
982 match self.screen_mode {
983 ScreenMode::Inline { ui_height } => ui_height,
984 ScreenMode::InlineAuto {
985 min_height,
986 max_height,
987 } => {
988 let (min, max) = sanitize_auto_bounds(min_height, max_height);
989 let current = self.auto_ui_height.unwrap_or(min);
990 current.clamp(min, max).min(self.term_height)
991 }
992 ScreenMode::AltScreen => self.term_height,
993 }
994 }
995
996 pub fn ui_height(&self) -> u16 {
998 self.effective_ui_height()
999 }
1000
1001 fn ui_start_row(&self) -> u16 {
1003 let ui_height = self.effective_ui_height().min(self.term_height);
1004 match (self.screen_mode, self.ui_anchor) {
1005 (ScreenMode::Inline { .. }, UiAnchor::Bottom)
1006 | (ScreenMode::InlineAuto { .. }, UiAnchor::Bottom) => {
1007 self.term_height.saturating_sub(ui_height)
1008 }
1009 (ScreenMode::Inline { .. }, UiAnchor::Top)
1010 | (ScreenMode::InlineAuto { .. }, UiAnchor::Top) => 0,
1011 (ScreenMode::AltScreen, _) => 0,
1012 }
1013 }
1014
1015 pub fn inline_strategy(&self) -> InlineStrategy {
1017 self.inline_strategy
1018 }
1019
1020 pub fn scroll_region_active(&self) -> bool {
1022 self.scroll_region_active
1023 }
1024
1025 fn activate_scroll_region(&mut self, ui_height: u16) -> io::Result<()> {
1033 if self.scroll_region_active {
1034 return Ok(());
1035 }
1036
1037 let ui_height = ui_height.min(self.term_height);
1038 if ui_height >= self.term_height {
1039 return Ok(());
1040 }
1041
1042 match self.ui_anchor {
1043 UiAnchor::Bottom => {
1044 let term_height = self.term_height;
1045 let log_bottom = term_height.saturating_sub(ui_height);
1046 if log_bottom > 0 {
1047 write!(self.writer(), "\x1b[1;{}r", log_bottom)?;
1049 self.scroll_region_active = true;
1050 }
1051 }
1052 UiAnchor::Top => {
1053 let term_height = self.term_height;
1054 let log_top = ui_height.saturating_add(1);
1055 if log_top <= term_height {
1056 write!(self.writer(), "\x1b[{};{}r", log_top, term_height)?;
1058 self.scroll_region_active = true;
1059 write!(self.writer(), "\x1b[{};1H", log_top)?;
1062 }
1063 }
1064 }
1065 Ok(())
1066 }
1067
1068 fn deactivate_scroll_region(&mut self) -> io::Result<()> {
1070 if self.scroll_region_active {
1071 self.writer().write_all(b"\x1b[r")?;
1072 self.scroll_region_active = false;
1073 }
1074 Ok(())
1075 }
1076
1077 fn clear_rows(&mut self, start_row: u16, height: u16) -> io::Result<()> {
1078 let start_row = start_row.min(self.term_height);
1079 let end_row = start_row.saturating_add(height).min(self.term_height);
1080 if start_row >= end_row {
1081 return Ok(());
1082 }
1083
1084 self.writer().write_all(SGR_BG_DEFAULT)?;
1087 for row in start_row..end_row {
1088 write!(self.writer(), "\x1b[{};1H", row.saturating_add(1))?;
1089 self.writer().write_all(ERASE_LINE)?;
1090 }
1091 Ok(())
1092 }
1093
1094 fn clear_inline_region_diff(&mut self, current: InlineRegion) -> io::Result<()> {
1095 let Some(previous) = self.last_inline_region else {
1096 return Ok(());
1097 };
1098
1099 let prev_start = previous.start.min(self.term_height);
1100 let prev_end = previous
1101 .start
1102 .saturating_add(previous.height)
1103 .min(self.term_height);
1104 if prev_start >= prev_end {
1105 return Ok(());
1106 }
1107
1108 let curr_start = current.start.min(self.term_height);
1109 let curr_end = current
1110 .start
1111 .saturating_add(current.height)
1112 .min(self.term_height);
1113
1114 if curr_start > prev_start {
1115 let clear_end = curr_start.min(prev_end);
1116 if clear_end > prev_start {
1117 self.clear_rows(prev_start, clear_end - prev_start)?;
1118 }
1119 }
1120
1121 if curr_end < prev_end {
1122 let clear_start = curr_end.max(prev_start);
1123 if prev_end > clear_start {
1124 self.clear_rows(clear_start, prev_end - clear_start)?;
1125 }
1126 }
1127
1128 Ok(())
1129 }
1130
1131 pub fn present_ui(
1145 &mut self,
1146 buffer: &Buffer,
1147 cursor: Option<(u16, u16)>,
1148 cursor_visible: bool,
1149 ) -> io::Result<()> {
1150 let _output_guard = terminal_output_lock();
1153 let mode_str = match self.screen_mode {
1154 ScreenMode::Inline { .. } => "inline",
1155 ScreenMode::InlineAuto { .. } => "inline_auto",
1156 ScreenMode::AltScreen => "altscreen",
1157 };
1158 let trace_enabled = self.render_trace.is_some();
1159 if trace_enabled {
1160 self.writer().reset_counter();
1161 }
1162 let present_start = if trace_enabled {
1163 Some(Instant::now())
1164 } else {
1165 None
1166 };
1167 let _span = info_span!(
1168 "ftui.render.present",
1169 mode = mode_str,
1170 width = buffer.width(),
1171 height = buffer.height(),
1172 )
1173 .entered();
1174
1175 let result = match self.screen_mode {
1176 ScreenMode::Inline { ui_height } => {
1177 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1178 }
1179 ScreenMode::InlineAuto { .. } => {
1180 let ui_height = self.effective_ui_height();
1181 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1182 }
1183 ScreenMode::AltScreen => self.present_altscreen(buffer, cursor, cursor_visible),
1184 };
1185
1186 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1187 let present_bytes = if trace_enabled {
1188 {
1189 let w = self.writer();
1190 let count = w.bytes_written();
1191 w.reset_counter();
1192 Some(count)
1193 }
1194 } else {
1195 None
1196 };
1197 if trace_enabled {
1198 }
1200
1201 if let Ok(stats) = result {
1202 self.record_successful_present(stats.diff_strategy);
1203 let new_prev = match self.clone_buf.take() {
1208 Some(mut buf)
1209 if buf.width() == buffer.width() && buf.height() == buffer.height() =>
1210 {
1211 buf.clone_from(buffer);
1212 buf
1213 }
1214 _ => buffer.clone(),
1215 };
1216 self.clone_buf = self.spare_buffer.take();
1217 self.spare_buffer = self.prev_buffer.take();
1218 self.prev_buffer = Some(new_prev);
1219
1220 if let Some(ref mut trace) = self.render_trace {
1221 let payload_info = match stats.diff_strategy {
1222 DiffStrategy::FullRedraw => {
1223 let payload = build_full_buffer_payload(buffer, &self.pool);
1224 trace.write_payload(&payload).ok()
1225 }
1226 _ => {
1227 let payload =
1228 build_diff_runs_payload(buffer, &self.diff_scratch, &self.pool);
1229 trace.write_payload(&payload).ok()
1230 }
1231 };
1232 let (payload_kind, payload_path) = match payload_info {
1233 Some(info) => (info.kind, Some(info.path)),
1234 None => ("none", None),
1235 };
1236 let payload_path_ref = payload_path.as_deref();
1237 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1238 let ui_anchor = ui_anchor_str(self.ui_anchor);
1239 let frame = RenderTraceFrame {
1240 cols: buffer.width(),
1241 rows: buffer.height(),
1242 mode: mode_str,
1243 ui_height: stats.ui_height,
1244 ui_anchor,
1245 diff_strategy,
1246 diff_cells: stats.diff_cells,
1247 diff_runs: stats.diff_runs,
1248 present_bytes: present_bytes.unwrap_or(0),
1249 render_us: None,
1250 present_us,
1251 payload_kind,
1252 payload_path: payload_path_ref,
1253 trace_us: None,
1254 };
1255 let _ = trace.record_frame(frame, buffer, &self.pool);
1256 }
1257 return Ok(());
1258 }
1259
1260 self.invalidate_after_present_error();
1261 result.map(|_| ())
1262 }
1263
1264 pub fn present_ui_owned(
1269 &mut self,
1270 buffer: Buffer,
1271 cursor: Option<(u16, u16)>,
1272 cursor_visible: bool,
1273 ) -> io::Result<()> {
1274 let _output_guard = terminal_output_lock();
1276 let mode_str = match self.screen_mode {
1277 ScreenMode::Inline { .. } => "inline",
1278 ScreenMode::InlineAuto { .. } => "inline_auto",
1279 ScreenMode::AltScreen => "altscreen",
1280 };
1281 let trace_enabled = self.render_trace.is_some();
1282 if trace_enabled {
1283 self.writer().reset_counter();
1284 }
1285 let present_start = if trace_enabled {
1286 Some(Instant::now())
1287 } else {
1288 None
1289 };
1290 let _span = info_span!(
1291 "ftui.render.present",
1292 mode = mode_str,
1293 width = buffer.width(),
1294 height = buffer.height(),
1295 )
1296 .entered();
1297
1298 let result = match self.screen_mode {
1299 ScreenMode::Inline { ui_height } => {
1300 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1301 }
1302 ScreenMode::InlineAuto { .. } => {
1303 let ui_height = self.effective_ui_height();
1304 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1305 }
1306 ScreenMode::AltScreen => self.present_altscreen(&buffer, cursor, cursor_visible),
1307 };
1308
1309 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1310 let present_bytes = if trace_enabled {
1311 {
1312 let w = self.writer();
1313 let count = w.bytes_written();
1314 w.reset_counter();
1315 Some(count)
1316 }
1317 } else {
1318 None
1319 };
1320 if trace_enabled {
1321 }
1323
1324 if let Ok(stats) = result {
1325 self.record_successful_present(stats.diff_strategy);
1326 if let Some(ref mut trace) = self.render_trace {
1327 let payload_info = match stats.diff_strategy {
1328 DiffStrategy::FullRedraw => {
1329 let payload = build_full_buffer_payload(&buffer, &self.pool);
1330 trace.write_payload(&payload).ok()
1331 }
1332 _ => {
1333 let payload =
1334 build_diff_runs_payload(&buffer, &self.diff_scratch, &self.pool);
1335 trace.write_payload(&payload).ok()
1336 }
1337 };
1338 let (payload_kind, payload_path) = match payload_info {
1339 Some(info) => (info.kind, Some(info.path)),
1340 None => ("none", None),
1341 };
1342 let payload_path_ref = payload_path.as_deref();
1343 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1344 let ui_anchor = ui_anchor_str(self.ui_anchor);
1345 let frame = RenderTraceFrame {
1346 cols: buffer.width(),
1347 rows: buffer.height(),
1348 mode: mode_str,
1349 ui_height: stats.ui_height,
1350 ui_anchor,
1351 diff_strategy,
1352 diff_cells: stats.diff_cells,
1353 diff_runs: stats.diff_runs,
1354 present_bytes: present_bytes.unwrap_or(0),
1355 render_us: None,
1356 present_us,
1357 payload_kind,
1358 payload_path: payload_path_ref,
1359 trace_us: None,
1360 };
1361 let _ = trace.record_frame(frame, &buffer, &self.pool);
1362 }
1363
1364 self.clone_buf = self.spare_buffer.take();
1366 self.spare_buffer = self.prev_buffer.take();
1367 self.prev_buffer = Some(buffer);
1368 return Ok(());
1369 }
1370
1371 self.invalidate_after_present_error();
1372 result.map(|_| ())
1373 }
1374
1375 fn decide_diff(&mut self, buffer: &Buffer) -> DiffDecision {
1376 let prev_dims = self
1377 .prev_buffer
1378 .as_ref()
1379 .map(|prev| (prev.width(), prev.height()));
1380 if prev_dims.is_none() || prev_dims != Some((buffer.width(), buffer.height())) {
1381 self.full_redraw_probe = 0;
1382 self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1383 self.last_certificate = Some(evaluate_render_certificate(
1384 &RenderCertificateInputs {
1385 prev_available: prev_dims.is_some(),
1386 dims_changed: prev_dims.is_some(),
1387 full_redraw_due: false,
1388 dirty_row_count: buffer.dirty_row_count(),
1389 total_rows: buffer.height(),
1390 },
1391 Vec::new(),
1392 ));
1393 return DiffDecision {
1394 strategy: DiffStrategy::FullRedraw,
1395 has_diff: false,
1396 };
1397 }
1398
1399 if self.full_redraw_interval_due() {
1400 self.full_redraw_probe = 0;
1401 self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1402 self.last_certificate = Some(evaluate_render_certificate(
1403 &RenderCertificateInputs {
1404 prev_available: true,
1405 dims_changed: false,
1406 full_redraw_due: true,
1407 dirty_row_count: buffer.dirty_row_count(),
1408 total_rows: buffer.height(),
1409 },
1410 Vec::new(),
1411 ));
1412 return DiffDecision {
1413 strategy: DiffStrategy::FullRedraw,
1414 has_diff: false,
1415 };
1416 }
1417
1418 let dirty_rows = buffer.dirty_row_count();
1419 let width = buffer.width() as usize;
1420 let height = buffer.height() as usize;
1421 let mut span_stats_snapshot: Option<DirtySpanStats> = None;
1422 let mut dirty_scan_cells_estimate = dirty_rows.saturating_mul(width);
1423
1424 if self.diff_config.bayesian_enabled {
1425 let span_stats = buffer.dirty_span_stats();
1426 if span_stats.span_coverage_cells > 0 {
1427 dirty_scan_cells_estimate = span_stats.span_coverage_cells;
1428 }
1429 span_stats_snapshot = Some(span_stats);
1430 }
1431
1432 let mut strategy = if self.diff_config.bayesian_enabled {
1434 self.diff_strategy.select_with_scan_estimate(
1436 buffer.width(),
1437 buffer.height(),
1438 dirty_rows,
1439 dirty_scan_cells_estimate,
1440 )
1441 } else {
1442 if self.diff_config.dirty_rows_enabled && dirty_rows < buffer.height() as usize {
1444 DiffStrategy::DirtyRows
1445 } else {
1446 DiffStrategy::Full
1447 }
1448 };
1449
1450 if !self.diff_config.dirty_rows_enabled && strategy == DiffStrategy::DirtyRows {
1452 strategy = DiffStrategy::Full;
1453 if self.diff_config.bayesian_enabled {
1454 self.diff_strategy
1455 .override_last_strategy(strategy, "dirty_rows_disabled");
1456 }
1457 }
1458
1459 if strategy == DiffStrategy::FullRedraw {
1461 if self.full_redraw_probe >= FULL_REDRAW_PROBE_INTERVAL {
1462 self.full_redraw_probe = 0;
1463 let probed = if self.diff_config.dirty_rows_enabled
1464 && dirty_rows < buffer.height() as usize
1465 {
1466 DiffStrategy::DirtyRows
1467 } else {
1468 DiffStrategy::Full
1469 };
1470 if probed != strategy {
1471 strategy = probed;
1472 if self.diff_config.bayesian_enabled {
1473 self.diff_strategy
1474 .override_last_strategy(strategy, "full_redraw_probe");
1475 }
1476 }
1477 } else {
1478 self.full_redraw_probe = self.full_redraw_probe.saturating_add(1);
1479 }
1480 } else {
1481 self.full_redraw_probe = 0;
1482 }
1483
1484 let mut has_diff = false;
1485 match strategy {
1486 DiffStrategy::Full => {
1487 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1488 self.diff_scratch.compute_into(prev, buffer);
1489 self.last_certificate = Some(RenderCertificate {
1490 level: RenderCertificateLevel::FullRequired,
1491 causes: vec!["strategy-selected-full"],
1492 dirty_rows: Vec::new(),
1493 fell_back: false,
1494 });
1495 has_diff = true;
1496 }
1497 DiffStrategy::DirtyRows => {
1498 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1499 if self.diff_config.certified_skips {
1500 let certificate = evaluate_render_certificate(
1501 &RenderCertificateInputs {
1502 prev_available: true,
1503 dims_changed: false,
1504 full_redraw_due: false,
1505 dirty_row_count: dirty_rows,
1506 total_rows: buffer.height(),
1507 },
1508 buffer.dirty_row_indices(),
1509 );
1510 self.diff_scratch
1511 .compute_certified_into(prev, buffer, certificate.to_hint());
1512 self.last_certificate = Some(certificate);
1513 } else {
1514 self.diff_scratch.compute_dirty_into(prev, buffer);
1515 self.last_certificate = None;
1516 }
1517 has_diff = true;
1518 }
1519 DiffStrategy::FullRedraw => {}
1520 }
1521
1522 let mut scan_cost_estimate = 0usize;
1523 let mut fallback_reason: &'static str = "none";
1524 let tile_stats = if strategy == DiffStrategy::DirtyRows {
1525 self.diff_scratch.last_tile_stats()
1526 } else {
1527 None
1528 };
1529
1530 if self.diff_config.bayesian_enabled && has_diff {
1532 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1533 let (scan_cost, reason) = estimate_diff_scan_cost(
1534 strategy,
1535 dirty_rows,
1536 width,
1537 height,
1538 &span_stats,
1539 tile_stats,
1540 );
1541 let scanned_cells = scan_cost.max(self.diff_scratch.len());
1542 self.diff_strategy
1543 .observe(scanned_cells, self.diff_scratch.len());
1544 span_stats_snapshot = Some(span_stats);
1545 scan_cost_estimate = scan_cost;
1546 fallback_reason = reason;
1547 }
1548
1549 if let Some(evidence) = self.diff_strategy.last_evidence() {
1550 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1551 let (scan_cost, reason) = if span_stats_snapshot.is_some() {
1552 (scan_cost_estimate, fallback_reason)
1553 } else {
1554 estimate_diff_scan_cost(
1555 strategy,
1556 dirty_rows,
1557 width,
1558 height,
1559 &span_stats,
1560 tile_stats,
1561 )
1562 };
1563 let span_coverage_pct = if evidence.total_cells == 0 {
1564 0.0
1565 } else {
1566 (span_stats.span_coverage_cells as f64 / evidence.total_cells as f64) * 100.0
1567 };
1568 let span_count = span_stats.total_spans;
1569 let max_span_len = span_stats.max_span_len;
1570 let event_idx = self.diff_evidence_idx;
1571 self.diff_evidence_idx = self.diff_evidence_idx.saturating_add(1);
1572 let tile_used = tile_stats.is_some_and(|stats| stats.fallback.is_none());
1573 let tile_fallback = tile_stats
1574 .and_then(|stats| stats.fallback)
1575 .map(TileDiffFallback::as_str)
1576 .unwrap_or("none");
1577 let run_id = json_escape(&self.diff_evidence_run_id);
1578 let strategy_json = json_escape(&strategy.to_string());
1579 let guard_reason_json = json_escape(evidence.guard_reason);
1580 let fallback_reason_json = json_escape(reason);
1581 let tile_fallback_json = json_escape(tile_fallback);
1582 let schema_version = crate::evidence_sink::EVIDENCE_SCHEMA_VERSION;
1583 let screen_mode = match self.screen_mode {
1584 ScreenMode::Inline { .. } => "inline",
1585 ScreenMode::InlineAuto { .. } => "inline_auto",
1586 ScreenMode::AltScreen => "altscreen",
1587 };
1588 let (
1589 tile_w,
1590 tile_h,
1591 tiles_x,
1592 tiles_y,
1593 dirty_tiles,
1594 dirty_cells,
1595 dirty_tile_ratio,
1596 dirty_cell_ratio,
1597 scanned_tiles,
1598 skipped_tiles,
1599 scan_cells_estimate,
1600 sat_build_cells,
1601 ) = if let Some(stats) = tile_stats {
1602 (
1603 stats.tile_w,
1604 stats.tile_h,
1605 stats.tiles_x,
1606 stats.tiles_y,
1607 stats.dirty_tiles,
1608 stats.dirty_cells,
1609 stats.dirty_tile_ratio,
1610 stats.dirty_cell_ratio,
1611 stats.scanned_tiles,
1612 stats.skipped_tiles,
1613 stats.scan_cells_estimate,
1614 stats.sat_build_cells,
1615 )
1616 } else {
1617 (0, 0, 0, 0, 0, 0, 0.0, 0.0, 0, 0, 0, 0)
1618 };
1619 let tile_size = tile_w as usize * tile_h as usize;
1620 let dirty_tile_count = dirty_tiles;
1621 let skipped_tile_count = skipped_tiles;
1622 let sat_build_cost_est = sat_build_cells;
1623
1624 set_diff_snapshot(Some(DiffDecisionSnapshot {
1625 event_idx,
1626 screen_mode: screen_mode.to_string(),
1627 cols: u16::try_from(width).unwrap_or(u16::MAX),
1628 rows: u16::try_from(height).unwrap_or(u16::MAX),
1629 evidence: evidence.clone(),
1630 span_count,
1631 span_coverage_pct,
1632 max_span_len,
1633 scan_cost_estimate: scan_cost,
1634 fallback_reason: reason.to_string(),
1635 tile_used,
1636 tile_fallback: tile_fallback.to_string(),
1637 strategy_used: strategy,
1638 }));
1639
1640 trace!(
1641 strategy = %strategy,
1642 selected = %evidence.strategy,
1643 cost_full = evidence.cost_full,
1644 cost_dirty = evidence.cost_dirty,
1645 cost_redraw = evidence.cost_redraw,
1646 dirty_rows = evidence.dirty_rows,
1647 total_rows = evidence.total_rows,
1648 total_cells = evidence.total_cells,
1649 bayesian_enabled = self.diff_config.bayesian_enabled,
1650 dirty_rows_enabled = self.diff_config.dirty_rows_enabled,
1651 "diff strategy selected"
1652 );
1653 if let Some(ref sink) = self.evidence_sink {
1654 let line = format!(
1655 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":{}}}"#,
1656 schema_version,
1657 run_id,
1658 event_idx,
1659 screen_mode,
1660 width,
1661 height,
1662 strategy_json,
1663 evidence.cost_full,
1664 evidence.cost_dirty,
1665 evidence.cost_redraw,
1666 evidence.posterior_mean,
1667 evidence.posterior_variance,
1668 evidence.alpha,
1669 evidence.beta,
1670 guard_reason_json,
1671 evidence.hysteresis_applied,
1672 evidence.hysteresis_ratio,
1673 evidence.dirty_rows,
1674 evidence.total_rows,
1675 evidence.total_cells,
1676 span_count,
1677 span_coverage_pct,
1678 max_span_len,
1679 fallback_reason_json,
1680 scan_cost,
1681 tile_used,
1682 tile_fallback_json,
1683 tile_w,
1684 tile_h,
1685 tile_size,
1686 tiles_x,
1687 tiles_y,
1688 dirty_tiles,
1689 dirty_tile_count,
1690 dirty_cells,
1691 dirty_tile_ratio,
1692 dirty_cell_ratio,
1693 scanned_tiles,
1694 skipped_tiles,
1695 skipped_tile_count,
1696 scan_cells_estimate,
1697 sat_build_cost_est,
1698 self.diff_config.bayesian_enabled,
1699 self.diff_config.dirty_rows_enabled,
1700 );
1701 let _ = sink.write_jsonl(&line);
1702 }
1703 }
1704
1705 if let Some(ref certificate) = self.last_certificate
1708 && let Some(ref sink) = self.evidence_sink
1709 {
1710 let line = format!(
1711 r#"{{"event":"certificate_decision","run_id":"{}","event_idx":{},"strategy":"{:?}","certificate":{}}}"#,
1712 self.diff_evidence_run_id,
1713 self.diff_evidence_idx,
1714 strategy,
1715 certificate.to_evidence_json()
1716 );
1717 let _ = sink.write_jsonl(&line);
1718 }
1719
1720 self.last_diff_strategy = Some(strategy);
1721 DiffDecision { strategy, has_diff }
1722 }
1723
1724 fn full_redraw_interval_due(&self) -> bool {
1725 let frame_due = self.diff_config.full_redraw_interval_frames > 0
1727 && self.frames_since_full_redraw >= self.diff_config.full_redraw_interval_frames;
1728 let time_due = self
1735 .diff_config
1736 .full_redraw_max_interval
1737 .is_some_and(|interval| {
1738 self.last_full_redraw_at
1739 .is_none_or(|at| at.elapsed() >= interval)
1740 });
1741 frame_due || time_due
1742 }
1743
1744 fn record_successful_present(&mut self, strategy: DiffStrategy) {
1745 if strategy == DiffStrategy::FullRedraw {
1746 self.frames_since_full_redraw = 0;
1747 if self.diff_config.full_redraw_max_interval.is_some() {
1749 self.last_full_redraw_at = Some(Instant::now());
1750 }
1751 } else {
1752 self.frames_since_full_redraw = self.frames_since_full_redraw.saturating_add(1);
1753 }
1754 }
1755
1756 fn invalidate_after_present_error(&mut self) {
1757 self.prev_buffer = None;
1758 self.last_inline_region = None;
1759 self.reset_diff_strategy();
1760 }
1761
1762 fn present_inline(
1768 &mut self,
1769 buffer: &Buffer,
1770 ui_height: u16,
1771 cursor: Option<(u16, u16)>,
1772 cursor_visible: bool,
1773 ) -> io::Result<FrameEmitStats> {
1774 let sync_output_enabled = self.capabilities.use_sync_output();
1775 let render_mode = inline_strategy_str(self.inline_strategy);
1776 let _inline_span = info_span!(
1777 "inline.render",
1778 inline_height = ui_height,
1779 scrollback_preserved = tracing::field::Empty,
1780 render_mode,
1781 )
1782 .entered();
1783
1784 let result = (|| -> io::Result<FrameEmitStats> {
1785 let visible_height = ui_height.min(self.term_height);
1786 let ui_y_start = self.ui_start_row();
1787 let current_region = InlineRegion {
1788 start: ui_y_start,
1789 height: visible_height,
1790 };
1791
1792 if sync_output_enabled && !self.in_sync_block {
1794 self.in_sync_block = true;
1797 if let Err(err) = self.writer().write_all(SYNC_BEGIN) {
1798 let _ = self.writer().write_all(SYNC_END);
1801 self.in_sync_block = false;
1802 let _ = self.writer().flush();
1803 return Err(err);
1804 }
1805 }
1806
1807 self.writer().write_all(CURSOR_SAVE)?;
1809 self.cursor_saved = true;
1810
1811 self.set_cursor_visibility(false)?;
1814
1815 {
1817 let _span = debug_span!("ftui.render.scroll_region").entered();
1818 if visible_height > 0 {
1819 match self.inline_strategy {
1820 InlineStrategy::ScrollRegion | InlineStrategy::Hybrid => {
1821 self.activate_scroll_region(visible_height)?;
1822 }
1823 InlineStrategy::OverlayRedraw => {}
1824 }
1825 } else if self.scroll_region_active {
1826 self.deactivate_scroll_region()?;
1827 }
1828 }
1829
1830 self.clear_inline_region_diff(current_region)?;
1831
1832 let mut diff_strategy = DiffStrategy::FullRedraw;
1833 let mut diff_us = 0u64;
1834 let mut emit_stats = EmitStats {
1835 diff_cells: 0,
1836 diff_runs: 0,
1837 };
1838
1839 if visible_height > 0 {
1840 let dims_changed = self.prev_buffer.as_ref().map(|b| (b.width(), b.height()))
1843 != Some((buffer.width(), buffer.height()));
1844
1845 if self.prev_buffer.is_none() || dims_changed {
1846 self.clear_rows(ui_y_start, visible_height)?;
1847 } else {
1848 let buf_height = buffer.height().min(visible_height);
1851 if buf_height < visible_height {
1852 let clear_start = ui_y_start.saturating_add(buf_height);
1853 let clear_height = visible_height.saturating_sub(buf_height);
1854 self.clear_rows(clear_start, clear_height)?;
1855 }
1856 }
1857
1858 let diff_start = if self.timing_enabled {
1860 Some(Instant::now())
1861 } else {
1862 None
1863 };
1864 let decision = {
1865 let _span = debug_span!("ftui.render.diff_compute").entered();
1866 self.decide_diff(buffer)
1867 };
1868 if let Some(start) = diff_start {
1869 diff_us = start.elapsed().as_micros() as u64;
1870 }
1871 diff_strategy = decision.strategy;
1872
1873 {
1875 let _span = debug_span!("ftui.render.emit").entered();
1876
1877 let presenter = self.presenter.as_mut().expect("presenter consumed");
1880 presenter.reset();
1881 presenter.set_viewport_offset_y(ui_y_start);
1882
1883 if decision.has_diff {
1884 presenter.prepare_runs(&self.diff_scratch);
1885 presenter.clip_runs_below(visible_height);
1891 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1893
1894 emit_stats.diff_cells = self.diff_scratch.len();
1895 emit_stats.diff_runs = self.diff_scratch.runs().len();
1896 } else {
1897 let render_height = buffer.height().min(visible_height);
1901 let full = BufferDiff::full(buffer.width(), render_height);
1902 presenter.prepare_runs(&full);
1903 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1904
1905 emit_stats.diff_cells = full.len();
1906 emit_stats.diff_runs = full.runs().len();
1907 }
1908
1909 presenter.finish_frame()?;
1910 }
1911 }
1912
1913 self.writer().write_all(CURSOR_RESTORE)?;
1915 self.cursor_saved = false;
1916
1917 let mut show_cursor = false;
1918 if cursor_visible
1919 && let Some((cx, cy)) = cursor
1920 && cx < buffer.width()
1921 && cy < buffer.height()
1922 && cy < visible_height
1923 {
1924 let abs_y = ui_y_start.saturating_add(cy);
1926 write!(
1927 self.writer(),
1928 "\x1b[{};{}H",
1929 abs_y.saturating_add(1),
1930 cx.saturating_add(1)
1931 )?;
1932 show_cursor = true;
1933 }
1934 self.set_cursor_visibility(show_cursor)?;
1935
1936 if sync_output_enabled && self.in_sync_block {
1938 self.writer().write_all(SYNC_END)?;
1939 self.in_sync_block = false;
1940 } else if !sync_output_enabled {
1941 self.in_sync_block = false;
1944 }
1945
1946 self.writer().flush()?;
1947 self.last_inline_region = if visible_height > 0 {
1948 Some(current_region)
1949 } else {
1950 None
1951 };
1952
1953 if self.timing_enabled {
1954 self.last_present_timings = Some(PresentTimings { diff_us });
1955 }
1956
1957 Ok(FrameEmitStats {
1958 diff_strategy,
1959 diff_cells: emit_stats.diff_cells,
1960 diff_runs: emit_stats.diff_runs,
1961 ui_height: visible_height,
1962 })
1963 })();
1964
1965 if result.is_err() {
1966 _inline_span.record("scrollback_preserved", false);
1967 warn!(
1968 inline_height = ui_height,
1969 render_mode, "scrollback preservation failed during inline render"
1970 );
1971 self.best_effort_inline_cleanup();
1972 } else {
1973 _inline_span.record("scrollback_preserved", true);
1974 }
1975
1976 result
1977 }
1978
1979 fn present_altscreen(
1981 &mut self,
1982 buffer: &Buffer,
1983 cursor: Option<(u16, u16)>,
1984 cursor_visible: bool,
1985 ) -> io::Result<FrameEmitStats> {
1986 let sync_output_enabled = self.capabilities.use_sync_output();
1987 let diff_start = if self.timing_enabled {
1988 Some(Instant::now())
1989 } else {
1990 None
1991 };
1992 let decision = {
1993 let _span = debug_span!("ftui.render.diff_compute").entered();
1994 self.decide_diff(buffer)
1995 };
1996 let diff_us = diff_start
1997 .map(|start| start.elapsed().as_micros() as u64)
1998 .unwrap_or(0);
1999
2000 if sync_output_enabled && !self.in_sync_block {
2003 self.in_sync_block = true;
2006 if let Err(err) = self.writer().write_all(SYNC_BEGIN) {
2007 let _ = self.writer().write_all(SYNC_END);
2010 self.in_sync_block = false;
2011 let _ = self.writer().flush();
2012 return Err(err);
2013 }
2014 }
2015
2016 let operation_result = (|| -> io::Result<FrameEmitStats> {
2017 self.set_cursor_visibility(false)?;
2020
2021 let emit_stats = {
2022 let _span = debug_span!("ftui.render.emit").entered();
2023 let presenter = self.presenter.as_mut().expect("presenter consumed");
2024
2025 presenter.reset();
2028 presenter.set_viewport_offset_y(0);
2030
2031 let stats = if decision.has_diff {
2032 presenter.prepare_runs(&self.diff_scratch);
2033 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
2034
2035 EmitStats {
2036 diff_cells: self.diff_scratch.len(),
2037 diff_runs: self.diff_scratch.runs().len(),
2038 }
2039 } else {
2040 self.diff_scratch.fill_full(buffer.width(), buffer.height());
2042 presenter.prepare_runs(&self.diff_scratch);
2043 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
2044
2045 EmitStats {
2046 diff_cells: (buffer.width() as usize) * (buffer.height() as usize),
2047 diff_runs: buffer.height() as usize,
2048 }
2049 };
2050
2051 presenter.finish_frame()?;
2052 stats
2053 };
2054
2055 let mut show_cursor = false;
2056 if cursor_visible
2057 && let Some((cx, cy)) = cursor
2058 && cx < buffer.width()
2059 && cy < buffer.height()
2060 {
2061 write!(
2063 self.writer(),
2064 "\x1b[{};{}H",
2065 cy.saturating_add(1),
2066 cx.saturating_add(1)
2067 )?;
2068 show_cursor = true;
2069 }
2070 self.set_cursor_visibility(show_cursor)?;
2071
2072 if self.timing_enabled {
2073 self.last_present_timings = Some(PresentTimings { diff_us });
2074 }
2075
2076 Ok(FrameEmitStats {
2077 diff_strategy: decision.strategy,
2078 diff_cells: emit_stats.diff_cells,
2079 diff_runs: emit_stats.diff_runs,
2080 ui_height: 0,
2081 })
2082 })();
2083
2084 if operation_result.is_err()
2085 && let Some(ref mut presenter) = self.presenter
2086 {
2087 presenter.finish_frame_best_effort();
2088 }
2089
2090 let sync_end_result = if sync_output_enabled && self.in_sync_block {
2092 let res = self.writer().write_all(SYNC_END);
2093 if res.is_ok() {
2094 self.in_sync_block = false;
2095 }
2096 Some(res)
2097 } else {
2098 if !sync_output_enabled {
2099 self.in_sync_block = false;
2102 }
2103 None
2104 };
2105 let flush_result = self.writer().flush();
2106
2107 let cleanup_error = sync_end_result
2110 .and_then(Result::err)
2111 .or_else(|| flush_result.err());
2112 if let Some(err) = cleanup_error {
2113 return Err(err);
2114 }
2115 operation_result
2116 }
2117
2118 #[allow(dead_code)] fn create_full_diff(&self, buffer: &Buffer) -> BufferDiff {
2124 BufferDiff::full(buffer.width(), buffer.height())
2125 }
2126
2127 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
2152 let _output_guard = terminal_output_lock();
2154 let sanitized = sanitize(text);
2158 let text = sanitized.as_ref();
2159 match self.screen_mode {
2160 ScreenMode::Inline { ui_height } => self.write_log_inline(ui_height, text),
2161 ScreenMode::InlineAuto { .. } => {
2162 let ui_height = self.effective_ui_height();
2163 self.write_log_inline(ui_height, text)
2164 }
2165 ScreenMode::AltScreen => {
2166 Ok(())
2169 }
2170 }
2171 }
2172
2173 fn write_log_inline(&mut self, ui_height: u16, text: &str) -> io::Result<()> {
2180 let visible_height = ui_height.min(self.term_height);
2181 if visible_height >= self.term_height {
2182 return Ok(());
2184 }
2185
2186 if self.scroll_region_active {
2187 if !self.position_cursor_for_log(visible_height)? {
2191 return Ok(());
2192 }
2193 self.writer().write_all(text.as_bytes())?;
2194 return self.writer().flush();
2195 }
2196
2197 let log_row = match self.ui_anchor {
2201 UiAnchor::Bottom => {
2202 let pending_start = self.ui_start_row();
2206 let anchor = self.last_inline_region.map_or(pending_start, |displayed| {
2207 displayed.start.min(pending_start)
2208 });
2209 if anchor == 0 {
2210 return Ok(());
2212 }
2213 anchor
2214 }
2215 UiAnchor::Top => {
2216 self.term_height
2220 }
2221 };
2222
2223 let line = sanitize_overlay_log_line(text, usize::from(self.term_width));
2224 if line.is_empty() {
2225 return Ok(());
2226 }
2227
2228 self.writer().write_all(CURSOR_SAVE)?;
2231 let write_result = write!(self.writer(), "\x1b[{log_row};1H")
2232 .and_then(|()| self.writer().write_all(ERASE_LINE))
2233 .and_then(|()| self.writer().write_all(line.as_bytes()));
2234 let restore_result = self.writer().write_all(CURSOR_RESTORE);
2235
2236 write_result?;
2239 restore_result?;
2240 self.writer().flush()
2241 }
2242
2243 fn position_cursor_for_log(&mut self, ui_height: u16) -> io::Result<bool> {
2252 let visible_height = ui_height.min(self.term_height);
2253 if visible_height >= self.term_height {
2254 return Ok(false);
2256 }
2257
2258 let log_row = match self.ui_anchor {
2259 UiAnchor::Bottom => {
2260 self.term_height.saturating_sub(visible_height)
2263 }
2264 UiAnchor::Top => {
2265 self.term_height
2268 }
2269 };
2270
2271 write!(self.writer(), "\x1b[{};1H", log_row)?;
2273 Ok(true)
2274 }
2275
2276 pub fn clear_screen(&mut self) -> io::Result<()> {
2278 let _output_guard = terminal_output_lock();
2280 let mut first_error = None;
2281 if self.in_sync_block {
2282 if self.capabilities.use_sync_output()
2283 && let Err(err) = self.writer().write_all(SYNC_END)
2284 {
2285 first_error = Some(err);
2286 }
2287 self.in_sync_block = false;
2288 }
2289 if self.cursor_saved {
2290 if let Err(err) = self.writer().write_all(CURSOR_RESTORE) {
2291 first_error.get_or_insert(err);
2292 }
2293 self.cursor_saved = false;
2294 }
2295 if self.scroll_region_active {
2296 if let Err(err) = self.writer().write_all(b"\x1b[r") {
2297 first_error.get_or_insert(err);
2298 }
2299 self.scroll_region_active = false;
2300 }
2301 if let Err(err) = self.writer().write_all(b"\x1b[2J\x1b[1;1H") {
2302 first_error.get_or_insert(err);
2303 }
2304 if let Err(err) = self.writer().flush() {
2305 first_error.get_or_insert(err);
2306 }
2307 self.prev_buffer = None;
2308 self.last_inline_region = None;
2309 self.reset_diff_strategy();
2310 if let Some(err) = first_error {
2311 Err(err)
2312 } else {
2313 Ok(())
2314 }
2315 }
2316
2317 fn set_cursor_visibility(&mut self, visible: bool) -> io::Result<()> {
2318 if self.cursor_visible == visible {
2319 return Ok(());
2320 }
2321 self.cursor_visible = visible;
2322 if visible {
2323 self.writer().write_all(b"\x1b[?25h")?;
2324 } else {
2325 self.writer().write_all(b"\x1b[?25l")?;
2326 }
2327 Ok(())
2328 }
2329
2330 pub fn hide_cursor(&mut self) -> io::Result<()> {
2332 let _output_guard = terminal_output_lock();
2333 self.set_cursor_visibility(false)?;
2334 self.writer().flush()
2335 }
2336
2337 pub fn show_cursor(&mut self) -> io::Result<()> {
2339 let _output_guard = terminal_output_lock();
2340 self.set_cursor_visibility(true)?;
2341 self.writer().flush()
2342 }
2343
2344 pub fn flush(&mut self) -> io::Result<()> {
2346 let _output_guard = terminal_output_lock();
2347 self.writer().flush()
2348 }
2349
2350 pub fn pool(&self) -> &GraphemePool {
2352 &self.pool
2353 }
2354
2355 pub fn pool_mut(&mut self) -> &mut GraphemePool {
2357 &mut self.pool
2358 }
2359
2360 pub fn links(&self) -> &LinkRegistry {
2362 &self.links
2363 }
2364
2365 pub fn links_mut(&mut self) -> &mut LinkRegistry {
2367 &mut self.links
2368 }
2369
2370 pub fn pool_and_links_mut(&mut self) -> (&mut GraphemePool, &mut LinkRegistry) {
2374 (&mut self.pool, &mut self.links)
2375 }
2376
2377 pub fn capabilities(&self) -> &TerminalCapabilities {
2379 &self.capabilities
2380 }
2381
2382 pub fn into_inner(mut self) -> Option<W> {
2387 self.cleanup();
2388 self.presenter.take()?.into_inner().ok()
2390 }
2391
2392 pub fn gc(&mut self, extra_buffer: Option<&Buffer>) {
2400 let mut buffers = Vec::with_capacity(2);
2401 if let Some(ref buf) = self.prev_buffer {
2402 buffers.push(buf);
2403 }
2404 if let Some(buf) = extra_buffer {
2405 buffers.push(buf);
2406 }
2407 self.pool.gc(&buffers);
2408 }
2409
2410 pub fn estimate_memory_usage(&self) -> usize {
2412 let mut total = 0;
2413 if let Some(b) = &self.prev_buffer {
2415 total += b.width() as usize * b.height() as usize * 16;
2416 }
2417 if let Some(b) = &self.spare_buffer {
2418 total += b.width() as usize * b.height() as usize * 16;
2419 }
2420 if let Some(b) = &self.clone_buf {
2421 total += b.width() as usize * b.height() as usize * 16;
2422 }
2423 total += self.pool.capacity() * 32;
2425 total += self.links.estimate_memory();
2427 total
2428 }
2429
2430 fn best_effort_inline_cleanup(&mut self) {
2434 let Some(ref mut presenter) = self.presenter else {
2435 return;
2436 };
2437 presenter.finish_frame_best_effort();
2438 let writer = presenter.counting_writer_mut();
2439
2440 let _ = writer.write_all(SGR_BG_DEFAULT);
2443
2444 if self.in_sync_block {
2447 if self.capabilities.use_sync_output() {
2448 let _ = writer.write_all(SYNC_END);
2449 }
2450 self.in_sync_block = false;
2451 }
2452
2453 let _ = writer.write_all(CURSOR_RESTORE);
2454 self.cursor_saved = false;
2455
2456 let _ = writer.write_all(b"\x1b[r");
2457 self.scroll_region_active = false;
2458
2459 let _ = writer.write_all(b"\x1b[?25h");
2460 self.cursor_visible = true;
2461 let _ = writer.flush();
2462 }
2463
2464 fn cleanup(&mut self) {
2466 let _output_guard = terminal_output_lock();
2469 let Some(presenter) = &mut self.presenter else {
2470 return; };
2472 presenter.finish_frame_best_effort();
2473 let writer = presenter.counting_writer_mut();
2474
2475 let _ = writer.write_all(SGR_BG_DEFAULT);
2477
2478 if self.in_sync_block {
2480 if self.capabilities.use_sync_output() {
2481 let _ = writer.write_all(SYNC_END);
2482 }
2483 self.in_sync_block = false;
2484 }
2485
2486 if self.scroll_region_active {
2490 let _ = writer.write_all(b"\x1b[r");
2491 self.scroll_region_active = false;
2492 }
2493
2494 if self.cursor_saved {
2496 let _ = writer.write_all(CURSOR_RESTORE);
2497 self.cursor_saved = false;
2498 }
2499
2500 let _ = writer.write_all(b"\x1b[?25h");
2502 self.cursor_visible = true;
2503
2504 let _ = writer.flush();
2506
2507 if let Some(ref mut trace) = self.render_trace {
2508 let _ = trace.finish(None);
2509 }
2510 }
2511}
2512
2513impl<W: Write> Drop for TerminalWriter<W> {
2514 fn drop(&mut self) {
2515 if matches!(
2517 self.screen_mode,
2518 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
2519 ) {
2520 INLINE_ACTIVE_WIDGETS.fetch_sub(1, Ordering::SeqCst);
2521 }
2522 self.cleanup();
2523 }
2524}
2525
2526#[cfg(test)]
2527mod tests {
2528 use super::*;
2529 use ftui_render::cell::{Cell, CellAttrs, CellContent, PackedRgba, StyleFlags};
2530 use std::cell::RefCell;
2531 use std::io;
2532 use std::path::PathBuf;
2533 use std::rc::Rc;
2534 use std::sync::atomic::{AtomicUsize, Ordering};
2535
2536 fn max_cursor_row(output: &[u8]) -> u16 {
2537 let mut max_row = 0u16;
2538 let mut i = 0;
2539 while i + 2 < output.len() {
2540 if output[i] == 0x1b && output[i + 1] == b'[' {
2541 let mut j = i + 2;
2542 let mut row: u16 = 0;
2543 let mut saw_row = false;
2544 while j < output.len() && output[j].is_ascii_digit() {
2545 saw_row = true;
2546 row = row
2547 .saturating_mul(10)
2548 .saturating_add((output[j] - b'0') as u16);
2549 j += 1;
2550 }
2551 if saw_row && j < output.len() && output[j] == b';' {
2552 j += 1;
2553 let mut saw_col = false;
2554 while j < output.len() && output[j].is_ascii_digit() {
2555 saw_col = true;
2556 j += 1;
2557 }
2558 if saw_col && j < output.len() && output[j] == b'H' {
2559 max_row = max_row.max(row);
2560 }
2561 }
2562 }
2563 i += 1;
2564 }
2565 max_row
2566 }
2567
2568 fn basic_caps() -> TerminalCapabilities {
2569 TerminalCapabilities::basic()
2570 }
2571
2572 fn full_caps() -> TerminalCapabilities {
2573 let mut caps = TerminalCapabilities::basic();
2574 caps.color_depth = ColorDepth::TrueColor;
2575 caps.sync_output = true;
2576 caps
2577 }
2578
2579 fn find_nth(haystack: &[u8], needle: &[u8], nth: usize) -> Option<usize> {
2580 if nth == 0 {
2581 return None;
2582 }
2583 let mut count = 0;
2584 let mut i = 0;
2585 while i + needle.len() <= haystack.len() {
2586 if &haystack[i..i + needle.len()] == needle {
2587 count += 1;
2588 if count == nth {
2589 return Some(i);
2590 }
2591 }
2592 i += 1;
2593 }
2594 None
2595 }
2596
2597 fn temp_evidence_path(label: &str) -> PathBuf {
2598 static COUNTER: AtomicUsize = AtomicUsize::new(0);
2599 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2600 let mut path = std::env::temp_dir();
2601 path.push(format!(
2602 "ftui_{}_{}_{}.jsonl",
2603 label,
2604 std::process::id(),
2605 id
2606 ));
2607 path
2608 }
2609
2610 #[derive(Default)]
2611 struct FaultState {
2612 bytes: Vec<u8>,
2613 write_calls: usize,
2614 injected_failure_triggered: bool,
2615 }
2616
2617 struct SingleWriteFaultWriter {
2618 state: Rc<RefCell<FaultState>>,
2619 fail_on_call: usize,
2620 max_chunk_len: usize,
2621 }
2622
2623 impl SingleWriteFaultWriter {
2624 fn new(state: Rc<RefCell<FaultState>>, fail_on_call: usize, max_chunk_len: usize) -> Self {
2625 Self {
2626 state,
2627 fail_on_call,
2628 max_chunk_len: max_chunk_len.max(1),
2629 }
2630 }
2631 }
2632
2633 impl Write for SingleWriteFaultWriter {
2634 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
2635 let mut state = self.state.borrow_mut();
2636 state.write_calls = state.write_calls.saturating_add(1);
2637 if !state.injected_failure_triggered && state.write_calls == self.fail_on_call {
2638 state.injected_failure_triggered = true;
2639 return Err(io::Error::other("injected partial-write fault"));
2640 }
2641
2642 let write_len = buf.len().min(self.max_chunk_len);
2643 state.bytes.extend_from_slice(&buf[..write_len]);
2644 Ok(write_len)
2645 }
2646
2647 fn flush(&mut self) -> io::Result<()> {
2648 Ok(())
2649 }
2650 }
2651
2652 #[test]
2653 fn new_creates_writer() {
2654 let output = Vec::new();
2655 let writer = TerminalWriter::new(
2656 output,
2657 ScreenMode::Inline { ui_height: 10 },
2658 UiAnchor::Bottom,
2659 basic_caps(),
2660 );
2661 assert_eq!(writer.ui_height(), 10);
2662 }
2663
2664 #[test]
2665 fn ui_start_row_bottom_anchor() {
2666 let output = Vec::new();
2667 let mut writer = TerminalWriter::new(
2668 output,
2669 ScreenMode::Inline { ui_height: 10 },
2670 UiAnchor::Bottom,
2671 basic_caps(),
2672 );
2673 writer.set_size(80, 24);
2674 assert_eq!(writer.ui_start_row(), 14); }
2676
2677 #[test]
2678 fn ui_start_row_top_anchor() {
2679 let output = Vec::new();
2680 let mut writer = TerminalWriter::new(
2681 output,
2682 ScreenMode::Inline { ui_height: 10 },
2683 UiAnchor::Top,
2684 basic_caps(),
2685 );
2686 writer.set_size(80, 24);
2687 assert_eq!(writer.ui_start_row(), 0);
2688 }
2689
2690 #[test]
2691 fn ui_start_row_altscreen() {
2692 let output = Vec::new();
2693 let mut writer = TerminalWriter::new(
2694 output,
2695 ScreenMode::AltScreen,
2696 UiAnchor::Bottom,
2697 basic_caps(),
2698 );
2699 writer.set_size(80, 24);
2700 assert_eq!(writer.ui_start_row(), 0);
2701 }
2702
2703 #[test]
2704 fn present_ui_inline_saves_restores_cursor() {
2705 let mut output = Vec::new();
2706 {
2707 let mut writer = TerminalWriter::new(
2708 &mut output,
2709 ScreenMode::Inline { ui_height: 5 },
2710 UiAnchor::Bottom,
2711 basic_caps(),
2712 );
2713 writer.set_size(10, 10);
2714
2715 let buffer = Buffer::new(10, 5);
2716 writer.present_ui(&buffer, None, true).unwrap();
2717 }
2718
2719 assert!(output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE));
2721 assert!(
2722 output
2723 .windows(CURSOR_RESTORE.len())
2724 .any(|w| w == CURSOR_RESTORE)
2725 );
2726 }
2727
2728 #[test]
2729 fn present_ui_with_sync_output() {
2730 let mut output = Vec::new();
2731 {
2732 let mut writer = TerminalWriter::new(
2733 &mut output,
2734 ScreenMode::Inline { ui_height: 5 },
2735 UiAnchor::Bottom,
2736 full_caps(),
2737 );
2738 writer.set_size(10, 10);
2739
2740 let buffer = Buffer::new(10, 5);
2741 writer.present_ui(&buffer, None, true).unwrap();
2742 }
2743
2744 assert!(output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN));
2746 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2747 }
2748
2749 #[test]
2750 fn present_ui_altscreen_closes_stale_sync_block_when_policy_allows_sync() {
2751 let mut output = Vec::new();
2752 {
2753 let mut writer = TerminalWriter::new(
2754 &mut output,
2755 ScreenMode::AltScreen,
2756 UiAnchor::Bottom,
2757 full_caps(),
2758 );
2759 writer.set_size(8, 2);
2760 writer.in_sync_block = true;
2761
2762 let mut buffer = Buffer::new(8, 2);
2763 buffer.set_raw(0, 0, Cell::from_char('X'));
2764 writer.present_ui(&buffer, None, true).unwrap();
2765
2766 assert!(
2767 !writer.in_sync_block,
2768 "present_altscreen must close stale sync blocks"
2769 );
2770 }
2771
2772 assert!(
2773 output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2774 "sync end should be emitted when stale sync state is detected"
2775 );
2776 }
2777
2778 #[test]
2779 fn present_ui_altscreen_stale_sync_block_skips_sync_end_in_mux() {
2780 let mut output = Vec::new();
2781 {
2782 let mut writer = TerminalWriter::new(
2783 &mut output,
2784 ScreenMode::AltScreen,
2785 UiAnchor::Bottom,
2786 mux_caps(),
2787 );
2788 writer.set_size(8, 2);
2789 writer.in_sync_block = true;
2790
2791 let mut buffer = Buffer::new(8, 2);
2792 buffer.set_raw(0, 0, Cell::from_char('X'));
2793 writer.present_ui(&buffer, None, true).unwrap();
2794
2795 assert!(
2796 !writer.in_sync_block,
2797 "present_altscreen must clear stale sync state"
2798 );
2799 }
2800
2801 assert!(
2802 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2803 "sync end must be suppressed when policy disables synchronized output"
2804 );
2805 }
2806
2807 #[test]
2808 fn present_ui_altscreen_sanitizes_grapheme_escape_payloads() {
2809 let mut output = Vec::new();
2810 {
2811 let mut writer = TerminalWriter::new(
2812 &mut output,
2813 ScreenMode::AltScreen,
2814 UiAnchor::Bottom,
2815 basic_caps(),
2816 );
2817 writer.set_size(12, 1);
2818
2819 let gid = writer
2820 .pool_mut()
2821 .intern("ok\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}", 6);
2822 let mut buffer = Buffer::new(12, 1);
2823 buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
2824
2825 writer.present_ui(&buffer, None, true).unwrap();
2826 }
2827
2828 let output_str = String::from_utf8_lossy(&output);
2829 assert!(
2830 output_str.contains("oktail"),
2831 "sanitized grapheme content should preserve visible payload"
2832 );
2833 assert!(
2834 !output_str.contains("52;c;SGVsbG8"),
2835 "OSC payload must not be forwarded by alt-screen emitter"
2836 );
2837 assert!(
2838 !output_str.contains('\u{009d}'),
2839 "C1 controls must be stripped from alt-screen grapheme output"
2840 );
2841 }
2842
2843 #[test]
2844 fn present_ui_inline_skips_sync_output_in_mux() {
2845 let mut output = Vec::new();
2846 {
2847 let mut writer = TerminalWriter::new(
2848 &mut output,
2849 ScreenMode::Inline { ui_height: 5 },
2850 UiAnchor::Bottom,
2851 mux_caps(),
2852 );
2853 writer.set_size(10, 10);
2854
2855 let buffer = Buffer::new(10, 5);
2856 writer.present_ui(&buffer, None, true).unwrap();
2857 }
2858
2859 assert!(
2860 !output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN),
2861 "sync begin must be suppressed in tmux/screen/zellij environments"
2862 );
2863 assert!(
2864 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2865 "sync end must be suppressed in tmux/screen/zellij environments"
2866 );
2867 }
2868
2869 #[test]
2870 fn present_ui_altscreen_skips_sync_output_in_mux() {
2871 let mut output = Vec::new();
2872 {
2873 let mut writer = TerminalWriter::new(
2874 &mut output,
2875 ScreenMode::AltScreen,
2876 UiAnchor::Bottom,
2877 mux_caps(),
2878 );
2879 writer.set_size(10, 10);
2880
2881 let buffer = Buffer::new(10, 5);
2882 writer.present_ui(&buffer, None, true).unwrap();
2883 }
2884
2885 assert!(
2886 !output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN),
2887 "sync begin must be suppressed in tmux/screen/zellij environments"
2888 );
2889 assert!(
2890 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2891 "sync end must be suppressed in tmux/screen/zellij environments"
2892 );
2893 }
2894
2895 #[test]
2896 fn present_ui_inline_skips_hyperlinks_in_mux() {
2897 let mut output = Vec::new();
2898 {
2899 let mut caps = mux_caps();
2900 caps.osc8_hyperlinks = true;
2901
2902 let mut writer = TerminalWriter::new(
2903 &mut output,
2904 ScreenMode::Inline { ui_height: 2 },
2905 UiAnchor::Bottom,
2906 caps,
2907 );
2908 writer.set_size(8, 4);
2909
2910 let link_id = writer.links_mut().register("https://example.com");
2911 let mut buffer = Buffer::new(8, 2);
2912 buffer.set_raw(
2913 0,
2914 0,
2915 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2916 );
2917 writer.present_ui(&buffer, None, true).unwrap();
2918 }
2919
2920 assert!(
2921 !output.windows(b"\x1b]8;".len()).any(|w| w == b"\x1b]8;"),
2922 "OSC 8 sequences must be suppressed by mux hyperlink policy"
2923 );
2924 }
2925
2926 #[test]
2927 fn present_ui_inline_closes_hyperlinks_at_frame_end() {
2928 let mut output = Vec::new();
2929 {
2930 let mut caps = full_caps();
2931 caps.osc8_hyperlinks = true;
2932
2933 let mut writer = TerminalWriter::new(
2934 &mut output,
2935 ScreenMode::Inline { ui_height: 2 },
2936 UiAnchor::Bottom,
2937 caps,
2938 );
2939 writer.set_size(8, 4);
2940
2941 let link_id = writer.links_mut().register("https://example.com");
2942 let mut buffer = Buffer::new(8, 2);
2943 buffer.set_raw(
2944 0,
2945 0,
2946 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2947 );
2948 writer.present_ui(&buffer, None, true).unwrap();
2949 }
2950
2951 let open = b"\x1b]8;;https://example.com\x07";
2952 let close = b"\x1b]8;;\x07";
2953 let open_pos = output
2954 .windows(open.len())
2955 .position(|window| window == open)
2956 .expect("expected OSC 8 open sequence");
2957 let close_pos = output
2958 .windows(close.len())
2959 .position(|window| window == close)
2960 .expect("expected OSC 8 close sequence");
2961 assert!(
2962 open_pos < close_pos,
2963 "hyperlink must close before frame end"
2964 );
2965 }
2966
2967 #[test]
2968 fn present_ui_altscreen_skips_hyperlinks_in_mux() {
2969 let mut output = Vec::new();
2970 {
2971 let mut caps = mux_caps();
2972 caps.osc8_hyperlinks = true;
2973
2974 let mut writer =
2975 TerminalWriter::new(&mut output, ScreenMode::AltScreen, UiAnchor::Bottom, caps);
2976 writer.set_size(8, 4);
2977
2978 let link_id = writer.links_mut().register("https://example.com");
2979 let mut buffer = Buffer::new(8, 2);
2980 buffer.set_raw(
2981 0,
2982 0,
2983 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2984 );
2985 writer.present_ui(&buffer, None, true).unwrap();
2986 }
2987
2988 assert!(
2989 !output.windows(b"\x1b]8;".len()).any(|w| w == b"\x1b]8;"),
2990 "OSC 8 sequences must be suppressed by mux hyperlink policy"
2991 );
2992 }
2993
2994 #[test]
2995 fn present_ui_altscreen_closes_hyperlinks_at_frame_end() {
2996 let mut output = Vec::new();
2997 {
2998 let mut caps = full_caps();
2999 caps.osc8_hyperlinks = true;
3000
3001 let mut writer =
3002 TerminalWriter::new(&mut output, ScreenMode::AltScreen, UiAnchor::Bottom, caps);
3003 writer.set_size(8, 4);
3004
3005 let link_id = writer.links_mut().register("https://example.com");
3006 let mut buffer = Buffer::new(8, 2);
3007 buffer.set_raw(
3008 0,
3009 0,
3010 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
3011 );
3012 writer.present_ui(&buffer, None, true).unwrap();
3013 }
3014
3015 let open = b"\x1b]8;;https://example.com\x07";
3016 let close = b"\x1b]8;;\x07";
3017 let open_pos = output
3018 .windows(open.len())
3019 .position(|window| window == open)
3020 .expect("expected OSC 8 open sequence");
3021 let close_pos = output
3022 .windows(close.len())
3023 .position(|window| window == close)
3024 .expect("expected OSC 8 close sequence");
3025 assert!(
3026 open_pos < close_pos,
3027 "hyperlink must close before frame end"
3028 );
3029 }
3030
3031 #[test]
3032 fn present_ui_hides_cursor_when_requested() {
3033 let mut output = Vec::new();
3034 {
3035 let mut writer = TerminalWriter::new(
3036 &mut output,
3037 ScreenMode::AltScreen,
3038 UiAnchor::Bottom,
3039 basic_caps(),
3040 );
3041 writer.set_size(10, 5);
3042
3043 let buffer = Buffer::new(10, 5);
3044 writer.present_ui(&buffer, None, false).unwrap();
3045 }
3046
3047 assert!(
3048 output.windows(6).any(|w| w == b"\x1b[?25l"),
3049 "expected cursor hide sequence"
3050 );
3051 }
3052
3053 #[test]
3054 fn present_ui_visible_with_position_temporarily_hides_cursor() {
3055 let mut output = Vec::new();
3056 {
3057 let mut writer = TerminalWriter::new(
3058 &mut output,
3059 ScreenMode::AltScreen,
3060 UiAnchor::Bottom,
3061 basic_caps(),
3062 );
3063 writer.set_size(10, 5);
3064
3065 let buffer = Buffer::new(10, 5);
3066 writer.present_ui(&buffer, Some((0, 0)), true).unwrap();
3067 }
3068
3069 assert!(
3070 output.windows(6).any(|w| w == b"\x1b[?25l"),
3071 "expected cursor hide during frame emission"
3072 );
3073 }
3074
3075 #[test]
3076 fn present_ui_visible_without_position_hides_cursor() {
3077 let mut output = Vec::new();
3078 {
3079 let mut writer = TerminalWriter::new(
3080 &mut output,
3081 ScreenMode::AltScreen,
3082 UiAnchor::Bottom,
3083 basic_caps(),
3084 );
3085 writer.set_size(10, 5);
3086
3087 let buffer = Buffer::new(10, 5);
3088 writer.present_ui(&buffer, None, true).unwrap();
3089 }
3090
3091 assert!(
3092 output.windows(6).any(|w| w == b"\x1b[?25l"),
3093 "expected cursor hide sequence when no explicit cursor position exists"
3094 );
3095 }
3096
3097 #[test]
3098 fn write_log_in_inline_mode() {
3099 let mut output = Vec::new();
3100 {
3101 let mut writer = TerminalWriter::new(
3102 &mut output,
3103 ScreenMode::Inline { ui_height: 5 },
3104 UiAnchor::Bottom,
3105 basic_caps(),
3106 );
3107 writer.write_log("test log\n").unwrap();
3108 }
3109
3110 let output_str = String::from_utf8_lossy(&output);
3111 assert!(output_str.contains("test log"));
3112 }
3113
3114 #[test]
3115 fn write_log_in_altscreen_is_noop() {
3116 let mut output = Vec::new();
3117 {
3118 let mut writer = TerminalWriter::new(
3119 &mut output,
3120 ScreenMode::AltScreen,
3121 UiAnchor::Bottom,
3122 basic_caps(),
3123 );
3124 writer.write_log("test log\n").unwrap();
3125 }
3126
3127 let output_str = String::from_utf8_lossy(&output);
3128 assert!(!output_str.contains("test log"));
3130 }
3131
3132 #[test]
3133 fn clear_screen_resets_prev_buffer() {
3134 let mut output = Vec::new();
3135 let mut writer = TerminalWriter::new(
3136 &mut output,
3137 ScreenMode::AltScreen,
3138 UiAnchor::Bottom,
3139 basic_caps(),
3140 );
3141
3142 let buffer = Buffer::new(10, 5);
3144 writer.present_ui(&buffer, None, true).unwrap();
3145 assert!(writer.prev_buffer.is_some());
3146
3147 writer.clear_screen().unwrap();
3149 assert!(writer.prev_buffer.is_none());
3150 }
3151
3152 #[test]
3153 fn set_size_clears_prev_buffer() {
3154 let output = Vec::new();
3155 let mut writer = TerminalWriter::new(
3156 output,
3157 ScreenMode::AltScreen,
3158 UiAnchor::Bottom,
3159 basic_caps(),
3160 );
3161
3162 writer.prev_buffer = Some(Buffer::new(10, 10));
3163 writer.set_size(20, 20);
3164
3165 assert!(writer.prev_buffer.is_none());
3166 }
3167
3168 #[test]
3169 fn inline_auto_resize_clears_cached_height() {
3170 let output = Vec::new();
3171 let mut writer = TerminalWriter::new(
3172 output,
3173 ScreenMode::InlineAuto {
3174 min_height: 3,
3175 max_height: 8,
3176 },
3177 UiAnchor::Bottom,
3178 basic_caps(),
3179 );
3180
3181 writer.set_size(80, 24);
3182 writer.set_auto_ui_height(6);
3183 assert_eq!(writer.auto_ui_height(), Some(6));
3184 assert_eq!(writer.render_height_hint(), 6);
3185
3186 writer.set_size(100, 30);
3187 assert_eq!(writer.auto_ui_height(), None);
3188 assert_eq!(writer.render_height_hint(), 8);
3189 }
3190
3191 #[test]
3192 fn drop_cleanup_restores_cursor() {
3193 let mut output = Vec::new();
3194 {
3195 let mut writer = TerminalWriter::new(
3196 &mut output,
3197 ScreenMode::Inline { ui_height: 5 },
3198 UiAnchor::Bottom,
3199 basic_caps(),
3200 );
3201 writer.cursor_saved = true;
3202 }
3204
3205 assert!(
3207 output
3208 .windows(CURSOR_RESTORE.len())
3209 .any(|w| w == CURSOR_RESTORE)
3210 );
3211 }
3212
3213 #[test]
3214 fn drop_cleanup_ends_sync_block() {
3215 let mut output = Vec::new();
3216 {
3217 let mut writer = TerminalWriter::new(
3218 &mut output,
3219 ScreenMode::Inline { ui_height: 5 },
3220 UiAnchor::Bottom,
3221 full_caps(),
3222 );
3223 writer.in_sync_block = true;
3224 }
3226
3227 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
3229 }
3230
3231 #[test]
3232 fn drop_cleanup_skips_sync_end_in_mux_even_with_stale_state() {
3233 let mut output = Vec::new();
3234 {
3235 let mut writer = TerminalWriter::new(
3236 &mut output,
3237 ScreenMode::Inline { ui_height: 5 },
3238 UiAnchor::Bottom,
3239 mux_caps(),
3240 );
3241 writer.in_sync_block = true;
3242 }
3244
3245 assert!(
3246 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
3247 "drop cleanup must not emit sync_end in mux environments"
3248 );
3249 }
3250
3251 #[test]
3252 fn present_multiple_frames_uses_diff() {
3253 use std::io::Cursor;
3254
3255 let output = Cursor::new(Vec::new());
3257 let mut writer = TerminalWriter::new(
3258 output,
3259 ScreenMode::AltScreen,
3260 UiAnchor::Bottom,
3261 basic_caps(),
3262 );
3263 writer.set_size(10, 5);
3264
3265 let mut buffer1 = Buffer::new(10, 5);
3267 buffer1.set_raw(0, 0, Cell::from_char('A'));
3268 writer.present_ui(&buffer1, None, true).unwrap();
3269
3270 writer.present_ui(&buffer1, None, true).unwrap();
3272
3273 let mut buffer2 = buffer1.clone();
3275 buffer2.set_raw(1, 0, Cell::from_char('B'));
3276 writer.present_ui(&buffer2, None, true).unwrap();
3277
3278 }
3281
3282 #[test]
3283 fn cell_content_rendered_correctly() {
3284 let mut output = Vec::new();
3285 {
3286 let mut writer = TerminalWriter::new(
3287 &mut output,
3288 ScreenMode::AltScreen,
3289 UiAnchor::Bottom,
3290 basic_caps(),
3291 );
3292 writer.set_size(10, 5);
3293
3294 let mut buffer = Buffer::new(10, 5);
3295 buffer.set_raw(0, 0, Cell::from_char('H'));
3296 buffer.set_raw(1, 0, Cell::from_char('i'));
3297 buffer.set_raw(2, 0, Cell::from_char('!'));
3298 writer.present_ui(&buffer, None, true).unwrap();
3299 }
3300
3301 let output_str = String::from_utf8_lossy(&output);
3302 assert!(output_str.contains('H'));
3303 assert!(output_str.contains('i'));
3304 assert!(output_str.contains('!'));
3305 }
3306
3307 #[test]
3308 fn resize_reanchors_ui_region() {
3309 let output = Vec::new();
3310 let mut writer = TerminalWriter::new(
3311 output,
3312 ScreenMode::Inline { ui_height: 10 },
3313 UiAnchor::Bottom,
3314 basic_caps(),
3315 );
3316
3317 writer.set_size(80, 24);
3319 assert_eq!(writer.ui_start_row(), 14);
3320
3321 writer.set_size(80, 40);
3323 assert_eq!(writer.ui_start_row(), 30);
3324
3325 writer.set_size(80, 15);
3327 assert_eq!(writer.ui_start_row(), 5);
3328 }
3329
3330 #[test]
3331 fn inline_auto_height_clamps_and_uses_max_for_render() {
3332 let output = Vec::new();
3333 let mut writer = TerminalWriter::new(
3334 output,
3335 ScreenMode::InlineAuto {
3336 min_height: 3,
3337 max_height: 8,
3338 },
3339 UiAnchor::Bottom,
3340 basic_caps(),
3341 );
3342 writer.set_size(80, 24);
3343
3344 assert_eq!(writer.ui_height(), 3);
3346 assert_eq!(writer.auto_ui_height(), None);
3347
3348 assert_eq!(writer.render_height_hint(), 8);
3350
3351 writer.set_auto_ui_height(6);
3353 assert_eq!(writer.render_height_hint(), 6);
3354
3355 writer.clear_auto_ui_height();
3357 assert_eq!(writer.render_height_hint(), 8);
3358
3359 writer.set_auto_ui_height(3);
3361 assert_eq!(writer.auto_ui_height(), Some(3));
3362 assert_eq!(writer.ui_height(), 3);
3363
3364 writer.clear_auto_ui_height();
3365 assert_eq!(writer.render_height_hint(), 8);
3366
3367 writer.set_auto_ui_height(10);
3369 assert_eq!(writer.ui_height(), 8);
3370
3371 writer.set_auto_ui_height(1);
3373 assert_eq!(writer.ui_height(), 3);
3374 }
3375
3376 #[test]
3377 fn resize_with_top_anchor_stays_at_zero() {
3378 let output = Vec::new();
3379 let mut writer = TerminalWriter::new(
3380 output,
3381 ScreenMode::Inline { ui_height: 10 },
3382 UiAnchor::Top,
3383 basic_caps(),
3384 );
3385
3386 writer.set_size(80, 24);
3387 assert_eq!(writer.ui_start_row(), 0);
3388
3389 writer.set_size(80, 40);
3390 assert_eq!(writer.ui_start_row(), 0);
3391 }
3392
3393 #[test]
3394 fn inline_mode_never_clears_full_screen() {
3395 let mut output = Vec::new();
3396 {
3397 let mut writer = TerminalWriter::new(
3398 &mut output,
3399 ScreenMode::Inline { ui_height: 5 },
3400 UiAnchor::Bottom,
3401 basic_caps(),
3402 );
3403 writer.set_size(10, 10);
3404
3405 let buffer = Buffer::new(10, 5);
3406 writer.present_ui(&buffer, None, true).unwrap();
3407 }
3408
3409 let has_ed2 = output.windows(4).any(|w| w == b"\x1b[2J");
3411 assert!(!has_ed2, "Inline mode should never use full screen clear");
3412
3413 assert!(output.windows(ERASE_LINE.len()).any(|w| w == ERASE_LINE));
3415 }
3416
3417 #[test]
3418 fn present_after_log_maintains_cursor_position() {
3419 let mut output = Vec::new();
3420 {
3421 let mut writer = TerminalWriter::new(
3422 &mut output,
3423 ScreenMode::Inline { ui_height: 5 },
3424 UiAnchor::Bottom,
3425 basic_caps(),
3426 );
3427 writer.set_size(10, 10);
3428
3429 let buffer = Buffer::new(10, 5);
3431 writer.present_ui(&buffer, None, true).unwrap();
3432
3433 writer.write_log("log line\n").unwrap();
3435
3436 writer.present_ui(&buffer, None, true).unwrap();
3438 }
3439
3440 let save_count = output
3444 .windows(CURSOR_SAVE.len())
3445 .filter(|w| *w == CURSOR_SAVE)
3446 .count();
3447 assert!(
3448 save_count >= 2,
3449 "Should have saved cursor at least twice (once per present)"
3450 );
3451
3452 let restore_count = output
3454 .windows(CURSOR_RESTORE.len())
3455 .filter(|w| *w == CURSOR_RESTORE)
3456 .count();
3457 assert!(
3459 restore_count >= 2,
3460 "Should have restored cursor at least twice"
3461 );
3462 }
3463
3464 #[test]
3465 fn ui_height_bounds_check() {
3466 let output = Vec::new();
3467 let mut writer = TerminalWriter::new(
3468 output,
3469 ScreenMode::Inline { ui_height: 100 },
3470 UiAnchor::Bottom,
3471 basic_caps(),
3472 );
3473
3474 writer.set_size(80, 10);
3476
3477 assert_eq!(writer.ui_start_row(), 0);
3479 }
3480
3481 #[test]
3482 fn inline_ui_height_clamped_to_terminal_height() {
3483 let mut output = Vec::new();
3484 {
3485 let mut writer = TerminalWriter::new(
3486 &mut output,
3487 ScreenMode::Inline { ui_height: 10 },
3488 UiAnchor::Bottom,
3489 basic_caps(),
3490 );
3491 writer.set_size(8, 3);
3492 let buffer = Buffer::new(8, 10);
3493 writer.present_ui(&buffer, None, true).unwrap();
3494 }
3495
3496 let max_row = max_cursor_row(&output);
3497 assert!(
3498 max_row <= 3,
3499 "cursor row {} exceeds terminal height",
3500 max_row
3501 );
3502 }
3503
3504 #[test]
3505 fn inline_diff_frame_never_emits_below_the_fold() {
3506 let mut output = Vec::new();
3512 {
3513 let mut writer = TerminalWriter::new(
3514 &mut output,
3515 ScreenMode::Inline { ui_height: 20 },
3516 UiAnchor::Bottom,
3517 basic_caps(),
3518 );
3519 writer.set_size(80, 10); let buffer = Buffer::new(80, 20);
3523 writer.present_ui(&buffer, None, true).unwrap();
3524
3525 let mut buffer2 = Buffer::new(80, 20);
3527 buffer2.set(0, 19, ftui_render::cell::Cell::from_char('B'));
3528 writer.present_ui(&buffer2, None, true).unwrap();
3529 }
3530
3531 let max_row = max_cursor_row(&output);
3532 assert!(
3533 max_row <= 10,
3534 "diff frame emitted cursor row {max_row} past the 10-row terminal"
3535 );
3536 assert!(
3538 !output.windows(1).any(|w| w == b"B"),
3539 "below-the-fold cell was emitted"
3540 );
3541 }
3542
3543 #[test]
3544 fn inline_shrink_clears_stale_rows() {
3545 let mut output = Vec::new();
3546 {
3547 let mut writer = TerminalWriter::new(
3548 &mut output,
3549 ScreenMode::InlineAuto {
3550 min_height: 1,
3551 max_height: 6,
3552 },
3553 UiAnchor::Bottom,
3554 basic_caps(),
3555 );
3556 writer.set_size(10, 10);
3557
3558 let buffer = Buffer::new(10, 6);
3559 writer.set_auto_ui_height(6);
3560 writer.present_ui(&buffer, None, true).unwrap();
3561
3562 writer.set_auto_ui_height(3);
3563 writer.present_ui(&buffer, None, true).unwrap();
3564 }
3565
3566 let second_save = find_nth(&output, CURSOR_SAVE, 2).expect("expected second cursor save");
3567 let after_save = &output[second_save..];
3568 let restore_idx = after_save
3569 .windows(CURSOR_RESTORE.len())
3570 .position(|w| w == CURSOR_RESTORE)
3571 .expect("expected cursor restore after second save");
3572 let segment = &after_save[..restore_idx];
3573 let erase_count = segment
3574 .windows(ERASE_LINE.len())
3575 .filter(|w| *w == ERASE_LINE)
3576 .count();
3577 let bg_reset_count = segment
3578 .windows(SGR_BG_DEFAULT.len())
3579 .filter(|w| *w == SGR_BG_DEFAULT)
3580 .count();
3581
3582 assert_eq!(erase_count, 6, "expected clears for stale + new rows");
3583 assert!(
3584 bg_reset_count >= 2,
3585 "expected background resets before row clears"
3586 );
3587 }
3588
3589 fn scroll_region_caps() -> TerminalCapabilities {
3593 let mut caps = TerminalCapabilities::basic();
3594 caps.scroll_region = true;
3595 caps.sync_output = true;
3596 caps
3597 }
3598
3599 fn hybrid_caps() -> TerminalCapabilities {
3601 let mut caps = TerminalCapabilities::basic();
3602 caps.scroll_region = true;
3603 caps
3604 }
3605
3606 fn mux_caps() -> TerminalCapabilities {
3608 let mut caps = TerminalCapabilities::basic();
3609 caps.scroll_region = true;
3610 caps.sync_output = true;
3611 caps.in_tmux = true;
3612 caps
3613 }
3614
3615 #[test]
3616 fn scroll_region_bounds_bottom_anchor() {
3617 let mut output = Vec::new();
3618 {
3619 let mut writer = TerminalWriter::new(
3620 &mut output,
3621 ScreenMode::Inline { ui_height: 5 },
3622 UiAnchor::Bottom,
3623 scroll_region_caps(),
3624 );
3625 writer.set_size(10, 10);
3626 let buffer = Buffer::new(10, 5);
3627 writer.present_ui(&buffer, None, true).unwrap();
3628 }
3629
3630 let seq = b"\x1b[1;5r";
3631 assert!(
3632 output.windows(seq.len()).any(|w| w == seq),
3633 "expected scroll region for bottom anchor"
3634 );
3635 }
3636
3637 #[test]
3638 fn scroll_region_bounds_top_anchor() {
3639 let mut output = Vec::new();
3640 {
3641 let mut writer = TerminalWriter::new(
3642 &mut output,
3643 ScreenMode::Inline { ui_height: 5 },
3644 UiAnchor::Top,
3645 scroll_region_caps(),
3646 );
3647 writer.set_size(10, 10);
3648 let buffer = Buffer::new(10, 5);
3649 writer.present_ui(&buffer, None, true).unwrap();
3650 }
3651
3652 let seq = b"\x1b[6;10r";
3653 assert!(
3654 output.windows(seq.len()).any(|w| w == seq),
3655 "expected scroll region for top anchor"
3656 );
3657 let cursor_seq = b"\x1b[6;1H";
3658 assert!(
3659 output.windows(cursor_seq.len()).any(|w| w == cursor_seq),
3660 "expected cursor move into log region for top anchor"
3661 );
3662 }
3663
3664 #[test]
3665 fn present_ui_inline_resets_style_before_cursor_restore() {
3666 let mut output = Vec::new();
3667 {
3668 let mut writer = TerminalWriter::new(
3669 &mut output,
3670 ScreenMode::Inline { ui_height: 2 },
3671 UiAnchor::Bottom,
3672 basic_caps(),
3673 );
3674 writer.set_size(5, 5);
3675 let mut buffer = Buffer::new(5, 2);
3676 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(PackedRgba::RED));
3677 writer.present_ui(&buffer, None, true).unwrap();
3678 }
3679
3680 let seq = b"\x1b[0m\x1b8";
3681 assert!(
3682 output.windows(seq.len()).any(|w| w == seq),
3683 "expected SGR reset before cursor restore in inline mode"
3684 );
3685 }
3686
3687 #[test]
3688 fn strategy_selected_from_capabilities() {
3689 let w = TerminalWriter::new(
3691 Vec::new(),
3692 ScreenMode::Inline { ui_height: 5 },
3693 UiAnchor::Bottom,
3694 basic_caps(),
3695 );
3696 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3697
3698 let w = TerminalWriter::new(
3700 Vec::new(),
3701 ScreenMode::Inline { ui_height: 5 },
3702 UiAnchor::Bottom,
3703 scroll_region_caps(),
3704 );
3705 assert_eq!(w.inline_strategy(), InlineStrategy::ScrollRegion);
3706
3707 let w = TerminalWriter::new(
3709 Vec::new(),
3710 ScreenMode::Inline { ui_height: 5 },
3711 UiAnchor::Bottom,
3712 hybrid_caps(),
3713 );
3714 assert_eq!(w.inline_strategy(), InlineStrategy::Hybrid);
3715
3716 let w = TerminalWriter::new(
3718 Vec::new(),
3719 ScreenMode::Inline { ui_height: 5 },
3720 UiAnchor::Bottom,
3721 mux_caps(),
3722 );
3723 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3724 }
3725
3726 #[test]
3727 fn scroll_region_activated_on_present() {
3728 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 scroll_region_caps(),
3735 );
3736 writer.set_size(80, 24);
3737 assert!(!writer.scroll_region_active());
3738
3739 let buffer = Buffer::new(80, 5);
3740 writer.present_ui(&buffer, None, true).unwrap();
3741 assert!(writer.scroll_region_active());
3742 }
3743
3744 let expected = b"\x1b[1;19r";
3746 assert!(
3747 output.windows(expected.len()).any(|w| w == expected),
3748 "Should set scroll region to rows 1-19"
3749 );
3750 }
3751
3752 #[test]
3753 fn scroll_region_not_activated_for_overlay() {
3754 let mut output = Vec::new();
3755 {
3756 let mut writer = TerminalWriter::new(
3757 &mut output,
3758 ScreenMode::Inline { ui_height: 5 },
3759 UiAnchor::Bottom,
3760 basic_caps(),
3761 );
3762 writer.set_size(80, 24);
3763
3764 let buffer = Buffer::new(80, 5);
3765 writer.present_ui(&buffer, None, true).unwrap();
3766 assert!(!writer.scroll_region_active());
3767 }
3768
3769 let decstbm = b"\x1b[1;19r";
3771 assert!(
3772 !output.windows(decstbm.len()).any(|w| w == decstbm),
3773 "OverlayRedraw should not set scroll region"
3774 );
3775 }
3776
3777 #[test]
3778 fn scroll_region_not_activated_in_mux() {
3779 let mut output = Vec::new();
3780 {
3781 let mut writer = TerminalWriter::new(
3782 &mut output,
3783 ScreenMode::Inline { ui_height: 5 },
3784 UiAnchor::Bottom,
3785 mux_caps(),
3786 );
3787 writer.set_size(80, 24);
3788
3789 let buffer = Buffer::new(80, 5);
3790 writer.present_ui(&buffer, None, true).unwrap();
3791 assert!(!writer.scroll_region_active());
3792 }
3793
3794 let decstbm = b"\x1b[1;19r";
3796 assert!(
3797 !output.windows(decstbm.len()).any(|w| w == decstbm),
3798 "Mux environment should not use scroll region"
3799 );
3800 }
3801
3802 #[test]
3803 fn scroll_region_reset_on_cleanup() {
3804 let mut output = Vec::new();
3805 {
3806 let mut writer = TerminalWriter::new(
3807 &mut output,
3808 ScreenMode::Inline { ui_height: 5 },
3809 UiAnchor::Bottom,
3810 scroll_region_caps(),
3811 );
3812 writer.set_size(80, 24);
3813
3814 let buffer = Buffer::new(80, 5);
3815 writer.present_ui(&buffer, None, true).unwrap();
3816 }
3818
3819 let reset = b"\x1b[r";
3821 assert!(
3822 output.windows(reset.len()).any(|w| w == reset),
3823 "Cleanup should reset scroll region"
3824 );
3825 }
3826
3827 #[test]
3828 fn scroll_region_reset_on_resize() {
3829 let output = Vec::new();
3830 let mut writer = TerminalWriter::new(
3831 output,
3832 ScreenMode::Inline { ui_height: 5 },
3833 UiAnchor::Bottom,
3834 scroll_region_caps(),
3835 );
3836 writer.set_size(80, 24);
3837
3838 writer.activate_scroll_region(5).unwrap();
3840 assert!(writer.scroll_region_active());
3841
3842 writer.set_size(80, 40);
3844 assert!(!writer.scroll_region_active());
3845 }
3846
3847 #[test]
3848 fn scroll_region_reactivated_after_resize() {
3849 let mut output = Vec::new();
3850 {
3851 let mut writer = TerminalWriter::new(
3852 &mut output,
3853 ScreenMode::Inline { ui_height: 5 },
3854 UiAnchor::Bottom,
3855 scroll_region_caps(),
3856 );
3857 writer.set_size(80, 24);
3858
3859 let buffer = Buffer::new(80, 5);
3861 writer.present_ui(&buffer, None, true).unwrap();
3862 assert!(writer.scroll_region_active());
3863
3864 writer.set_size(80, 40);
3866 assert!(!writer.scroll_region_active());
3867
3868 let buffer2 = Buffer::new(80, 5);
3870 writer.present_ui(&buffer2, None, true).unwrap();
3871 assert!(writer.scroll_region_active());
3872 }
3873
3874 let new_region = b"\x1b[1;35r";
3876 assert!(
3877 output.windows(new_region.len()).any(|w| w == new_region),
3878 "Should set scroll region to new dimensions after resize"
3879 );
3880 }
3881
3882 #[test]
3883 fn hybrid_strategy_activates_scroll_region() {
3884 let mut output = Vec::new();
3885 {
3886 let mut writer = TerminalWriter::new(
3887 &mut output,
3888 ScreenMode::Inline { ui_height: 5 },
3889 UiAnchor::Bottom,
3890 hybrid_caps(),
3891 );
3892 writer.set_size(80, 24);
3893
3894 let buffer = Buffer::new(80, 5);
3895 writer.present_ui(&buffer, None, true).unwrap();
3896 assert!(writer.scroll_region_active());
3897 }
3898
3899 let expected = b"\x1b[1;19r";
3901 assert!(
3902 output.windows(expected.len()).any(|w| w == expected),
3903 "Hybrid should activate scroll region as optimization"
3904 );
3905 }
3906
3907 #[test]
3908 fn altscreen_does_not_activate_scroll_region() {
3909 let output = Vec::new();
3910 let mut writer = TerminalWriter::new(
3911 output,
3912 ScreenMode::AltScreen,
3913 UiAnchor::Bottom,
3914 scroll_region_caps(),
3915 );
3916 writer.set_size(80, 24);
3917
3918 let buffer = Buffer::new(80, 24);
3919 writer.present_ui(&buffer, None, true).unwrap();
3920 assert!(!writer.scroll_region_active());
3921 }
3922
3923 #[test]
3924 fn scroll_region_still_saves_restores_cursor() {
3925 let mut output = Vec::new();
3926 {
3927 let mut writer = TerminalWriter::new(
3928 &mut output,
3929 ScreenMode::Inline { ui_height: 5 },
3930 UiAnchor::Bottom,
3931 scroll_region_caps(),
3932 );
3933 writer.set_size(80, 24);
3934
3935 let buffer = Buffer::new(80, 5);
3936 writer.present_ui(&buffer, None, true).unwrap();
3937 }
3938
3939 assert!(
3941 output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE),
3942 "Scroll region mode should still save cursor"
3943 );
3944 assert!(
3945 output
3946 .windows(CURSOR_RESTORE.len())
3947 .any(|w| w == CURSOR_RESTORE),
3948 "Scroll region mode should still restore cursor"
3949 );
3950 }
3951
3952 #[test]
3955 fn write_log_positions_cursor_bottom_anchor() {
3956 let mut output = Vec::new();
3959 {
3960 let mut writer = TerminalWriter::new(
3961 &mut output,
3962 ScreenMode::Inline { ui_height: 5 },
3963 UiAnchor::Bottom,
3964 basic_caps(),
3965 );
3966 writer.set_size(80, 24);
3967 writer.write_log("test log\n").unwrap();
3968 }
3969
3970 let expected_pos = b"\x1b[19;1H";
3974 assert!(
3975 output
3976 .windows(expected_pos.len())
3977 .any(|w| w == expected_pos),
3978 "Log write should position cursor at row 19 for bottom anchor"
3979 );
3980 }
3981
3982 #[test]
3983 fn write_log_positions_cursor_top_anchor() {
3984 let mut output = Vec::new();
3987 {
3988 let mut writer = TerminalWriter::new(
3989 &mut output,
3990 ScreenMode::Inline { ui_height: 5 },
3991 UiAnchor::Top,
3992 basic_caps(),
3993 );
3994 writer.set_size(80, 24);
3995 writer.write_log("test log\n").unwrap();
3996 }
3997
3998 let expected_pos = b"\x1b[24;1H";
4002 assert!(
4003 output
4004 .windows(expected_pos.len())
4005 .any(|w| w == expected_pos),
4006 "Log write should position cursor at row 24 for top anchor"
4007 );
4008 }
4009
4010 #[test]
4011 fn write_log_contains_text() {
4012 let mut output = Vec::new();
4014 {
4015 let mut writer = TerminalWriter::new(
4016 &mut output,
4017 ScreenMode::Inline { ui_height: 5 },
4018 UiAnchor::Bottom,
4019 basic_caps(),
4020 );
4021 writer.set_size(80, 24);
4022 writer.write_log("hello world\n").unwrap();
4023 }
4024
4025 let output_str = String::from_utf8_lossy(&output);
4026 assert!(output_str.contains("hello world"));
4027 }
4028
4029 #[test]
4030 fn write_log_sanitizes_escape_injection_payloads() {
4031 let mut output = Vec::new();
4032 {
4033 let mut writer = TerminalWriter::new(
4034 &mut output,
4035 ScreenMode::Inline { ui_height: 5 },
4036 UiAnchor::Bottom,
4037 basic_caps(),
4038 );
4039 writer.set_size(80, 24);
4040 writer
4041 .write_log("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x\n")
4042 .unwrap();
4043 }
4044
4045 let output_str = String::from_utf8_lossy(&output);
4046 assert!(output_str.contains("safetailx"));
4047 assert!(
4048 !output_str.contains("52;c;SGVsbG8"),
4049 "OSC payload must not be forwarded to terminal output"
4050 );
4051 assert!(
4052 !output_str.contains('\u{009d}'),
4053 "C1 controls must be stripped from log output"
4054 );
4055 }
4056
4057 #[test]
4058 fn write_log_multiple_writes_position_each_time() {
4059 let mut output = Vec::new();
4061 {
4062 let mut writer = TerminalWriter::new(
4063 &mut output,
4064 ScreenMode::Inline { ui_height: 5 },
4065 UiAnchor::Bottom,
4066 basic_caps(),
4067 );
4068 writer.set_size(80, 24);
4069 writer.write_log("first\n").unwrap();
4070 writer.write_log("second\n").unwrap();
4071 }
4072
4073 let expected_pos = b"\x1b[19;1H";
4075 let count = output
4076 .windows(expected_pos.len())
4077 .filter(|w| *w == expected_pos)
4078 .count();
4079 assert_eq!(count, 2, "Should position cursor for each log write");
4080 }
4081
4082 #[test]
4083 fn write_log_after_present_ui_works_correctly() {
4084 let mut output = Vec::new();
4086 {
4087 let mut writer = TerminalWriter::new(
4088 &mut output,
4089 ScreenMode::Inline { ui_height: 5 },
4090 UiAnchor::Bottom,
4091 basic_caps(),
4092 );
4093 writer.set_size(80, 24);
4094
4095 let buffer = Buffer::new(80, 5);
4097 writer.present_ui(&buffer, None, true).unwrap();
4098
4099 writer.write_log("after UI\n").unwrap();
4101 }
4102
4103 let output_str = String::from_utf8_lossy(&output);
4104 assert!(output_str.contains("after UI"));
4105
4106 let expected_pos = b"\x1b[19;1H";
4108 assert!(
4110 output
4111 .windows(expected_pos.len())
4112 .any(|w| w == expected_pos),
4113 "Log write after present_ui should position cursor"
4114 );
4115 }
4116
4117 #[test]
4118 fn write_log_ui_fills_terminal_is_noop() {
4119 let mut output = Vec::new();
4123 {
4124 let mut writer = TerminalWriter::new(
4125 &mut output,
4126 ScreenMode::Inline { ui_height: 24 },
4127 UiAnchor::Bottom,
4128 basic_caps(),
4129 );
4130 writer.set_size(80, 24);
4131 writer.write_log("should still write\n").unwrap();
4132 }
4133 assert!(
4135 !output
4136 .windows(b"should still write".len())
4137 .any(|w| w == b"should still write"),
4138 "write_log should not emit log text when UI fills the terminal"
4139 );
4140 }
4141
4142 #[test]
4143 fn write_log_with_scroll_region_active() {
4144 let mut output = Vec::new();
4146 {
4147 let mut writer = TerminalWriter::new(
4148 &mut output,
4149 ScreenMode::Inline { ui_height: 5 },
4150 UiAnchor::Bottom,
4151 scroll_region_caps(),
4152 );
4153 writer.set_size(80, 24);
4154
4155 let buffer = Buffer::new(80, 5);
4157 writer.present_ui(&buffer, None, true).unwrap();
4158 assert!(writer.scroll_region_active());
4159
4160 writer.write_log("with scroll region\n").unwrap();
4162 }
4163
4164 let output_str = String::from_utf8_lossy(&output);
4165 assert!(output_str.contains("with scroll region"));
4166 }
4167
4168 #[test]
4169 fn log_write_cursor_position_not_in_ui_region_bottom_anchor() {
4170 let mut output = Vec::new();
4176 {
4177 let mut writer = TerminalWriter::new(
4178 &mut output,
4179 ScreenMode::Inline { ui_height: 5 },
4180 UiAnchor::Bottom,
4181 basic_caps(),
4182 );
4183 writer.set_size(80, 24);
4184 writer.write_log("test\n").unwrap();
4185 }
4186
4187 let mut found_row = None;
4190 let mut i = 0;
4191 while i + 2 < output.len() {
4192 if output[i] == 0x1b && output[i + 1] == b'[' {
4193 let mut j = i + 2;
4194 let mut row: u16 = 0;
4195 while j < output.len() && output[j].is_ascii_digit() {
4196 row = row * 10 + (output[j] - b'0') as u16;
4197 j += 1;
4198 }
4199 if j < output.len() && output[j] == b';' {
4200 j += 1;
4201 while j < output.len() && output[j].is_ascii_digit() {
4202 j += 1;
4203 }
4204 if j < output.len() && output[j] == b'H' {
4205 found_row = Some(row);
4206 }
4207 }
4208 }
4209 i += 1;
4210 }
4211
4212 if let Some(row) = found_row {
4213 assert!(
4215 row < 20,
4216 "Log cursor row {} should be below UI start row 20",
4217 row
4218 );
4219 }
4220 }
4221
4222 #[test]
4223 fn log_write_cursor_position_not_in_ui_region_top_anchor() {
4224 let mut output = Vec::new();
4230 {
4231 let mut writer = TerminalWriter::new(
4232 &mut output,
4233 ScreenMode::Inline { ui_height: 5 },
4234 UiAnchor::Top,
4235 basic_caps(),
4236 );
4237 writer.set_size(80, 24);
4238 writer.write_log("test\n").unwrap();
4239 }
4240
4241 let mut found_row = None;
4243 let mut i = 0;
4244 while i + 2 < output.len() {
4245 if output[i] == 0x1b && output[i + 1] == b'[' {
4246 let mut j = i + 2;
4247 let mut row: u16 = 0;
4248 while j < output.len() && output[j].is_ascii_digit() {
4249 row = row * 10 + (output[j] - b'0') as u16;
4250 j += 1;
4251 }
4252 if j < output.len() && output[j] == b';' {
4253 j += 1;
4254 while j < output.len() && output[j].is_ascii_digit() {
4255 j += 1;
4256 }
4257 if j < output.len() && output[j] == b'H' {
4258 found_row = Some(row);
4259 }
4260 }
4261 }
4262 i += 1;
4263 }
4264
4265 if let Some(row) = found_row {
4266 assert!(
4268 row > 5,
4269 "Log cursor row {} should be above UI end row 5",
4270 row
4271 );
4272 }
4273 }
4274
4275 #[test]
4276 fn present_ui_positions_cursor_after_restore() {
4277 let mut output = Vec::new();
4278 {
4279 let mut writer = TerminalWriter::new(
4280 &mut output,
4281 ScreenMode::Inline { ui_height: 5 },
4282 UiAnchor::Bottom,
4283 basic_caps(),
4284 );
4285 writer.set_size(80, 24);
4286
4287 let buffer = Buffer::new(80, 5);
4288 writer.present_ui(&buffer, Some((2, 1)), true).unwrap();
4290 }
4291
4292 let expected_pos = b"\x1b[21;3H";
4296
4297 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4299 let after_restore = &output[restore_idx..];
4300
4301 assert!(
4303 after_restore
4304 .windows(expected_pos.len())
4305 .any(|w| w == expected_pos),
4306 "Cursor positioning should happen after restore"
4307 );
4308 }
4309
4310 #[test]
4311 fn present_ui_inline_skips_cursor_position_when_x_is_out_of_bounds() {
4312 let mut output = Vec::new();
4313 {
4314 let mut writer = TerminalWriter::new(
4315 &mut output,
4316 ScreenMode::Inline { ui_height: 5 },
4317 UiAnchor::Bottom,
4318 basic_caps(),
4319 );
4320 writer.set_size(80, 24);
4321
4322 let buffer = Buffer::new(4, 5);
4323 writer.present_ui(&buffer, Some((4, 1)), true).unwrap();
4324 }
4325
4326 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4327 let after_restore = &output[restore_idx..];
4328 let invalid_pos = b"\x1b[21;5H";
4329 assert!(
4330 !after_restore
4331 .windows(invalid_pos.len())
4332 .any(|w| w == invalid_pos),
4333 "inline cursor should not move to x outside the buffer width"
4334 );
4335 }
4336
4337 #[test]
4338 fn present_ui_inline_skips_cursor_position_when_y_is_below_buffer_height() {
4339 let mut output = Vec::new();
4340 {
4341 let mut writer = TerminalWriter::new(
4342 &mut output,
4343 ScreenMode::Inline { ui_height: 5 },
4344 UiAnchor::Bottom,
4345 basic_caps(),
4346 );
4347 writer.set_size(80, 24);
4348
4349 let buffer = Buffer::new(4, 2);
4350 writer.present_ui(&buffer, Some((1, 4)), true).unwrap();
4351 }
4352
4353 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4354 let after_restore = &output[restore_idx..];
4355 let invalid_pos = b"\x1b[24;2H";
4356 assert!(
4357 !after_restore
4358 .windows(invalid_pos.len())
4359 .any(|w| w == invalid_pos),
4360 "inline cursor should not move below the buffer height just because the inline region is taller"
4361 );
4362 }
4363
4364 #[test]
4369 fn runtime_diff_config_default() {
4370 let config = RuntimeDiffConfig::default();
4371 assert!(config.bayesian_enabled);
4372 assert!(config.dirty_rows_enabled);
4373 assert!(config.dirty_span_config.enabled);
4374 assert!(config.tile_diff_config.enabled);
4375 assert!(config.reset_on_resize);
4376 assert!(config.reset_on_invalidation);
4377 assert_eq!(config.full_redraw_interval_frames, 240);
4378 }
4379
4380 #[test]
4381 fn runtime_diff_config_builder() {
4382 let custom_span = DirtySpanConfig::default().with_max_spans_per_row(8);
4383 let tile_config = TileDiffConfig::default()
4384 .with_enabled(false)
4385 .with_tile_size(24, 12)
4386 .with_dense_tile_ratio(0.75)
4387 .with_max_tiles(2048);
4388 let config = RuntimeDiffConfig::new()
4389 .with_bayesian_enabled(false)
4390 .with_dirty_rows_enabled(false)
4391 .with_dirty_span_config(custom_span)
4392 .with_dirty_spans_enabled(false)
4393 .with_tile_diff_config(tile_config)
4394 .with_reset_on_resize(false)
4395 .with_reset_on_invalidation(false)
4396 .with_full_redraw_interval_frames(17);
4397
4398 assert!(!config.bayesian_enabled);
4399 assert!(!config.dirty_rows_enabled);
4400 assert!(!config.dirty_span_config.enabled);
4401 assert_eq!(config.dirty_span_config.max_spans_per_row, 8);
4402 assert!(!config.tile_diff_config.enabled);
4403 assert_eq!(config.tile_diff_config.tile_w, 24);
4404 assert_eq!(config.tile_diff_config.tile_h, 12);
4405 assert_eq!(config.tile_diff_config.max_tiles, 2048);
4406 assert!(!config.reset_on_resize);
4407 assert!(!config.reset_on_invalidation);
4408 assert_eq!(config.full_redraw_interval_frames, 17);
4409 }
4410
4411 #[test]
4412 fn with_diff_config_applies_strategy_config() {
4413 use ftui_render::diff_strategy::DiffStrategyConfig;
4414
4415 let strategy_config = DiffStrategyConfig {
4416 prior_alpha: 5.0,
4417 prior_beta: 5.0,
4418 ..Default::default()
4419 };
4420
4421 let runtime_config =
4422 RuntimeDiffConfig::default().with_strategy_config(strategy_config.clone());
4423
4424 let writer = TerminalWriter::with_diff_config(
4425 Vec::<u8>::new(),
4426 ScreenMode::AltScreen,
4427 UiAnchor::Bottom,
4428 basic_caps(),
4429 runtime_config,
4430 );
4431
4432 let (alpha, beta) = writer.diff_strategy().posterior_params();
4434 assert!((alpha - 5.0).abs() < 0.001);
4435 assert!((beta - 5.0).abs() < 0.001);
4436 }
4437
4438 #[test]
4439 fn with_diff_config_applies_tile_config() {
4440 let tile_config = TileDiffConfig::default()
4441 .with_enabled(false)
4442 .with_tile_size(32, 16)
4443 .with_max_tiles(1024);
4444 let runtime_config = RuntimeDiffConfig::default().with_tile_diff_config(tile_config);
4445
4446 let mut writer = TerminalWriter::with_diff_config(
4447 Vec::<u8>::new(),
4448 ScreenMode::AltScreen,
4449 UiAnchor::Bottom,
4450 basic_caps(),
4451 runtime_config,
4452 );
4453
4454 let applied = writer.diff_scratch.tile_config_mut();
4455 assert!(!applied.enabled);
4456 assert_eq!(applied.tile_w, 32);
4457 assert_eq!(applied.tile_h, 16);
4458 assert_eq!(applied.max_tiles, 1024);
4459 }
4460
4461 #[test]
4462 fn diff_config_accessor() {
4463 let config = RuntimeDiffConfig::default().with_bayesian_enabled(false);
4464
4465 let writer = TerminalWriter::with_diff_config(
4466 Vec::<u8>::new(),
4467 ScreenMode::AltScreen,
4468 UiAnchor::Bottom,
4469 basic_caps(),
4470 config,
4471 );
4472
4473 assert!(!writer.diff_config().bayesian_enabled);
4474 }
4475
4476 #[test]
4477 fn last_diff_strategy_updates_after_present() {
4478 let mut output = Vec::new();
4479 let mut writer = TerminalWriter::with_diff_config(
4480 &mut output,
4481 ScreenMode::AltScreen,
4482 UiAnchor::Bottom,
4483 basic_caps(),
4484 RuntimeDiffConfig::default(),
4485 );
4486 writer.set_size(10, 3);
4487
4488 let mut buffer = Buffer::new(10, 3);
4489 buffer.set_raw(0, 0, Cell::from_char('X'));
4490
4491 assert!(writer.last_diff_strategy().is_none());
4492 writer.present_ui(&buffer, None, false).unwrap();
4493 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4494
4495 buffer.set_raw(1, 1, Cell::from_char('Y'));
4496 writer.present_ui(&buffer, None, false).unwrap();
4497 assert!(writer.last_diff_strategy().is_some());
4498 }
4499
4500 #[test]
4501 fn full_redraw_interval_forces_terminal_resync() {
4502 let mut output = Vec::new();
4503 let mut writer = TerminalWriter::with_diff_config(
4504 &mut output,
4505 ScreenMode::AltScreen,
4506 UiAnchor::Bottom,
4507 basic_caps(),
4508 RuntimeDiffConfig::default()
4509 .with_bayesian_enabled(false)
4510 .with_full_redraw_interval_frames(1),
4511 );
4512 writer.set_size(4, 2);
4513
4514 let mut buffer = Buffer::new(4, 2);
4515 buffer.set_raw(0, 0, Cell::from_char('A'));
4516
4517 writer.present_ui(&buffer, None, false).unwrap();
4518 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4519
4520 writer.present_ui(&buffer, None, false).unwrap();
4521 assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4522
4523 writer.present_ui(&buffer, None, false).unwrap();
4524 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4525 }
4526
4527 #[test]
4528 fn full_redraw_interval_emits_current_frame_after_incremental_baseline() {
4529 let state = Rc::new(RefCell::new(FaultState::default()));
4530 let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), usize::MAX, 1);
4531 let mut writer = TerminalWriter::with_diff_config(
4532 writer_backend,
4533 ScreenMode::AltScreen,
4534 UiAnchor::Bottom,
4535 basic_caps(),
4536 RuntimeDiffConfig::default()
4537 .with_bayesian_enabled(false)
4538 .with_full_redraw_interval_frames(1),
4539 );
4540 writer.set_size(4, 2);
4541
4542 let mut buffer = Buffer::new(4, 2);
4543 buffer.set_raw(0, 0, Cell::from_char('Q'));
4544
4545 writer.present_ui(&buffer, None, false).unwrap();
4546 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4547
4548 buffer.set_raw(1, 0, Cell::from_char('Z'));
4549 writer.present_ui(&buffer, None, false).unwrap();
4550 assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4551
4552 state.borrow_mut().bytes.clear();
4553 writer.present_ui(&buffer, None, false).unwrap();
4554 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4555
4556 let bytes = state.borrow().bytes.clone();
4557 assert!(
4558 bytes.windows(b"QZ".len()).any(|window| window == b"QZ"),
4559 "forced full redraw should emit adjacent current-frame cells"
4560 );
4561 }
4562
4563 #[test]
4564 fn full_redraw_interval_zero_disables_terminal_resync() {
4565 let mut output = Vec::new();
4566 let mut writer = TerminalWriter::with_diff_config(
4567 &mut output,
4568 ScreenMode::AltScreen,
4569 UiAnchor::Bottom,
4570 basic_caps(),
4571 RuntimeDiffConfig::default()
4572 .with_bayesian_enabled(false)
4573 .with_full_redraw_interval_frames(0),
4574 );
4575 writer.set_size(4, 2);
4576
4577 let mut buffer = Buffer::new(4, 2);
4578 buffer.set_raw(0, 0, Cell::from_char('A'));
4579
4580 writer.present_ui(&buffer, None, false).unwrap();
4581 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4582
4583 for _ in 0..5 {
4584 writer.present_ui(&buffer, None, false).unwrap();
4585 assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4586 }
4587 }
4588
4589 #[test]
4590 fn full_redraw_max_interval_zero_forces_resync_every_frame() {
4591 let mut output = Vec::new();
4596 let mut writer = TerminalWriter::with_diff_config(
4597 &mut output,
4598 ScreenMode::AltScreen,
4599 UiAnchor::Bottom,
4600 basic_caps(),
4601 RuntimeDiffConfig::default()
4602 .with_bayesian_enabled(false)
4603 .with_full_redraw_interval_frames(0)
4605 .with_full_redraw_max_interval(Some(std::time::Duration::ZERO)),
4606 );
4607 writer.set_size(4, 2);
4608
4609 let mut buffer = Buffer::new(4, 2);
4610 buffer.set_raw(0, 0, Cell::from_char('A'));
4611
4612 for _ in 0..5 {
4615 writer.present_ui(&buffer, None, false).unwrap();
4616 assert_eq!(
4617 writer.last_diff_strategy(),
4618 Some(DiffStrategy::FullRedraw),
4619 "zero wall-clock interval must force a full redraw every frame"
4620 );
4621 }
4622 }
4623
4624 #[test]
4625 fn full_redraw_max_interval_none_keeps_incremental_after_baseline() {
4626 let mut output = Vec::new();
4631 let mut writer = TerminalWriter::with_diff_config(
4632 &mut output,
4633 ScreenMode::AltScreen,
4634 UiAnchor::Bottom,
4635 basic_caps(),
4636 RuntimeDiffConfig::default()
4637 .with_bayesian_enabled(false)
4638 .with_full_redraw_interval_frames(0)
4639 .with_full_redraw_max_interval(None),
4640 );
4641 writer.set_size(4, 2);
4642
4643 let mut buffer = Buffer::new(4, 2);
4644 buffer.set_raw(0, 0, Cell::from_char('A'));
4645
4646 writer.present_ui(&buffer, None, false).unwrap();
4647 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4648
4649 for _ in 0..5 {
4650 writer.present_ui(&buffer, None, false).unwrap();
4651 assert_ne!(
4652 writer.last_diff_strategy(),
4653 Some(DiffStrategy::FullRedraw),
4654 "no wall-clock bound must leave frames incremental after baseline"
4655 );
4656 }
4657 }
4658
4659 #[test]
4660 fn diff_decision_evidence_schema_includes_span_fields() {
4661 let evidence_path = temp_evidence_path("diff_decision_schema");
4662 let sink = EvidenceSink::from_config(
4663 &crate::evidence_sink::EvidenceSinkConfig::enabled_file(&evidence_path),
4664 )
4665 .expect("evidence sink config")
4666 .expect("evidence sink enabled");
4667
4668 let mut writer = TerminalWriter::with_diff_config(
4669 Vec::<u8>::new(),
4670 ScreenMode::AltScreen,
4671 UiAnchor::Bottom,
4672 basic_caps(),
4673 RuntimeDiffConfig::default(),
4674 )
4675 .with_evidence_sink(sink);
4676 writer.set_size(10, 3);
4677
4678 let mut buffer = Buffer::new(10, 3);
4679 buffer.set_raw(0, 0, Cell::from_char('X'));
4680 writer.present_ui(&buffer, None, false).unwrap();
4681
4682 buffer.set_raw(1, 1, Cell::from_char('Y'));
4683 writer.present_ui(&buffer, None, false).unwrap();
4684
4685 let jsonl = std::fs::read_to_string(&evidence_path).expect("read evidence jsonl");
4686 let line = jsonl
4687 .lines()
4688 .find(|line| line.contains("\"event\":\"diff_decision\""))
4689 .expect("diff_decision line");
4690 let value: serde_json::Value = serde_json::from_str(line).expect("valid json");
4691
4692 assert_eq!(
4693 value["schema_version"],
4694 crate::evidence_sink::EVIDENCE_SCHEMA_VERSION
4695 );
4696 assert_eq!(value["event"], "diff_decision");
4697 assert!(
4698 value["run_id"]
4699 .as_str()
4700 .map(|s| !s.is_empty())
4701 .unwrap_or(false),
4702 "run_id should be a non-empty string"
4703 );
4704 assert!(
4705 value["event_idx"].is_number(),
4706 "event_idx should be numeric"
4707 );
4708 assert_eq!(value["screen_mode"], "altscreen");
4709 assert!(value["cols"].is_number(), "cols should be numeric");
4710 assert!(value["rows"].is_number(), "rows should be numeric");
4711 assert!(
4712 value["span_count"].is_number(),
4713 "span_count should be numeric"
4714 );
4715 assert!(
4716 value["span_coverage_pct"].is_number(),
4717 "span_coverage_pct should be numeric"
4718 );
4719 assert!(
4720 value["tile_size"].is_number(),
4721 "tile_size should be numeric"
4722 );
4723 assert!(
4724 value["dirty_tile_count"].is_number(),
4725 "dirty_tile_count should be numeric"
4726 );
4727 assert!(
4728 value["skipped_tile_count"].is_number(),
4729 "skipped_tile_count should be numeric"
4730 );
4731 assert!(
4732 value["sat_build_cost_est"].is_number(),
4733 "sat_build_cost_est should be numeric"
4734 );
4735 assert!(
4736 value["fallback_reason"].is_string(),
4737 "fallback_reason should be string"
4738 );
4739 assert!(
4740 value["scan_cost_estimate"].is_number(),
4741 "scan_cost_estimate should be numeric"
4742 );
4743 assert!(
4744 value["max_span_len"].is_number(),
4745 "max_span_len should be numeric"
4746 );
4747 assert!(
4748 value["guard_reason"].is_string(),
4749 "guard_reason should be a string"
4750 );
4751 assert!(
4752 value["hysteresis_applied"].is_boolean(),
4753 "hysteresis_applied should be boolean"
4754 );
4755 assert!(
4756 value["hysteresis_ratio"].is_number(),
4757 "hysteresis_ratio should be numeric"
4758 );
4759 assert!(
4760 value["fallback_reason"].is_string(),
4761 "fallback_reason should be a string"
4762 );
4763 assert!(
4764 value["scan_cost_estimate"].is_number(),
4765 "scan_cost_estimate should be numeric"
4766 );
4767 }
4768
4769 #[test]
4770 fn diff_strategy_posterior_updates_with_total_cells() {
4771 let mut output = Vec::new();
4772 let mut writer = TerminalWriter::with_diff_config(
4773 &mut output,
4774 ScreenMode::AltScreen,
4775 UiAnchor::Bottom,
4776 basic_caps(),
4777 RuntimeDiffConfig::default(),
4778 );
4779 writer.set_size(10, 10);
4780
4781 let mut buffer = Buffer::new(10, 10);
4782 buffer.set_raw(0, 0, Cell::from_char('A'));
4783 writer.present_ui(&buffer, None, false).unwrap();
4784
4785 let mut buffer2 = Buffer::new(10, 10);
4786 for x in 0..10u16 {
4787 buffer2.set_raw(x, 0, Cell::from_char('X'));
4788 }
4789 writer.present_ui(&buffer2, None, false).unwrap();
4790
4791 let config = writer.diff_strategy().config().clone();
4792 let total_cells = 10usize * 10usize;
4793 let changed = 10usize;
4794 let alpha = config.prior_alpha * config.decay + changed as f64;
4795 let beta = config.prior_beta * config.decay + (total_cells - changed) as f64;
4796 let expected = alpha / (alpha + beta);
4797 let mean = writer.diff_strategy().posterior_mean();
4798 assert!(
4799 (mean - expected).abs() < 1e-9,
4800 "posterior mean should use total_cells; got {mean:.6}, expected {expected:.6}"
4801 );
4802 }
4803
4804 #[test]
4805 fn log_write_without_scroll_region_preserves_diff_strategy() {
4806 let mut output = Vec::new();
4812 {
4813 let config = RuntimeDiffConfig::default();
4814 let mut writer = TerminalWriter::with_diff_config(
4815 &mut output,
4816 ScreenMode::Inline { ui_height: 5 },
4817 UiAnchor::Bottom,
4818 basic_caps(), config,
4820 );
4821 writer.set_size(80, 24);
4822
4823 let mut buffer = Buffer::new(80, 5);
4825 buffer.set_raw(0, 0, Cell::from_char('X'));
4826 writer.present_ui(&buffer, None, false).unwrap();
4827
4828 buffer.set_raw(1, 1, Cell::from_char('Y'));
4830 writer.present_ui(&buffer, None, false).unwrap();
4831
4832 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4833
4834 assert!(!writer.scroll_region_active());
4836 writer.write_log("log message\n").unwrap();
4837
4838 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4839 assert!(
4840 (alpha_after - alpha_before).abs() < 0.01
4841 && (beta_after - beta_before).abs() < 0.01,
4842 "posterior should be preserved across overlay log write: \
4843 before=({}, {}), after=({}, {})",
4844 alpha_before,
4845 beta_before,
4846 alpha_after,
4847 beta_after
4848 );
4849 }
4850 }
4851
4852 #[test]
4853 fn log_write_with_scroll_region_preserves_diff_strategy() {
4854 let mut output = Vec::new();
4856 {
4857 let config = RuntimeDiffConfig::default();
4858 let mut writer = TerminalWriter::with_diff_config(
4859 &mut output,
4860 ScreenMode::Inline { ui_height: 5 },
4861 UiAnchor::Bottom,
4862 scroll_region_caps(), config,
4864 );
4865 writer.set_size(80, 24);
4866
4867 let mut buffer = Buffer::new(80, 5);
4869 buffer.set_raw(0, 0, Cell::from_char('X'));
4870 writer.present_ui(&buffer, None, false).unwrap();
4871
4872 buffer.set_raw(1, 1, Cell::from_char('Y'));
4873 writer.present_ui(&buffer, None, false).unwrap();
4874
4875 assert!(writer.scroll_region_active());
4876
4877 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4879
4880 writer.write_log("log message\n").unwrap();
4882
4883 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4884 assert!(
4885 (alpha_after - alpha_before).abs() < 0.01
4886 && (beta_after - beta_before).abs() < 0.01,
4887 "posterior should be preserved with scroll region: before=({}, {}), after=({}, {})",
4888 alpha_before,
4889 beta_before,
4890 alpha_after,
4891 beta_after
4892 );
4893 }
4894 }
4895
4896 #[test]
4903 fn overlay_write_log_emits_single_erased_line_no_lf() {
4904 let mut output = Vec::new();
4905 {
4906 let mut writer = TerminalWriter::new(
4907 &mut output,
4908 ScreenMode::Inline { ui_height: 5 },
4909 UiAnchor::Bottom,
4910 basic_caps(),
4911 );
4912 writer.set_size(80, 24);
4913 writer.write_log("first line\n").unwrap();
4914 }
4915
4916 assert!(
4917 !output.contains(&b'\n'),
4918 "overlay write_log must never emit a raw LF byte"
4919 );
4920 assert!(
4921 contains_bytes(&output, ERASE_LINE),
4922 "overlay write_log must erase the log row before writing"
4923 );
4924 assert!(
4925 contains_bytes(&output, b"first line"),
4926 "the (single-line) log payload must be emitted"
4927 );
4928 }
4929
4930 #[test]
4933 fn overlay_write_log_drops_lines_after_first() {
4934 let mut output = Vec::new();
4935 {
4936 let mut writer = TerminalWriter::new(
4937 &mut output,
4938 ScreenMode::Inline { ui_height: 5 },
4939 UiAnchor::Bottom,
4940 basic_caps(),
4941 );
4942 writer.set_size(80, 24);
4943 writer.write_log("only me\nhidden tail\n").unwrap();
4944 }
4945
4946 let text = String::from_utf8_lossy(&output);
4947 assert!(text.contains("only me"), "first line must be emitted");
4948 assert!(
4949 !text.contains("hidden tail"),
4950 "second line must be dropped in overlay mode"
4951 );
4952 }
4953
4954 #[test]
4958 fn overlay_top_anchor_write_log_never_scrolls() {
4959 let mut output = Vec::new();
4960 {
4961 let mut writer = TerminalWriter::new(
4962 &mut output,
4963 ScreenMode::Inline { ui_height: 5 },
4964 UiAnchor::Top,
4965 basic_caps(),
4966 );
4967 writer.set_size(80, 24);
4968 writer.write_log("top anchored\n").unwrap();
4969 }
4970
4971 assert!(
4972 !output.contains(&b'\n'),
4973 "top-anchor overlay logs must not emit LF (screen-scroll hazard)"
4974 );
4975 assert!(
4976 contains_bytes(&output, b"\x1b[24;1H"),
4977 "log should target the screen's bottom row (below the UI)"
4978 );
4979 assert!(contains_bytes(&output, b"top anchored"));
4980 }
4981
4982 #[test]
4985 fn overlay_write_log_clamps_to_terminal_width() {
4986 let long_line = "x".repeat(100);
4987 let mut output = Vec::new();
4988 {
4989 let mut writer = TerminalWriter::new(
4990 &mut output,
4991 ScreenMode::Inline { ui_height: 5 },
4992 UiAnchor::Bottom,
4993 basic_caps(),
4994 );
4995 writer.set_size(80, 24);
4996 writer.write_log(&long_line).unwrap();
4997 }
4998
4999 let emitted_xs = output.iter().filter(|&&b| b == b'x').count();
5000 assert_eq!(
5001 emitted_xs, 80,
5002 "exactly term_width columns of the log line must be emitted"
5003 );
5004 }
5005
5006 #[test]
5008 fn inline_auto_shrink_positions_log_against_displayed_region() {
5009 let mut output = Vec::new();
5010 {
5011 let mut writer = TerminalWriter::new(
5012 &mut output,
5013 ScreenMode::InlineAuto {
5014 min_height: 1,
5015 max_height: 8,
5016 },
5017 UiAnchor::Bottom,
5018 basic_caps(),
5019 );
5020 writer.set_size(80, 24);
5021
5022 writer.set_auto_ui_height(8);
5025 let buffer = Buffer::new(80, 8);
5026 writer.present_ui(&buffer, None, false).unwrap();
5027
5028 writer.clear_auto_ui_height();
5031
5032 writer.write_log("mid-flight\n").unwrap();
5034 }
5035
5036 assert!(
5041 contains_bytes(&output, b"\x1b7\x1b[16;1H\x1b[2K"),
5042 "log must anchor one row above the displayed region start"
5043 );
5044 for row in 17..=24 {
5045 assert!(
5046 !contains_bytes(&output, format!("\x1b7\x1b[{row};1H").as_bytes()),
5047 "log must not position inside displayed UI row {row}"
5048 );
5049 }
5050 assert!(contains_bytes(&output, b"mid-flight"));
5051 }
5052
5053 #[test]
5056 fn set_size_clears_last_inline_region() {
5057 let mut writer = TerminalWriter::new(
5058 Vec::new(),
5059 ScreenMode::Inline { ui_height: 5 },
5060 UiAnchor::Bottom,
5061 basic_caps(),
5062 );
5063 writer.set_size(80, 24);
5064 let buffer = Buffer::new(80, 5);
5065 writer.present_ui(&buffer, None, false).unwrap();
5066 assert!(writer.last_inline_region.is_some());
5067
5068 writer.set_size(80, 30);
5069 assert!(
5070 writer.last_inline_region.is_none(),
5071 "resize must invalidate the displayed-region cache"
5072 );
5073 }
5074 #[test]
5075 fn strategy_selection_config_flags_applied() {
5076 let config = RuntimeDiffConfig::default()
5078 .with_dirty_rows_enabled(false)
5079 .with_bayesian_enabled(false);
5080
5081 let writer = TerminalWriter::with_diff_config(
5082 Vec::<u8>::new(),
5083 ScreenMode::AltScreen,
5084 UiAnchor::Bottom,
5085 basic_caps(),
5086 config,
5087 );
5088
5089 assert!(!writer.diff_config().dirty_rows_enabled);
5091 assert!(!writer.diff_config().bayesian_enabled);
5092
5093 let (alpha, beta) = writer.diff_strategy().posterior_params();
5095 assert!((alpha - 1.0).abs() < 0.01);
5097 assert!((beta - 19.0).abs() < 0.01);
5098 }
5099
5100 #[test]
5101 fn resize_respects_reset_toggle() {
5102 let config = RuntimeDiffConfig::default().with_reset_on_resize(false);
5104
5105 let mut writer = TerminalWriter::with_diff_config(
5106 Vec::<u8>::new(),
5107 ScreenMode::AltScreen,
5108 UiAnchor::Bottom,
5109 basic_caps(),
5110 config,
5111 );
5112 writer.set_size(80, 24);
5113
5114 let mut buffer = Buffer::new(80, 24);
5116 buffer.set_raw(0, 0, Cell::from_char('X'));
5117 writer.present_ui(&buffer, None, false).unwrap();
5118
5119 let mut buffer2 = Buffer::new(80, 24);
5120 buffer2.set_raw(1, 1, Cell::from_char('Y'));
5121 writer.present_ui(&buffer2, None, false).unwrap();
5122
5123 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
5125
5126 writer.set_size(100, 30);
5128
5129 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
5130 assert!(
5131 (alpha_after - alpha_before).abs() < 0.01 && (beta_after - beta_before).abs() < 0.01,
5132 "posterior should be preserved when reset_on_resize=false"
5133 );
5134 }
5135
5136 #[test]
5141 fn screen_mode_default_is_altscreen() {
5142 assert_eq!(ScreenMode::default(), ScreenMode::AltScreen);
5143 }
5144
5145 #[test]
5146 fn screen_mode_debug_format() {
5147 let dbg = format!("{:?}", ScreenMode::Inline { ui_height: 7 });
5148 assert!(dbg.contains("Inline"));
5149 assert!(dbg.contains('7'));
5150 }
5151
5152 #[test]
5153 fn screen_mode_inline_auto_debug_format() {
5154 let dbg = format!(
5155 "{:?}",
5156 ScreenMode::InlineAuto {
5157 min_height: 3,
5158 max_height: 10
5159 }
5160 );
5161 assert!(dbg.contains("InlineAuto"));
5162 }
5163
5164 #[test]
5165 fn screen_mode_eq_inline_auto() {
5166 let a = ScreenMode::InlineAuto {
5167 min_height: 2,
5168 max_height: 8,
5169 };
5170 let b = ScreenMode::InlineAuto {
5171 min_height: 2,
5172 max_height: 8,
5173 };
5174 assert_eq!(a, b);
5175 let c = ScreenMode::InlineAuto {
5176 min_height: 2,
5177 max_height: 9,
5178 };
5179 assert_ne!(a, c);
5180 }
5181
5182 #[test]
5183 fn ui_anchor_default_is_bottom() {
5184 assert_eq!(UiAnchor::default(), UiAnchor::Bottom);
5185 }
5186
5187 #[test]
5188 fn ui_anchor_debug_format() {
5189 assert_eq!(format!("{:?}", UiAnchor::Top), "Top");
5190 assert_eq!(format!("{:?}", UiAnchor::Bottom), "Bottom");
5191 }
5192
5193 #[test]
5198 fn width_height_accessors() {
5199 let output = Vec::new();
5200 let mut writer = TerminalWriter::new(
5201 output,
5202 ScreenMode::AltScreen,
5203 UiAnchor::Bottom,
5204 basic_caps(),
5205 );
5206 assert_eq!(writer.width(), 80);
5208 assert_eq!(writer.height(), 24);
5209
5210 writer.set_size(120, 40);
5211 assert_eq!(writer.width(), 120);
5212 assert_eq!(writer.height(), 40);
5213 }
5214
5215 #[test]
5216 fn screen_mode_accessor() {
5217 let writer = TerminalWriter::new(
5218 Vec::new(),
5219 ScreenMode::Inline { ui_height: 5 },
5220 UiAnchor::Top,
5221 basic_caps(),
5222 );
5223 assert_eq!(writer.screen_mode(), ScreenMode::Inline { ui_height: 5 });
5224 }
5225
5226 #[test]
5227 fn capabilities_accessor() {
5228 let caps = full_caps();
5229 let writer = TerminalWriter::new(Vec::new(), ScreenMode::AltScreen, UiAnchor::Bottom, caps);
5230 assert!(writer.capabilities().supports_true_color());
5231 assert!(writer.capabilities().sync_output);
5232 }
5233
5234 #[test]
5239 fn into_inner_returns_writer() {
5240 let writer = TerminalWriter::new(
5241 Vec::new(),
5242 ScreenMode::AltScreen,
5243 UiAnchor::Bottom,
5244 basic_caps(),
5245 );
5246 let inner = writer.into_inner();
5247 assert!(inner.is_some());
5248 }
5249
5250 #[test]
5251 fn into_inner_performs_cleanup() {
5252 let mut writer = TerminalWriter::new(
5253 Vec::new(),
5254 ScreenMode::Inline { ui_height: 5 },
5255 UiAnchor::Bottom,
5256 basic_caps(),
5257 );
5258 writer.cursor_saved = true;
5259 writer.in_sync_block = false;
5260
5261 let inner = writer.into_inner().unwrap();
5262 assert!(
5264 inner
5265 .windows(CURSOR_RESTORE.len())
5266 .any(|w| w == CURSOR_RESTORE),
5267 "into_inner should perform cleanup before returning"
5268 );
5269 }
5270
5271 #[test]
5276 fn take_render_buffer_creates_new_when_no_spare() {
5277 let mut writer = TerminalWriter::new(
5278 Vec::new(),
5279 ScreenMode::AltScreen,
5280 UiAnchor::Bottom,
5281 basic_caps(),
5282 );
5283 let buf = writer.take_render_buffer(80, 24);
5284 assert_eq!(buf.width(), 80);
5285 assert_eq!(buf.height(), 24);
5286 }
5287
5288 #[test]
5289 fn take_render_buffer_reuses_spare_on_match() {
5290 let mut writer = TerminalWriter::new(
5291 Vec::new(),
5292 ScreenMode::AltScreen,
5293 UiAnchor::Bottom,
5294 basic_caps(),
5295 );
5296 writer.spare_buffer = Some(Buffer::new(80, 24));
5298 assert!(writer.spare_buffer.is_some());
5299
5300 let buf = writer.take_render_buffer(80, 24);
5301 assert_eq!(buf.width(), 80);
5302 assert_eq!(buf.height(), 24);
5303 assert!(writer.spare_buffer.is_none());
5305 }
5306
5307 #[test]
5308 fn take_render_buffer_ignores_spare_on_size_mismatch() {
5309 let mut writer = TerminalWriter::new(
5310 Vec::new(),
5311 ScreenMode::AltScreen,
5312 UiAnchor::Bottom,
5313 basic_caps(),
5314 );
5315 writer.spare_buffer = Some(Buffer::new(80, 24));
5316
5317 let buf = writer.take_render_buffer(100, 30);
5319 assert_eq!(buf.width(), 100);
5320 assert_eq!(buf.height(), 30);
5321 }
5322
5323 #[test]
5328 fn gc_with_no_prev_buffer() {
5329 let mut writer = TerminalWriter::new(
5330 Vec::new(),
5331 ScreenMode::AltScreen,
5332 UiAnchor::Bottom,
5333 basic_caps(),
5334 );
5335 assert!(writer.prev_buffer.is_none());
5336 writer.gc(None);
5338 }
5339
5340 #[test]
5341 fn gc_with_prev_buffer() {
5342 let mut writer = TerminalWriter::new(
5343 Vec::new(),
5344 ScreenMode::AltScreen,
5345 UiAnchor::Bottom,
5346 basic_caps(),
5347 );
5348 writer.prev_buffer = Some(Buffer::new(10, 5));
5349 writer.gc(None);
5351 }
5352
5353 #[test]
5358 fn hide_cursor_emits_sequence() {
5359 let mut output = Vec::new();
5360 {
5361 let mut writer = TerminalWriter::new(
5362 &mut output,
5363 ScreenMode::AltScreen,
5364 UiAnchor::Bottom,
5365 basic_caps(),
5366 );
5367 writer.hide_cursor().unwrap();
5368 }
5369 assert!(
5370 output.windows(6).any(|w| w == b"\x1b[?25l"),
5371 "hide_cursor should emit cursor hide sequence"
5372 );
5373 }
5374
5375 #[test]
5376 fn show_cursor_emits_sequence() {
5377 let mut output = Vec::new();
5378 {
5379 let mut writer = TerminalWriter::new(
5380 &mut output,
5381 ScreenMode::AltScreen,
5382 UiAnchor::Bottom,
5383 basic_caps(),
5384 );
5385 writer.hide_cursor().unwrap();
5387 writer.show_cursor().unwrap();
5388 }
5389 assert!(
5390 output.windows(6).any(|w| w == b"\x1b[?25h"),
5391 "show_cursor should emit cursor show sequence"
5392 );
5393 }
5394
5395 #[test]
5396 fn hide_cursor_idempotent() {
5397 use std::io::Cursor;
5399 let mut writer = TerminalWriter::new(
5400 Cursor::new(Vec::new()),
5401 ScreenMode::AltScreen,
5402 UiAnchor::Bottom,
5403 basic_caps(),
5404 );
5405 writer.hide_cursor().unwrap();
5406 let inner = writer.into_inner().unwrap().into_inner();
5407 let hide_count = inner.windows(6).filter(|w| *w == b"\x1b[?25l").count();
5408 assert_eq!(
5410 hide_count, 1,
5411 "hide_cursor called once should emit exactly one hide sequence"
5412 );
5413 }
5414
5415 #[test]
5416 fn show_cursor_idempotent_when_already_visible() {
5417 use std::io::Cursor;
5418 let mut writer = TerminalWriter::new(
5419 Cursor::new(Vec::new()),
5420 ScreenMode::AltScreen,
5421 UiAnchor::Bottom,
5422 basic_caps(),
5423 );
5424 writer.show_cursor().unwrap();
5426 let inner = writer.into_inner().unwrap().into_inner();
5427 let show_count = inner.windows(6).filter(|w| *w == b"\x1b[?25h").count();
5429 assert!(
5430 show_count <= 1,
5431 "show_cursor when already visible should not add extra show sequences"
5432 );
5433 }
5434
5435 #[test]
5440 fn pool_accessor() {
5441 let writer = TerminalWriter::new(
5442 Vec::new(),
5443 ScreenMode::AltScreen,
5444 UiAnchor::Bottom,
5445 basic_caps(),
5446 );
5447 let _pool = writer.pool();
5449 }
5450
5451 #[test]
5452 fn pool_mut_accessor() {
5453 let mut writer = TerminalWriter::new(
5454 Vec::new(),
5455 ScreenMode::AltScreen,
5456 UiAnchor::Bottom,
5457 basic_caps(),
5458 );
5459 let _pool = writer.pool_mut();
5460 }
5461
5462 #[test]
5463 fn links_accessor() {
5464 let writer = TerminalWriter::new(
5465 Vec::new(),
5466 ScreenMode::AltScreen,
5467 UiAnchor::Bottom,
5468 basic_caps(),
5469 );
5470 let _links = writer.links();
5471 }
5472
5473 #[test]
5474 fn links_mut_accessor() {
5475 let mut writer = TerminalWriter::new(
5476 Vec::new(),
5477 ScreenMode::AltScreen,
5478 UiAnchor::Bottom,
5479 basic_caps(),
5480 );
5481 let _links = writer.links_mut();
5482 }
5483
5484 #[test]
5485 fn pool_and_links_mut_accessor() {
5486 let mut writer = TerminalWriter::new(
5487 Vec::new(),
5488 ScreenMode::AltScreen,
5489 UiAnchor::Bottom,
5490 basic_caps(),
5491 );
5492 let (_pool, _links) = writer.pool_and_links_mut();
5493 }
5494
5495 #[test]
5500 fn sanitize_auto_bounds_normal() {
5501 assert_eq!(sanitize_auto_bounds(3, 10), (3, 10));
5502 }
5503
5504 #[test]
5505 fn sanitize_auto_bounds_zero_min() {
5506 assert_eq!(sanitize_auto_bounds(0, 10), (1, 10));
5508 }
5509
5510 #[test]
5511 fn sanitize_auto_bounds_max_less_than_min() {
5512 assert_eq!(sanitize_auto_bounds(5, 3), (5, 5));
5514 }
5515
5516 #[test]
5517 fn sanitize_auto_bounds_both_zero() {
5518 assert_eq!(sanitize_auto_bounds(0, 0), (1, 1));
5519 }
5520
5521 #[test]
5522 fn diff_strategy_str_variants() {
5523 assert_eq!(diff_strategy_str(DiffStrategy::Full), "full");
5524 assert_eq!(diff_strategy_str(DiffStrategy::DirtyRows), "dirty");
5525 assert_eq!(diff_strategy_str(DiffStrategy::FullRedraw), "redraw");
5526 }
5527
5528 #[test]
5529 fn ui_anchor_str_variants() {
5530 assert_eq!(ui_anchor_str(UiAnchor::Bottom), "bottom");
5531 assert_eq!(ui_anchor_str(UiAnchor::Top), "top");
5532 }
5533
5534 #[test]
5535 fn json_escape_plain_text() {
5536 assert_eq!(json_escape("hello"), "hello");
5537 }
5538
5539 #[test]
5540 fn json_escape_special_chars() {
5541 assert_eq!(json_escape(r#"a"b"#), r#"a\"b"#);
5542 assert_eq!(json_escape("a\\b"), r#"a\\b"#);
5543 assert_eq!(json_escape("a\nb"), r#"a\nb"#);
5544 assert_eq!(json_escape("a\rb"), r#"a\rb"#);
5545 assert_eq!(json_escape("a\tb"), r#"a\tb"#);
5546 }
5547
5548 #[test]
5549 fn json_escape_control_chars() {
5550 let s = String::from("\x00\x01\x1f");
5551 let escaped = json_escape(&s);
5552 assert!(escaped.contains("\\u0000"));
5553 assert!(escaped.contains("\\u0001"));
5554 assert!(escaped.contains("\\u001F"));
5555 }
5556
5557 #[test]
5558 fn json_escape_unicode_passthrough() {
5559 assert_eq!(json_escape("caf\u{00e9}"), "caf\u{00e9}");
5560 assert_eq!(json_escape("\u{1f600}"), "\u{1f600}");
5561 }
5562
5563 #[test]
5568 fn counting_writer_into_inner() {
5569 let mut cw = CountingWriter::new(Vec::new());
5570 cw.write_all(b"data").unwrap();
5571 let inner = cw.into_inner();
5572 assert_eq!(inner, b"data");
5573 }
5574
5575 fn zero_span_stats() -> DirtySpanStats {
5580 DirtySpanStats {
5581 rows_full_dirty: 0,
5582 rows_with_spans: 0,
5583 total_spans: 0,
5584 overflows: 0,
5585 span_coverage_cells: 0,
5586 max_span_len: 0,
5587 max_spans_per_row: 4,
5588 }
5589 }
5590
5591 #[test]
5592 fn estimate_diff_scan_cost_full_strategy() {
5593 let stats = zero_span_stats();
5594 let (cost, label) = estimate_diff_scan_cost(DiffStrategy::Full, 0, 80, 24, &stats, None);
5595 assert_eq!(cost, 80 * 24);
5596 assert_eq!(label, "full_strategy");
5597 }
5598
5599 #[test]
5600 fn estimate_diff_scan_cost_full_redraw() {
5601 let stats = zero_span_stats();
5602 let (cost, label) =
5603 estimate_diff_scan_cost(DiffStrategy::FullRedraw, 5, 80, 24, &stats, None);
5604 assert_eq!(cost, 0);
5605 assert_eq!(label, "full_redraw");
5606 }
5607
5608 #[test]
5609 fn estimate_diff_scan_cost_dirty_rows_no_dirty() {
5610 let stats = zero_span_stats();
5611 let (cost, label) =
5612 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 0, 80, 24, &stats, None);
5613 assert_eq!(cost, 0);
5614 assert_eq!(label, "no_dirty_rows");
5615 }
5616
5617 #[test]
5618 fn estimate_diff_scan_cost_dirty_rows_with_span_coverage() {
5619 let mut stats = zero_span_stats();
5620 stats.span_coverage_cells = 100;
5621 let (cost, label) =
5622 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5623 assert_eq!(cost, 100);
5624 assert_eq!(label, "none");
5625 }
5626
5627 #[test]
5628 fn estimate_diff_scan_cost_dirty_rows_no_spans() {
5629 let stats = zero_span_stats();
5630 let (cost, label) =
5631 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5632 assert_eq!(cost, 5 * 80);
5633 assert_eq!(label, "no_spans");
5634 }
5635
5636 #[test]
5637 fn estimate_diff_scan_cost_dirty_rows_overflow_with_span() {
5638 let mut stats = zero_span_stats();
5639 stats.span_coverage_cells = 150;
5640 stats.overflows = 1;
5641 let (cost, label) =
5642 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5643 assert_eq!(cost, 150);
5644 assert_eq!(label, "span_overflow");
5645 }
5646
5647 #[test]
5648 fn estimate_diff_scan_cost_dirty_rows_overflow_no_span() {
5649 let mut stats = zero_span_stats();
5650 stats.overflows = 1;
5651 let (cost, label) =
5652 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5653 assert_eq!(cost, 5 * 80);
5654 assert_eq!(label, "span_overflow");
5655 }
5656
5657 #[test]
5658 fn estimate_diff_scan_cost_tile_skip() {
5659 let stats = zero_span_stats();
5660 let tile = TileDiffStats {
5661 width: 80,
5662 height: 24,
5663 tile_w: 16,
5664 tile_h: 8,
5665 tiles_x: 5,
5666 tiles_y: 3,
5667 total_tiles: 15,
5668 dirty_cells: 10,
5669 dirty_tiles: 2,
5670 dirty_cell_ratio: 0.005,
5671 dirty_tile_ratio: 0.13,
5672 scanned_tiles: 2,
5673 skipped_tiles: 13,
5674 sat_build_cells: 1920,
5675 scan_cells_estimate: 42,
5676 fallback: None,
5677 };
5678 let (cost, label) =
5679 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
5680 assert_eq!(cost, 42);
5681 assert_eq!(label, "tile_skip");
5682 }
5683
5684 #[test]
5685 fn estimate_diff_scan_cost_tile_with_fallback_uses_spans() {
5686 let mut stats = zero_span_stats();
5687 stats.span_coverage_cells = 200;
5688 let tile = TileDiffStats {
5689 width: 80,
5690 height: 24,
5691 tile_w: 16,
5692 tile_h: 8,
5693 tiles_x: 5,
5694 tiles_y: 3,
5695 total_tiles: 15,
5696 dirty_cells: 10,
5697 dirty_tiles: 2,
5698 dirty_cell_ratio: 0.005,
5699 dirty_tile_ratio: 0.13,
5700 scanned_tiles: 2,
5701 skipped_tiles: 13,
5702 sat_build_cells: 1920,
5703 scan_cells_estimate: 42,
5704 fallback: Some(TileDiffFallback::SmallScreen),
5705 };
5706 let (cost, label) =
5707 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
5708 assert_eq!(cost, 200);
5710 assert_eq!(label, "none");
5711 }
5712
5713 #[test]
5718 fn inline_auto_bounds_accessor() {
5719 let mut writer = TerminalWriter::new(
5720 Vec::new(),
5721 ScreenMode::InlineAuto {
5722 min_height: 3,
5723 max_height: 10,
5724 },
5725 UiAnchor::Bottom,
5726 basic_caps(),
5727 );
5728 writer.set_size(80, 24);
5729 let bounds = writer.inline_auto_bounds();
5730 assert_eq!(bounds, Some((3, 10)));
5731 }
5732
5733 #[test]
5734 fn inline_auto_bounds_clamped_to_terminal() {
5735 let mut writer = TerminalWriter::new(
5736 Vec::new(),
5737 ScreenMode::InlineAuto {
5738 min_height: 3,
5739 max_height: 50,
5740 },
5741 UiAnchor::Bottom,
5742 basic_caps(),
5743 );
5744 writer.set_size(80, 20);
5745 let bounds = writer.inline_auto_bounds();
5746 assert_eq!(bounds, Some((3, 20)));
5747 }
5748
5749 #[test]
5750 fn inline_auto_bounds_returns_none_for_non_auto() {
5751 let writer = TerminalWriter::new(
5752 Vec::new(),
5753 ScreenMode::Inline { ui_height: 5 },
5754 UiAnchor::Bottom,
5755 basic_caps(),
5756 );
5757 assert_eq!(writer.inline_auto_bounds(), None);
5758
5759 let writer2 = TerminalWriter::new(
5760 Vec::new(),
5761 ScreenMode::AltScreen,
5762 UiAnchor::Bottom,
5763 basic_caps(),
5764 );
5765 assert_eq!(writer2.inline_auto_bounds(), None);
5766 }
5767
5768 #[test]
5769 fn auto_ui_height_returns_none_for_non_auto() {
5770 let writer = TerminalWriter::new(
5771 Vec::new(),
5772 ScreenMode::Inline { ui_height: 5 },
5773 UiAnchor::Bottom,
5774 basic_caps(),
5775 );
5776 assert_eq!(writer.auto_ui_height(), None);
5777 }
5778
5779 #[test]
5780 fn render_height_hint_altscreen() {
5781 let mut writer = TerminalWriter::new(
5782 Vec::new(),
5783 ScreenMode::AltScreen,
5784 UiAnchor::Bottom,
5785 basic_caps(),
5786 );
5787 writer.set_size(80, 24);
5788 assert_eq!(writer.render_height_hint(), 24);
5789 }
5790
5791 #[test]
5792 fn render_height_hint_inline_fixed() {
5793 let writer = TerminalWriter::new(
5794 Vec::new(),
5795 ScreenMode::Inline { ui_height: 7 },
5796 UiAnchor::Bottom,
5797 basic_caps(),
5798 );
5799 assert_eq!(writer.render_height_hint(), 7);
5800 }
5801
5802 #[test]
5807 fn runtime_diff_config_tile_skip_toggle() {
5808 let config = RuntimeDiffConfig::new().with_tile_skip_enabled(false);
5809 assert!(!config.tile_diff_config.enabled);
5810 }
5811
5812 #[test]
5813 fn runtime_diff_config_dirty_spans_toggle() {
5814 let config = RuntimeDiffConfig::new().with_dirty_spans_enabled(false);
5815 assert!(!config.dirty_span_config.enabled);
5816 }
5817
5818 #[test]
5823 fn present_ui_altscreen_no_cursor_save_restore() {
5824 let mut output = Vec::new();
5825 {
5826 let mut writer = TerminalWriter::new(
5827 &mut output,
5828 ScreenMode::AltScreen,
5829 UiAnchor::Bottom,
5830 basic_caps(),
5831 );
5832 writer.set_size(10, 5);
5833 let buffer = Buffer::new(10, 5);
5834 writer.present_ui(&buffer, None, true).unwrap();
5835 }
5836
5837 let save_count = output
5839 .windows(CURSOR_SAVE.len())
5840 .filter(|w| *w == CURSOR_SAVE)
5841 .count();
5842 assert_eq!(save_count, 0, "AltScreen should not save cursor");
5843 }
5844
5845 #[test]
5846 fn clear_screen_emits_ed2() {
5847 let mut output = Vec::new();
5848 {
5849 let mut writer = TerminalWriter::new(
5850 &mut output,
5851 ScreenMode::AltScreen,
5852 UiAnchor::Bottom,
5853 basic_caps(),
5854 );
5855 writer.clear_screen().unwrap();
5856 }
5857 assert!(
5858 output.windows(4).any(|w| w == b"\x1b[2J"),
5859 "clear_screen should emit ED2 sequence"
5860 );
5861 }
5862
5863 #[test]
5864 fn clear_screen_resets_active_scroll_region_before_clearing() {
5865 let mut output = Vec::new();
5866 {
5867 let mut writer = TerminalWriter::new(
5868 &mut output,
5869 ScreenMode::Inline { ui_height: 5 },
5870 UiAnchor::Bottom,
5871 scroll_region_caps(),
5872 );
5873 writer.set_size(80, 24);
5874
5875 let buffer = Buffer::new(80, 5);
5876 writer.present_ui(&buffer, None, true).unwrap();
5877 assert!(writer.scroll_region_active());
5878
5879 writer.clear_screen().unwrap();
5880 assert!(
5881 !writer.scroll_region_active(),
5882 "clear_screen should leave no active scroll region"
5883 );
5884 }
5885
5886 let reset_idx = output
5887 .windows(b"\x1b[r".len())
5888 .position(|w| w == b"\x1b[r")
5889 .expect("expected scroll-region reset");
5890 let clear_idx = output
5891 .windows(b"\x1b[2J".len())
5892 .position(|w| w == b"\x1b[2J")
5893 .expect("expected full clear");
5894 assert!(
5895 reset_idx < clear_idx,
5896 "clear_screen should reset DECSTBM before full-screen clear"
5897 );
5898 }
5899
5900 #[test]
5901 fn clear_screen_restores_saved_cursor_before_clearing() {
5902 let mut output = Vec::new();
5903 {
5904 let mut writer = TerminalWriter::new(
5905 &mut output,
5906 ScreenMode::Inline { ui_height: 5 },
5907 UiAnchor::Bottom,
5908 basic_caps(),
5909 );
5910 writer.cursor_saved = true;
5911
5912 writer.clear_screen().unwrap();
5913 assert!(
5914 !writer.cursor_saved,
5915 "clear_screen should clear stale saved-cursor state"
5916 );
5917 }
5918
5919 let restore_idx = output
5920 .windows(CURSOR_RESTORE.len())
5921 .position(|w| w == CURSOR_RESTORE)
5922 .expect("expected cursor restore");
5923 let clear_idx = output
5924 .windows(b"\x1b[2J".len())
5925 .position(|w| w == b"\x1b[2J")
5926 .expect("expected full clear");
5927 assert!(
5928 restore_idx < clear_idx,
5929 "clear_screen should restore any saved cursor before clearing"
5930 );
5931 }
5932
5933 #[test]
5934 fn clear_screen_closes_stale_sync_block_before_clearing() {
5935 let mut output = Vec::new();
5936 {
5937 let mut writer = TerminalWriter::new(
5938 &mut output,
5939 ScreenMode::Inline { ui_height: 5 },
5940 UiAnchor::Bottom,
5941 full_caps(),
5942 );
5943 writer.in_sync_block = true;
5944
5945 writer.clear_screen().unwrap();
5946 assert!(
5947 !writer.in_sync_block,
5948 "clear_screen should clear stale sync-block state"
5949 );
5950 }
5951
5952 let sync_end_idx = output
5953 .windows(SYNC_END.len())
5954 .position(|w| w == SYNC_END)
5955 .expect("expected sync end");
5956 let clear_idx = output
5957 .windows(b"\x1b[2J".len())
5958 .position(|w| w == b"\x1b[2J")
5959 .expect("expected full clear");
5960 assert!(
5961 sync_end_idx < clear_idx,
5962 "clear_screen should end any open sync block before clearing"
5963 );
5964 }
5965
5966 #[test]
5967 fn clear_screen_skips_sync_end_in_mux_while_clearing_stale_state() {
5968 let mut output = Vec::new();
5969 {
5970 let mut writer = TerminalWriter::new(
5971 &mut output,
5972 ScreenMode::Inline { ui_height: 5 },
5973 UiAnchor::Bottom,
5974 mux_caps(),
5975 );
5976 writer.in_sync_block = true;
5977
5978 writer.clear_screen().unwrap();
5979 assert!(
5980 !writer.in_sync_block,
5981 "clear_screen should clear stale sync state even when sync output is disabled"
5982 );
5983 }
5984
5985 assert!(
5986 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
5987 "clear_screen must not emit sync_end in mux environments"
5988 );
5989 assert!(
5990 output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"),
5991 "clear_screen should still clear the screen"
5992 );
5993 }
5994
5995 #[test]
5996 fn clear_screen_invalidates_cached_state_even_when_flush_fails() {
5997 let state = Rc::new(RefCell::new(FaultState::default()));
5998 let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
5999 let mut writer = TerminalWriter::new(
6000 writer_backend,
6001 ScreenMode::Inline { ui_height: 5 },
6002 UiAnchor::Bottom,
6003 basic_caps(),
6004 );
6005 writer.cursor_saved = true;
6006 writer.prev_buffer = Some(Buffer::new(4, 2));
6007 writer.last_inline_region = Some(InlineRegion {
6008 start: 19,
6009 height: 5,
6010 });
6011 writer.last_diff_strategy = Some(DiffStrategy::DirtyRows);
6012
6013 let err = writer
6014 .clear_screen()
6015 .expect_err("expected injected flush write failure");
6016 assert_eq!(err.kind(), io::ErrorKind::Other);
6017 assert!(state.borrow().injected_failure_triggered);
6018 assert!(
6019 writer.prev_buffer.is_none(),
6020 "clear_screen should invalidate cached frame state after flush failure"
6021 );
6022 assert!(
6023 writer.last_inline_region.is_none(),
6024 "clear_screen should drop inline region cache after flush failure"
6025 );
6026 assert!(
6027 writer.last_diff_strategy.is_none(),
6028 "clear_screen should reset diff strategy after flush failure"
6029 );
6030 }
6031
6032 #[test]
6033 fn present_ui_retry_after_write_failure_forces_repaint() {
6034 let state = Rc::new(RefCell::new(FaultState::default()));
6035 let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
6036 let mut writer = TerminalWriter::new(
6037 writer_backend,
6038 ScreenMode::AltScreen,
6039 UiAnchor::Bottom,
6040 basic_caps(),
6041 );
6042 writer.set_size(4, 2);
6043
6044 let mut buffer = Buffer::new(4, 2);
6045 buffer.set_raw(0, 0, Cell::from_char('A'));
6046
6047 let err = writer
6048 .present_ui(&buffer, None, true)
6049 .expect_err("first present should hit the injected write fault");
6050 assert_eq!(err.kind(), io::ErrorKind::Other);
6051 assert!(
6052 writer.prev_buffer.is_none(),
6053 "failed present must not advance the diff baseline"
6054 );
6055
6056 writer
6057 .present_ui(&buffer, None, true)
6058 .expect("retry after transient failure should succeed");
6059
6060 let bytes = state.borrow().bytes.clone();
6061 assert!(
6062 bytes.contains(&b'A'),
6063 "retry should emit the missing cell content after a failed present"
6064 );
6065 }
6066
6067 #[test]
6068 fn present_ui_write_failure_with_existing_baseline_invalidates_diff_state() {
6069 let state = Rc::new(RefCell::new(FaultState::default()));
6070 let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
6071 let mut writer = TerminalWriter::new(
6072 writer_backend,
6073 ScreenMode::AltScreen,
6074 UiAnchor::Bottom,
6075 basic_caps(),
6076 );
6077 writer.set_size(4, 2);
6078
6079 let mut previous = Buffer::new(4, 2);
6080 previous.set_raw(0, 0, Cell::from_char('A'));
6081 writer.prev_buffer = Some(previous);
6082 writer.last_inline_region = Some(InlineRegion {
6083 start: 0,
6084 height: 2,
6085 });
6086 writer.last_diff_strategy = Some(DiffStrategy::DirtyRows);
6087 writer.frames_since_full_redraw = 7;
6088
6089 let mut buffer = Buffer::new(4, 2);
6090 buffer.set_raw(0, 0, Cell::from_char('B'));
6091
6092 let err = writer
6093 .present_ui(&buffer, None, true)
6094 .expect_err("present should hit the injected write fault");
6095 assert_eq!(err.kind(), io::ErrorKind::Other);
6096 assert!(
6097 writer.prev_buffer.is_none(),
6098 "failed present with a prior baseline must force the next frame to repaint"
6099 );
6100 assert!(
6101 writer.last_inline_region.is_none(),
6102 "failed present must drop inline-region assumptions"
6103 );
6104 assert_eq!(writer.last_diff_strategy(), None);
6105 assert_eq!(writer.frames_since_full_redraw, 0);
6106
6107 writer
6108 .present_ui(&buffer, None, true)
6109 .expect("retry after transient failure should succeed");
6110 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
6111 }
6112
6113 #[test]
6114 fn set_size_resets_scroll_region_and_spare_buffer() {
6115 let output = Vec::new();
6116 let mut writer = TerminalWriter::new(
6117 output,
6118 ScreenMode::Inline { ui_height: 5 },
6119 UiAnchor::Bottom,
6120 basic_caps(),
6121 );
6122 writer.spare_buffer = Some(Buffer::new(80, 24));
6123 writer.set_size(100, 30);
6124 assert!(writer.spare_buffer.is_none());
6125 }
6126
6127 static GAUGE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
6134
6135 #[test]
6136 fn inline_active_widgets_gauge_increments_for_inline_mode() {
6137 let _lock = GAUGE_TEST_LOCK
6138 .lock()
6139 .unwrap_or_else(|err| err.into_inner());
6140
6141 for _ in 0..64 {
6144 let before = inline_active_widgets();
6145 let writer = TerminalWriter::new(
6146 Vec::new(),
6147 ScreenMode::Inline { ui_height: 5 },
6148 UiAnchor::Bottom,
6149 basic_caps(),
6150 );
6151 let after_create = inline_active_widgets();
6152 drop(writer);
6153 let after_drop = inline_active_widgets();
6154
6155 if after_create == before.saturating_add(1) && after_drop == before {
6156 return;
6157 }
6158 std::thread::yield_now();
6159 }
6160
6161 panic!("failed to observe uncontended inline gauge +1/-1 transition");
6162 }
6163
6164 #[test]
6165 fn inline_active_widgets_gauge_increments_for_inline_auto_mode() {
6166 let _lock = GAUGE_TEST_LOCK
6167 .lock()
6168 .unwrap_or_else(|err| err.into_inner());
6169
6170 for _ in 0..64 {
6171 let before = inline_active_widgets();
6172 let writer = TerminalWriter::new(
6173 Vec::new(),
6174 ScreenMode::InlineAuto {
6175 min_height: 2,
6176 max_height: 10,
6177 },
6178 UiAnchor::Bottom,
6179 basic_caps(),
6180 );
6181 let after_create = inline_active_widgets();
6182 drop(writer);
6183 let after_drop = inline_active_widgets();
6184
6185 if after_create == before.saturating_add(1) && after_drop == before {
6186 return;
6187 }
6188 std::thread::yield_now();
6189 }
6190
6191 panic!("failed to observe uncontended inline-auto gauge +1/-1 transition");
6192 }
6193
6194 #[test]
6195 fn inline_active_widgets_gauge_unchanged_for_altscreen() {
6196 let _lock = GAUGE_TEST_LOCK
6197 .lock()
6198 .unwrap_or_else(|err| err.into_inner());
6199
6200 for _ in 0..64 {
6201 let before = inline_active_widgets();
6202 let writer = TerminalWriter::new(
6203 Vec::new(),
6204 ScreenMode::AltScreen,
6205 UiAnchor::Bottom,
6206 basic_caps(),
6207 );
6208 let after_create = inline_active_widgets();
6209 drop(writer);
6210 let after_drop = inline_active_widgets();
6211
6212 if after_create == before && after_drop == before {
6213 return;
6214 }
6215 std::thread::yield_now();
6216 }
6217
6218 panic!("failed to observe stable altscreen gauge behavior");
6219 }
6220
6221 const ALTSCREEN_ENTER: &[u8] = b"\x1b[?1049h";
6228
6229 const ALTSCREEN_EXIT: &[u8] = b"\x1b[?1049l";
6231
6232 fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
6234 haystack.windows(needle.len()).any(|w| w == needle)
6235 }
6236
6237 #[test]
6238 fn inline_render_never_emits_altscreen_enter() {
6239 let mut output = Vec::new();
6241 {
6242 let mut writer = TerminalWriter::new(
6243 &mut output,
6244 ScreenMode::Inline { ui_height: 5 },
6245 UiAnchor::Bottom,
6246 basic_caps(),
6247 );
6248 writer.set_size(80, 24);
6249
6250 let buffer = Buffer::new(80, 5);
6251 writer.present_ui(&buffer, None, true).unwrap();
6252 writer.write_log("hello\n").unwrap();
6253 writer.present_ui(&buffer, None, true).unwrap();
6255 }
6256
6257 assert!(
6258 !contains_bytes(&output, ALTSCREEN_ENTER),
6259 "inline mode must never emit CSI ?1049h (alternate screen enter)"
6260 );
6261 assert!(
6262 !contains_bytes(&output, ALTSCREEN_EXIT),
6263 "inline mode must never emit CSI ?1049l (alternate screen exit)"
6264 );
6265 }
6266
6267 #[test]
6268 fn inline_auto_render_never_emits_altscreen_enter() {
6269 let mut output = Vec::new();
6270 {
6271 let mut writer = TerminalWriter::new(
6272 &mut output,
6273 ScreenMode::InlineAuto {
6274 min_height: 3,
6275 max_height: 10,
6276 },
6277 UiAnchor::Bottom,
6278 basic_caps(),
6279 );
6280 writer.set_size(80, 24);
6281
6282 let buffer = Buffer::new(80, 5);
6283 writer.present_ui(&buffer, None, true).unwrap();
6284 }
6285
6286 assert!(
6287 !contains_bytes(&output, ALTSCREEN_ENTER),
6288 "InlineAuto mode must never emit CSI ?1049h"
6289 );
6290 }
6291
6292 #[test]
6293 fn inline_scrollback_preserved_after_present() {
6294 let mut output = Vec::new();
6299 {
6300 let mut writer = TerminalWriter::new(
6301 &mut output,
6302 ScreenMode::Inline { ui_height: 5 },
6303 UiAnchor::Bottom,
6304 basic_caps(),
6305 );
6306 writer.set_size(80, 24);
6307
6308 writer.write_log("scrollback line A\n").unwrap();
6309 writer.write_log("scrollback line B\n").unwrap();
6310
6311 let buffer = Buffer::new(80, 5);
6312 writer.present_ui(&buffer, None, true).unwrap();
6313
6314 writer.write_log("scrollback line C\n").unwrap();
6316 }
6317
6318 let text = String::from_utf8_lossy(&output);
6319 assert!(text.contains("scrollback line A"), "first log must survive");
6320 assert!(
6321 text.contains("scrollback line B"),
6322 "second log must survive"
6323 );
6324 assert!(
6325 text.contains("scrollback line C"),
6326 "post-render log must survive"
6327 );
6328
6329 assert!(
6332 contains_bytes(&output, CURSOR_SAVE),
6333 "present_ui must save cursor to protect scrollback"
6334 );
6335 assert!(
6336 contains_bytes(&output, CURSOR_RESTORE),
6337 "present_ui must restore cursor to protect scrollback"
6338 );
6339 }
6340
6341 #[test]
6342 fn multiple_inline_writers_coexist() {
6343 let mut writer_a = TerminalWriter::new(
6347 Vec::new(),
6348 ScreenMode::Inline { ui_height: 3 },
6349 UiAnchor::Bottom,
6350 basic_caps(),
6351 );
6352 writer_a.set_size(40, 12);
6353
6354 let mut writer_b = TerminalWriter::new(
6355 Vec::new(),
6356 ScreenMode::Inline { ui_height: 5 },
6357 UiAnchor::Bottom,
6358 basic_caps(),
6359 );
6360 writer_b.set_size(80, 24);
6361
6362 let buf_a = Buffer::new(40, 3);
6364 let buf_b = Buffer::new(80, 5);
6365 writer_a.present_ui(&buf_a, None, true).unwrap();
6366 writer_b.present_ui(&buf_b, None, true).unwrap();
6367
6368 writer_a.present_ui(&buf_a, None, true).unwrap();
6370 writer_b.present_ui(&buf_b, None, true).unwrap();
6371
6372 drop(writer_a);
6374 drop(writer_b);
6375 }
6376
6377 #[test]
6378 fn multiple_inline_writers_gauge_tracks_both() {
6379 let _lock = GAUGE_TEST_LOCK
6381 .lock()
6382 .unwrap_or_else(|err| err.into_inner());
6383
6384 for _ in 0..64 {
6385 let before = inline_active_widgets();
6386 let writer_a = TerminalWriter::new(
6387 Vec::new(),
6388 ScreenMode::Inline { ui_height: 3 },
6389 UiAnchor::Bottom,
6390 basic_caps(),
6391 );
6392 let after_a = inline_active_widgets();
6393
6394 let writer_b = TerminalWriter::new(
6395 Vec::new(),
6396 ScreenMode::Inline { ui_height: 5 },
6397 UiAnchor::Bottom,
6398 basic_caps(),
6399 );
6400 let after_b = inline_active_widgets();
6401
6402 drop(writer_a);
6403 let after_drop_a = inline_active_widgets();
6404
6405 drop(writer_b);
6406 let after_drop_b = inline_active_widgets();
6407
6408 if after_a == before.saturating_add(1)
6409 && after_b == before.saturating_add(2)
6410 && after_drop_a == before.saturating_add(1)
6411 && after_drop_b == before
6412 {
6413 return;
6414 }
6415 std::thread::yield_now();
6416 }
6417
6418 panic!("failed to observe uncontended two-writer gauge transitions");
6419 }
6420
6421 #[test]
6422 fn resize_during_inline_mode_preserves_scrollback() {
6423 let mut output = Vec::new();
6426 {
6427 let mut writer = TerminalWriter::new(
6428 &mut output,
6429 ScreenMode::Inline { ui_height: 5 },
6430 UiAnchor::Bottom,
6431 basic_caps(),
6432 );
6433 writer.set_size(80, 24);
6434
6435 let buffer = Buffer::new(80, 5);
6436 writer.present_ui(&buffer, None, true).unwrap();
6437
6438 writer.set_size(100, 30);
6440 assert_eq!(writer.ui_start_row(), 25); let buffer2 = Buffer::new(100, 5);
6444 writer.present_ui(&buffer2, None, true).unwrap();
6445
6446 writer.write_log("post-resize log\n").unwrap();
6448 }
6449
6450 let text = String::from_utf8_lossy(&output);
6451 assert!(text.contains("post-resize log"));
6452 assert!(
6453 !contains_bytes(&output, ALTSCREEN_ENTER),
6454 "resize must not trigger alternate screen"
6455 );
6456 }
6457
6458 #[test]
6459 fn resize_shrink_during_inline_mode_clamps_correctly() {
6460 let mut output = Vec::new();
6463 {
6464 let mut writer = TerminalWriter::new(
6465 &mut output,
6466 ScreenMode::Inline { ui_height: 10 },
6467 UiAnchor::Bottom,
6468 basic_caps(),
6469 );
6470 writer.set_size(80, 24);
6471 assert_eq!(writer.ui_start_row(), 14);
6472
6473 writer.set_size(80, 8);
6475 assert_eq!(writer.ui_start_row(), 0); let buffer = Buffer::new(80, 8);
6479 writer.present_ui(&buffer, None, true).unwrap();
6480 }
6481
6482 assert!(
6483 !contains_bytes(&output, ALTSCREEN_ENTER),
6484 "shrunken terminal must not switch to altscreen"
6485 );
6486 }
6487
6488 #[test]
6489 fn inline_render_emits_tracing_span_fields() {
6490 use std::sync::Arc;
6494 use std::sync::atomic::AtomicBool;
6495
6496 struct SpanChecker {
6497 saw_inline_render: Arc<AtomicBool>,
6498 }
6499
6500 impl tracing::Subscriber for SpanChecker {
6501 fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
6502 true
6503 }
6504 fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
6505 if span.metadata().name() == "inline.render" {
6506 self.saw_inline_render
6507 .store(true, std::sync::atomic::Ordering::SeqCst);
6508 }
6509 tracing::span::Id::from_u64(1)
6510 }
6511 fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
6512 fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
6513 }
6514 fn event(&self, _event: &tracing::Event<'_>) {}
6515 fn enter(&self, _span: &tracing::span::Id) {}
6516 fn exit(&self, _span: &tracing::span::Id) {}
6517 }
6518
6519 let saw_it = Arc::new(AtomicBool::new(false));
6520
6521 for _ in 0..8 {
6528 saw_it.store(false, std::sync::atomic::Ordering::SeqCst);
6529 let subscriber = SpanChecker {
6530 saw_inline_render: Arc::clone(&saw_it),
6531 };
6532
6533 let _guard = tracing::subscriber::set_default(subscriber);
6534 tracing::callsite::rebuild_interest_cache();
6535
6536 let mut output = Vec::new();
6537 {
6538 let mut writer = TerminalWriter::new(
6539 &mut output,
6540 ScreenMode::Inline { ui_height: 5 },
6541 UiAnchor::Bottom,
6542 basic_caps(),
6543 );
6544 writer.set_size(80, 24);
6545
6546 let buffer = Buffer::new(80, 5);
6547 writer.present_ui(&buffer, None, true).unwrap();
6548 }
6549
6550 if saw_it.load(std::sync::atomic::Ordering::SeqCst) {
6551 return;
6552 }
6553 }
6554
6555 assert!(
6556 saw_it.load(std::sync::atomic::Ordering::SeqCst),
6557 "present_ui in inline mode must emit an inline.render tracing span"
6558 );
6559 }
6560
6561 #[test]
6562 fn inline_render_no_altscreen_with_scroll_region_strategy() {
6563 let mut output = Vec::new();
6565 {
6566 let mut writer = TerminalWriter::new(
6567 &mut output,
6568 ScreenMode::Inline { ui_height: 5 },
6569 UiAnchor::Bottom,
6570 scroll_region_caps(),
6571 );
6572 writer.set_size(80, 24);
6573
6574 let buffer = Buffer::new(80, 5);
6575 writer.present_ui(&buffer, None, true).unwrap();
6576 writer.present_ui(&buffer, None, true).unwrap();
6577 }
6578
6579 assert!(
6580 !contains_bytes(&output, ALTSCREEN_ENTER),
6581 "scroll region strategy must never emit altscreen enter"
6582 );
6583 }
6584
6585 #[test]
6586 fn inline_render_no_altscreen_with_hybrid_strategy() {
6587 let mut output = Vec::new();
6588 {
6589 let mut writer = TerminalWriter::new(
6590 &mut output,
6591 ScreenMode::Inline { ui_height: 5 },
6592 UiAnchor::Bottom,
6593 hybrid_caps(),
6594 );
6595 writer.set_size(80, 24);
6596
6597 let buffer = Buffer::new(80, 5);
6598 writer.present_ui(&buffer, None, true).unwrap();
6599 }
6600
6601 assert!(
6602 !contains_bytes(&output, ALTSCREEN_ENTER),
6603 "hybrid strategy must never emit altscreen enter"
6604 );
6605 }
6606
6607 #[test]
6608 fn inline_render_no_altscreen_with_mux_strategy() {
6609 let mut output = Vec::new();
6610 {
6611 let mut writer = TerminalWriter::new(
6612 &mut output,
6613 ScreenMode::Inline { ui_height: 5 },
6614 UiAnchor::Bottom,
6615 mux_caps(),
6616 );
6617 writer.set_size(80, 24);
6618
6619 let buffer = Buffer::new(80, 5);
6620 writer.present_ui(&buffer, None, true).unwrap();
6621 }
6622
6623 assert!(
6624 !contains_bytes(&output, ALTSCREEN_ENTER),
6625 "mux (overlay) strategy must never emit altscreen enter"
6626 );
6627 }
6628
6629 #[test]
6630 fn test_altscreen_wide_char_rendering() {
6631 use ftui_render::cell::Cell;
6632 let mut output = Vec::new();
6633 {
6634 let mut writer = TerminalWriter::new(
6635 &mut output,
6636 ScreenMode::AltScreen,
6637 UiAnchor::Bottom,
6638 basic_caps(),
6639 );
6640 writer.set_size(10, 5);
6641
6642 let mut buf = Buffer::new(10, 1);
6643 buf.set_raw(0, 0, Cell::from_char('中'));
6645 buf.set(0, 0, Cell::from_char('中')); let prev = Buffer::new(10, 1);
6654 writer.prev_buffer = Some(prev);
6655
6656 writer.present_ui(&buf, None, true).unwrap();
6657 }
6658
6659 let output_str = String::from_utf8_lossy(&output);
6660
6661 assert!(output_str.contains('中'));
6663
6664 let bytes = output_str.as_bytes();
6667 let pos = bytes.windows(3).position(|w| w == "中".as_bytes());
6668 assert!(pos.is_some());
6669
6670 let after = pos.unwrap() + 3;
6672 if after < bytes.len() {
6673 assert_ne!(
6675 bytes[after], 0x20,
6676 "Wide char continuation clobbered with space"
6677 );
6678 }
6679 }
6680
6681 #[test]
6693 fn noninterference_inline_gauge_balanced_across_lifecycle() {
6694 let _lock = GAUGE_TEST_LOCK
6695 .lock()
6696 .unwrap_or_else(|err| err.into_inner());
6697
6698 for _ in 0..64 {
6699 let before = inline_active_widgets();
6700 let writer = TerminalWriter::new(
6701 Vec::new(),
6702 ScreenMode::Inline { ui_height: 3 },
6703 UiAnchor::Bottom,
6704 basic_caps(),
6705 );
6706 let during = inline_active_widgets();
6707 drop(writer);
6708 let after = inline_active_widgets();
6709
6710 if during == before.saturating_add(1) && after == before {
6711 return;
6712 }
6713 std::thread::yield_now();
6714 }
6715
6716 panic!("failed to observe stable inline lifecycle gauge transition");
6717 }
6718
6719 #[test]
6724 fn noninterference_altscreen_does_not_affect_inline_gauge() {
6725 let _lock = GAUGE_TEST_LOCK
6726 .lock()
6727 .unwrap_or_else(|err| err.into_inner());
6728
6729 let mut observed_stable = false;
6735 for _ in 0..64 {
6736 let before = inline_active_widgets();
6737 drop(TerminalWriter::new(
6738 Vec::new(),
6739 ScreenMode::AltScreen,
6740 UiAnchor::Bottom,
6741 basic_caps(),
6742 ));
6743 let after = inline_active_widgets();
6744
6745 if after == before {
6746 observed_stable = true;
6747 break;
6748 }
6749 std::thread::yield_now();
6750 }
6751
6752 assert!(
6753 observed_stable,
6754 "failed to observe stable altscreen lifecycle gauge transition"
6755 );
6756
6757 assert!(
6759 !matches!(
6760 ScreenMode::AltScreen,
6761 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
6762 ),
6763 "AltScreen must not match inline patterns"
6764 );
6765 }
6766
6767 #[test]
6769 fn noninterference_inline_auto_gauge_balanced() {
6770 let _lock = GAUGE_TEST_LOCK
6771 .lock()
6772 .unwrap_or_else(|err| err.into_inner());
6773
6774 for _ in 0..64 {
6775 let before = inline_active_widgets();
6776 let writer = TerminalWriter::new(
6777 Vec::new(),
6778 ScreenMode::InlineAuto {
6779 min_height: 3,
6780 max_height: 10,
6781 },
6782 UiAnchor::Bottom,
6783 basic_caps(),
6784 );
6785 let during = inline_active_widgets();
6786 drop(writer);
6787 let after = inline_active_widgets();
6788
6789 if during == before.saturating_add(1) && after == before {
6790 return;
6791 }
6792 std::thread::yield_now();
6793 }
6794
6795 panic!("failed to observe stable inline-auto lifecycle gauge transition");
6796 }
6797
6798 #[test]
6801 fn noninterference_into_inner_performs_cleanup() {
6802 let _lock = GAUGE_TEST_LOCK
6803 .lock()
6804 .unwrap_or_else(|err| err.into_inner());
6805
6806 let cursor_show = b"\x1b[?25h";
6807 for _ in 0..64 {
6808 let before_gauge = inline_active_widgets();
6809
6810 let mut writer = TerminalWriter::new(
6811 Vec::new(),
6812 ScreenMode::Inline { ui_height: 5 },
6813 UiAnchor::Bottom,
6814 basic_caps(),
6815 );
6816 writer.set_size(80, 24);
6817
6818 let during_gauge = inline_active_widgets();
6819 let output = writer.into_inner().expect("should return writer");
6820 let after_gauge = inline_active_widgets();
6821
6822 if during_gauge == before_gauge.saturating_add(1) && after_gauge == before_gauge {
6823 assert!(
6824 output.windows(cursor_show.len()).any(|w| w == cursor_show),
6825 "into_inner must emit cursor show during cleanup"
6826 );
6827 return;
6828 }
6829 std::thread::yield_now();
6830 }
6831
6832 panic!("failed to observe stable into_inner gauge transition");
6833 }
6834
6835 #[test]
6838 fn noninterference_inline_cleanup_restores_cursor_after_present() {
6839 let mut output = Vec::new();
6840 {
6841 let mut writer = TerminalWriter::new(
6842 &mut output,
6843 ScreenMode::Inline { ui_height: 5 },
6844 UiAnchor::Bottom,
6845 basic_caps(),
6846 );
6847 writer.set_size(80, 24);
6848
6849 let buffer = Buffer::new(80, 5);
6850 writer.present_ui(&buffer, None, true).unwrap();
6851
6852 }
6854
6855 let saves = output
6857 .windows(CURSOR_SAVE.len())
6858 .filter(|w| *w == CURSOR_SAVE)
6859 .count();
6860 let restores = output
6861 .windows(CURSOR_RESTORE.len())
6862 .filter(|w| *w == CURSOR_RESTORE)
6863 .count();
6864
6865 assert!(saves > 0, "present must save cursor");
6866 assert!(
6867 restores >= saves,
6868 "cleanup must ensure all cursor saves are restored: {saves} saves, {restores} restores"
6869 );
6870 }
6871
6872 #[test]
6875 fn noninterference_altscreen_cleanup_minimal() {
6876 let mut output = Vec::new();
6877 {
6878 let mut writer = TerminalWriter::new(
6879 &mut output,
6880 ScreenMode::AltScreen,
6881 UiAnchor::Bottom,
6882 basic_caps(),
6883 );
6884 writer.set_size(80, 24);
6885
6886 let mut buffer = Buffer::new(80, 24);
6887 buffer.set_raw(0, 0, Cell::from_char('A'));
6888 writer.present_ui(&buffer, None, true).unwrap();
6889 }
6890
6891 let cursor_show = b"\x1b[?25h";
6893 assert!(
6894 output.windows(cursor_show.len()).any(|w| w == cursor_show),
6895 "AltScreen cleanup must show cursor"
6896 );
6897
6898 }
6902
6903 #[test]
6907 fn noninterference_rapid_present_log_interleave() {
6908 let mut output = Vec::new();
6909 {
6910 let mut writer = TerminalWriter::new(
6911 &mut output,
6912 ScreenMode::Inline { ui_height: 3 },
6913 UiAnchor::Bottom,
6914 basic_caps(),
6915 );
6916 writer.set_size(40, 12);
6917
6918 for i in 0..10 {
6919 let mut buffer = Buffer::new(40, 3);
6920 buffer.set_raw(0, 0, Cell::from_char(char::from(b'A' + (i % 26))));
6921 writer.present_ui(&buffer, None, true).unwrap();
6922 writer.write_log(&format!("log-{i}")).unwrap();
6923 }
6924 }
6925
6926 let cursor_show = b"\x1b[?25h";
6928 assert!(
6929 output.windows(cursor_show.len()).any(|w| w == cursor_show),
6930 "cleanup must complete after rapid interleaving"
6931 );
6932
6933 let output_len = output.len();
6936 if output_len > 1 {
6937 let last_esc = output.iter().rposition(|&b| b == 0x1b);
6938 if let Some(pos) = last_esc {
6939 if output_len - pos < 10 {
6941 assert!(
6943 output_len - pos >= 3,
6944 "truncated escape sequence at end of output"
6945 );
6946 }
6947 }
6948 }
6949 }
6950
6951 #[test]
6954 fn noninterference_resize_between_presents_clears_diff_state() {
6955 let mut output = Vec::new();
6956 {
6957 let mut writer = TerminalWriter::new(
6958 &mut output,
6959 ScreenMode::Inline { ui_height: 5 },
6960 UiAnchor::Bottom,
6961 basic_caps(),
6962 );
6963 writer.set_size(80, 24);
6964
6965 let buffer1 = Buffer::new(80, 5);
6967 writer.present_ui(&buffer1, None, true).unwrap();
6968
6969 writer.set_size(120, 30);
6971 assert!(
6972 writer.prev_buffer.is_none(),
6973 "set_size must clear prev_buffer to invalidate diff"
6974 );
6975
6976 let buffer2 = Buffer::new(120, 5);
6978 writer.present_ui(&buffer2, None, true).unwrap();
6979 }
6980
6981 let cursor_show = b"\x1b[?25h";
6983 assert!(
6984 output.windows(cursor_show.len()).any(|w| w == cursor_show),
6985 "output must be valid after resize"
6986 );
6987 }
6988
6989 #[test]
6993 fn noninterference_sequential_writers_clean_handoff() {
6994 let mut output = Vec::new();
6995
6996 {
6998 let mut writer = TerminalWriter::new(
6999 &mut output,
7000 ScreenMode::Inline { ui_height: 3 },
7001 UiAnchor::Bottom,
7002 basic_caps(),
7003 );
7004 writer.set_size(40, 12);
7005 let buffer = Buffer::new(40, 3);
7006 writer.present_ui(&buffer, None, true).unwrap();
7007 }
7009
7010 let inline_end = output.len();
7011
7012 {
7014 let mut writer = TerminalWriter::new(
7015 &mut output,
7016 ScreenMode::AltScreen,
7017 UiAnchor::Bottom,
7018 basic_caps(),
7019 );
7020 writer.set_size(40, 12);
7021 let mut buffer = Buffer::new(40, 12);
7022 buffer.set_raw(0, 0, Cell::from_char('Z'));
7023 writer.present_ui(&buffer, None, true).unwrap();
7024 }
7026
7027 let cursor_show = b"\x1b[?25h";
7029 let first_show = output[..inline_end]
7030 .windows(cursor_show.len())
7031 .any(|w| w == cursor_show);
7032 let second_show = output[inline_end..]
7033 .windows(cursor_show.len())
7034 .any(|w| w == cursor_show);
7035
7036 assert!(first_show, "first writer must show cursor on cleanup");
7037 assert!(second_show, "second writer must show cursor on cleanup");
7038 }
7039
7040 #[test]
7043 fn noninterference_present_ui_owned_matches_present_ui() {
7044 let mut output_borrowed = Vec::new();
7045 let mut output_owned = Vec::new();
7046
7047 let mut buffer = Buffer::new(20, 5);
7048 buffer.set_raw(0, 0, Cell::from_char('H'));
7049 buffer.set_raw(1, 0, Cell::from_char('i'));
7050
7051 {
7053 let mut writer = TerminalWriter::new(
7054 &mut output_borrowed,
7055 ScreenMode::Inline { ui_height: 5 },
7056 UiAnchor::Bottom,
7057 basic_caps(),
7058 );
7059 writer.set_size(20, 10);
7060 writer.present_ui(&buffer, None, true).unwrap();
7061 }
7062
7063 {
7065 let mut writer = TerminalWriter::new(
7066 &mut output_owned,
7067 ScreenMode::Inline { ui_height: 5 },
7068 UiAnchor::Bottom,
7069 basic_caps(),
7070 );
7071 writer.set_size(20, 10);
7072 writer.present_ui_owned(buffer, None, true).unwrap();
7073 }
7074
7075 assert!(
7077 output_borrowed
7078 .windows(CURSOR_SAVE.len())
7079 .any(|w| w == CURSOR_SAVE),
7080 "borrowed path must save cursor"
7081 );
7082 assert!(
7083 output_owned
7084 .windows(CURSOR_SAVE.len())
7085 .any(|w| w == CURSOR_SAVE),
7086 "owned path must save cursor"
7087 );
7088
7089 assert!(
7091 output_borrowed.windows(1).any(|w| w == b"H"),
7092 "borrowed path must render content"
7093 );
7094 assert!(
7095 output_owned.windows(1).any(|w| w == b"H"),
7096 "owned path must render content"
7097 );
7098 }
7099
7100 #[test]
7103 fn noninterference_write_log_mode_behavior_preserved() {
7104 let mut inline_output = Vec::new();
7106 {
7107 let mut writer = TerminalWriter::new(
7108 &mut inline_output,
7109 ScreenMode::Inline { ui_height: 3 },
7110 UiAnchor::Bottom,
7111 basic_caps(),
7112 );
7113 writer.set_size(40, 12);
7114 writer.write_log("hello").unwrap();
7115 }
7116 assert!(
7117 inline_output.windows(5).any(|w| w == b"hello"),
7118 "inline write_log must produce output"
7119 );
7120
7121 let mut alt_output = Vec::new();
7123 {
7124 let mut writer = TerminalWriter::new(
7125 &mut alt_output,
7126 ScreenMode::AltScreen,
7127 UiAnchor::Bottom,
7128 basic_caps(),
7129 );
7130 writer.set_size(40, 12);
7131 writer.write_log("hello").unwrap();
7132 }
7133 let has_hello = alt_output.windows(5).any(|w| w == b"hello");
7136 assert!(!has_hello, "AltScreen write_log must be silent (no-op)");
7137 }
7138
7139 #[test]
7142 fn noninterference_sync_output_balanced_across_multiple_presents() {
7143 let mut output = Vec::new();
7144 {
7145 let mut writer = TerminalWriter::new(
7146 &mut output,
7147 ScreenMode::Inline { ui_height: 3 },
7148 UiAnchor::Bottom,
7149 full_caps(),
7150 );
7151 writer.set_size(20, 10);
7152
7153 for _ in 0..5 {
7154 let buffer = Buffer::new(20, 3);
7155 writer.present_ui(&buffer, None, true).unwrap();
7156 }
7157 }
7159
7160 let begins = output
7161 .windows(SYNC_BEGIN.len())
7162 .filter(|w| *w == SYNC_BEGIN)
7163 .count();
7164 let ends = output
7165 .windows(SYNC_END.len())
7166 .filter(|w| *w == SYNC_END)
7167 .count();
7168
7169 assert!(begins > 0, "sync-capable writer must emit SYNC_BEGIN");
7170 assert_eq!(
7171 begins, ends,
7172 "sync blocks must be balanced: {begins} begins, {ends} ends"
7173 );
7174 }
7175
7176 #[test]
7179 fn noninterference_inline_auto_height_clamped_without_panic() {
7180 let mut output = Vec::new();
7181 {
7182 let mut writer = TerminalWriter::new(
7183 &mut output,
7184 ScreenMode::InlineAuto {
7185 min_height: 3,
7186 max_height: 100,
7187 },
7188 UiAnchor::Bottom,
7189 basic_caps(),
7190 );
7191 writer.set_size(80, 10);
7193
7194 let effective = writer.effective_ui_height();
7196 assert!(
7197 effective <= 10,
7198 "InlineAuto effective_ui_height must clamp to terminal height, got {effective}"
7199 );
7200
7201 let buffer = Buffer::new(80, effective);
7202 writer.present_ui(&buffer, None, true).unwrap();
7203 }
7204 }
7205
7206 #[test]
7209 fn noninterference_inline_oversized_height_no_panic() {
7210 let mut output = Vec::new();
7211 {
7212 let mut writer = TerminalWriter::new(
7213 &mut output,
7214 ScreenMode::Inline { ui_height: 100 },
7215 UiAnchor::Bottom,
7216 basic_caps(),
7217 );
7218 writer.set_size(80, 10);
7219
7220 let buffer = Buffer::new(80, 10);
7223 let result = writer.present_ui(&buffer, None, true);
7225 assert!(
7226 result.is_ok(),
7227 "present_ui must not panic with oversized ui_height"
7228 );
7229 }
7230 }
7231
7232 fn present_sequence(certified: bool) -> (Vec<u8>, Vec<String>) {
7235 let config = RuntimeDiffConfig {
7236 certified_skips: certified,
7237 bayesian_enabled: false,
7239 ..RuntimeDiffConfig::default()
7240 };
7241 let mut writer = TerminalWriter::with_diff_config(
7242 Vec::new(),
7243 ScreenMode::Inline { ui_height: 4 },
7244 UiAnchor::Bottom,
7245 full_caps(),
7246 config,
7247 );
7248 writer.set_size(24, 12);
7249
7250 let mut certificates = Vec::new();
7251 for frame in 0..4u16 {
7252 let mut buffer = Buffer::new(24, 4);
7253 if frame > 0 {
7254 buffer.clear_dirty();
7255 }
7256 match frame {
7257 0 => {}
7259 1 => {}
7261 2 => {
7263 buffer.set(3, 1, ftui_render::cell::Cell::from_char('X'));
7264 buffer.set(9, 3, ftui_render::cell::Cell::from_char('Y'));
7265 }
7266 _ => {
7268 buffer.set(3, 1, ftui_render::cell::Cell::from_char('Z'));
7269 }
7270 }
7271 writer
7272 .present_ui_owned(buffer, None, false)
7273 .expect("present");
7274 certificates.push(
7275 writer
7276 .last_render_certificate()
7277 .map(|c| format!("{}:{:?}", c.level.label(), c.causes.clone()))
7278 .unwrap_or_else(|| "none".to_string()),
7279 );
7280 }
7281 (writer.into_inner().expect("writer sink"), certificates)
7282 }
7283
7284 #[test]
7285 fn certified_path_is_byte_identical_to_legacy_dirty_path() {
7286 let (certified_bytes, _) = present_sequence(true);
7287 let (legacy_bytes, _) = present_sequence(false);
7288 assert_eq!(
7289 certified_bytes, legacy_bytes,
7290 "certified diff path changed visible output"
7291 );
7292 }
7293
7294 #[test]
7295 fn zero_dirty_frame_earns_a_skip_all_certificate() {
7296 let (_, certificates) = present_sequence(true);
7297 assert!(
7299 certificates[1].starts_with("skip-all"),
7300 "frame 1 certificate was {}",
7301 certificates[1]
7302 );
7303 assert!(
7305 certificates[2].starts_with("narrow-to-dirty"),
7306 "frame 2 certificate was {}",
7307 certificates[2]
7308 );
7309 }
7310
7311 #[test]
7312 fn certificates_name_full_work_causes() {
7313 let (_, certificates) = present_sequence(true);
7314 assert!(
7316 certificates[0].contains("no-previous-frame"),
7317 "frame 0 certificate was {}",
7318 certificates[0]
7319 );
7320 }
7321
7322 #[test]
7323 fn legacy_path_records_no_certificate() {
7324 let (_, certificates) = present_sequence(false);
7325 assert_eq!(certificates[2], "none");
7328 assert_eq!(certificates[3], "none");
7329 }
7330}