1#![forbid(unsafe_code)]
2
3use std::io::{self, BufWriter, Write};
51use std::sync::atomic::{AtomicU32, Ordering};
52use web_time::Instant;
53
54static INLINE_ACTIVE_WIDGETS: AtomicU32 = AtomicU32::new(0);
59
60pub fn inline_active_widgets() -> u32 {
62 INLINE_ACTIVE_WIDGETS.load(Ordering::Relaxed)
63}
64
65use crate::evidence_sink::EvidenceSink;
66use crate::evidence_telemetry::{DiffDecisionSnapshot, set_diff_snapshot};
67use crate::render_trace::{
68 RenderTraceFrame, RenderTraceRecorder, build_diff_runs_payload, build_full_buffer_payload,
69};
70use ftui_core::inline_mode::InlineStrategy;
71use ftui_core::terminal_capabilities::TerminalCapabilities;
72use ftui_render::buffer::{Buffer, DirtySpanConfig, DirtySpanStats};
73use ftui_render::counting_writer::CountingWriter;
74use ftui_render::diff::{BufferDiff, ChangeRun, TileDiffConfig, TileDiffFallback, TileDiffStats};
75use ftui_render::diff_strategy::{DiffStrategy, DiffStrategyConfig, DiffStrategySelector};
76use ftui_render::grapheme_pool::GraphemePool;
77use ftui_render::link_registry::LinkRegistry;
78use ftui_render::presenter::Presenter;
79use ftui_render::sanitize::sanitize;
80use tracing::{debug_span, info, info_span, trace, warn};
81
82#[allow(dead_code)] const BUFFER_CAPACITY: usize = 64 * 1024;
85
86const CURSOR_SAVE: &[u8] = b"\x1b7";
88
89const CURSOR_RESTORE: &[u8] = b"\x1b8";
91
92const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
94
95const SYNC_END: &[u8] = b"\x1b[?2026l";
97
98const MAX_SAFE_HYPERLINK_URL_BYTES: usize = 4096;
100
101const ERASE_LINE: &[u8] = b"\x1b[2K";
103
104#[allow(dead_code)] const FULL_REDRAW_PROBE_INTERVAL: u64 = 60;
107
108fn default_diff_run_id() -> String {
113 format!("diff-{}", std::process::id())
114}
115
116fn diff_strategy_str(strategy: DiffStrategy) -> &'static str {
117 match strategy {
118 DiffStrategy::Full => "full",
119 DiffStrategy::DirtyRows => "dirty",
120 DiffStrategy::FullRedraw => "redraw",
121 }
122}
123
124fn inline_strategy_str(strategy: InlineStrategy) -> &'static str {
125 match strategy {
126 InlineStrategy::ScrollRegion => "scroll_region",
127 InlineStrategy::OverlayRedraw => "overlay_redraw",
128 InlineStrategy::Hybrid => "hybrid",
129 }
130}
131
132fn ui_anchor_str(anchor: UiAnchor) -> &'static str {
133 match anchor {
134 UiAnchor::Bottom => "bottom",
135 UiAnchor::Top => "top",
136 }
137}
138
139#[allow(dead_code)]
140#[inline]
141fn json_escape(value: &str) -> String {
142 let mut out = String::with_capacity(value.len());
143 for ch in value.chars() {
144 match ch {
145 '"' => out.push_str("\\\""),
146 '\\' => out.push_str("\\\\"),
147 '\n' => out.push_str("\\n"),
148 '\r' => out.push_str("\\r"),
149 '\t' => out.push_str("\\t"),
150 c if c.is_control() => {
151 use std::fmt::Write as _;
152 let _ = write!(out, "\\u{:04X}", c as u32);
153 }
154 _ => out.push(ch),
155 }
156 }
157 out
158}
159
160#[allow(dead_code)]
161fn estimate_diff_scan_cost(
162 strategy: DiffStrategy,
163 dirty_rows: usize,
164 width: usize,
165 height: usize,
166 span_stats: &DirtySpanStats,
167 tile_stats: Option<TileDiffStats>,
168) -> (usize, &'static str) {
169 match strategy {
170 DiffStrategy::Full => (width.saturating_mul(height), "full_strategy"),
171 DiffStrategy::FullRedraw => (0, "full_redraw"),
172 DiffStrategy::DirtyRows => {
173 if dirty_rows == 0 {
174 return (0, "no_dirty_rows");
175 }
176 if let Some(tile_stats) = tile_stats
177 && tile_stats.fallback.is_none()
178 {
179 return (tile_stats.scan_cells_estimate, "tile_skip");
180 }
181 let span_cells = span_stats.span_coverage_cells;
182 if span_stats.overflows > 0 {
183 let estimate = if span_cells > 0 {
184 span_cells
185 } else {
186 dirty_rows.saturating_mul(width)
187 };
188 return (estimate, "span_overflow");
189 }
190 if span_cells > 0 {
191 (span_cells, "none")
192 } else {
193 (dirty_rows.saturating_mul(width), "no_spans")
194 }
195 }
196 }
197}
198
199fn sanitize_auto_bounds(min_height: u16, max_height: u16) -> (u16, u16) {
200 let min = min_height.max(1);
201 let max = max_height.max(min);
202 (min, max)
203}
204
205#[inline]
206fn is_safe_hyperlink_url(url: &str) -> bool {
207 url.len() <= MAX_SAFE_HYPERLINK_URL_BYTES && !url.chars().any(char::is_control)
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
212pub enum ScreenMode {
213 Inline {
215 ui_height: u16,
217 },
218 InlineAuto {
222 min_height: u16,
224 max_height: u16,
226 },
227 #[default]
229 AltScreen,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub enum UiAnchor {
235 #[default]
237 Bottom,
238 Top,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243struct InlineRegion {
244 start: u16,
245 height: u16,
246}
247
248struct DiffDecision {
249 #[allow(dead_code)] strategy: DiffStrategy,
251 has_diff: bool,
252}
253
254#[derive(Debug, Clone, Copy)]
255#[allow(dead_code)]
256struct EmitStats {
257 diff_cells: usize,
258 diff_runs: usize,
259}
260
261#[derive(Debug, Clone, Copy)]
262#[allow(dead_code)]
263struct FrameEmitStats {
264 diff_strategy: DiffStrategy,
265 diff_cells: usize,
266 diff_runs: usize,
267 ui_height: u16,
268}
269
270#[derive(Debug, Clone, Copy)]
271#[allow(dead_code)]
272pub struct PresentTimings {
273 pub diff_us: u64,
274}
275
276#[derive(Debug, Clone)]
305pub struct RuntimeDiffConfig {
306 pub bayesian_enabled: bool,
316
317 pub dirty_rows_enabled: bool,
324
325 pub dirty_span_config: DirtySpanConfig,
329
330 pub tile_diff_config: TileDiffConfig,
334
335 pub reset_on_resize: bool,
342
343 pub reset_on_invalidation: bool,
350
351 pub strategy_config: DiffStrategyConfig,
355}
356
357impl Default for RuntimeDiffConfig {
358 fn default() -> Self {
359 Self {
360 bayesian_enabled: true,
361 dirty_rows_enabled: true,
362 dirty_span_config: DirtySpanConfig::default(),
363 tile_diff_config: TileDiffConfig::default(),
364 reset_on_resize: true,
365 reset_on_invalidation: true,
366 strategy_config: DiffStrategyConfig::default(),
367 }
368 }
369}
370
371impl RuntimeDiffConfig {
372 pub fn new() -> Self {
374 Self::default()
375 }
376
377 #[must_use]
379 pub fn with_bayesian_enabled(mut self, enabled: bool) -> Self {
380 self.bayesian_enabled = enabled;
381 self
382 }
383
384 #[must_use]
386 pub fn with_dirty_rows_enabled(mut self, enabled: bool) -> Self {
387 self.dirty_rows_enabled = enabled;
388 self
389 }
390
391 #[must_use]
393 pub fn with_dirty_spans_enabled(mut self, enabled: bool) -> Self {
394 self.dirty_span_config = self.dirty_span_config.with_enabled(enabled);
395 self
396 }
397
398 #[must_use]
400 pub fn with_dirty_span_config(mut self, config: DirtySpanConfig) -> Self {
401 self.dirty_span_config = config;
402 self
403 }
404
405 #[must_use]
407 pub fn with_tile_skip_enabled(mut self, enabled: bool) -> Self {
408 self.tile_diff_config = self.tile_diff_config.with_enabled(enabled);
409 self
410 }
411
412 #[must_use]
414 pub fn with_tile_diff_config(mut self, config: TileDiffConfig) -> Self {
415 self.tile_diff_config = config;
416 self
417 }
418
419 #[must_use]
421 pub fn with_reset_on_resize(mut self, enabled: bool) -> Self {
422 self.reset_on_resize = enabled;
423 self
424 }
425
426 #[must_use]
428 pub fn with_reset_on_invalidation(mut self, enabled: bool) -> Self {
429 self.reset_on_invalidation = enabled;
430 self
431 }
432
433 #[must_use]
435 pub fn with_strategy_config(mut self, config: DiffStrategyConfig) -> Self {
436 self.strategy_config = config;
437 self
438 }
439}
440
441pub struct TerminalWriter<W: Write> {
446 presenter: Option<Presenter<W>>,
450 screen_mode: ScreenMode,
452 auto_ui_height: Option<u16>,
454 ui_anchor: UiAnchor,
456 prev_buffer: Option<Buffer>,
458 spare_buffer: Option<Buffer>,
460 clone_buf: Option<Buffer>,
463 pool: GraphemePool,
465 links: LinkRegistry,
467 capabilities: TerminalCapabilities,
469 term_width: u16,
471 term_height: u16,
473 in_sync_block: bool,
475 cursor_saved: bool,
477 cursor_visible: bool,
479 inline_strategy: InlineStrategy,
481 scroll_region_active: bool,
483 last_inline_region: Option<InlineRegion>,
485 diff_strategy: DiffStrategySelector,
487 diff_scratch: BufferDiff,
489 runs_buf: Vec<ChangeRun>,
491 full_redraw_probe: u64,
493 #[allow(dead_code)] diff_config: RuntimeDiffConfig,
496 evidence_sink: Option<EvidenceSink>,
498 #[allow(dead_code)]
500 diff_evidence_run_id: String,
501 #[allow(dead_code)]
503 diff_evidence_idx: u64,
504 last_diff_strategy: Option<DiffStrategy>,
506 render_trace: Option<RenderTraceRecorder>,
508 timing_enabled: bool,
510 last_present_timings: Option<PresentTimings>,
512}
513
514impl<W: Write> TerminalWriter<W> {
515 pub fn new(
524 writer: W,
525 screen_mode: ScreenMode,
526 ui_anchor: UiAnchor,
527 capabilities: TerminalCapabilities,
528 ) -> Self {
529 Self::with_diff_config(
530 writer,
531 screen_mode,
532 ui_anchor,
533 capabilities,
534 RuntimeDiffConfig::default(),
535 )
536 }
537
538 pub fn with_diff_config(
567 writer: W,
568 screen_mode: ScreenMode,
569 ui_anchor: UiAnchor,
570 capabilities: TerminalCapabilities,
571 diff_config: RuntimeDiffConfig,
572 ) -> Self {
573 let inline_strategy = InlineStrategy::select(&capabilities);
574 let auto_ui_height = None;
575 let diff_strategy = DiffStrategySelector::new(diff_config.strategy_config.clone());
576
577 match screen_mode {
579 ScreenMode::Inline { ui_height } => {
580 info!(
581 inline_height = ui_height,
582 render_mode = %inline_strategy_str(inline_strategy),
583 "inline mode activated"
584 );
585 }
586 ScreenMode::InlineAuto {
587 min_height,
588 max_height,
589 } => {
590 info!(
591 min_height,
592 max_height,
593 render_mode = %inline_strategy_str(inline_strategy),
594 "inline auto mode activated"
595 );
596 }
597 ScreenMode::AltScreen => {}
598 }
599
600 let is_inline = matches!(
602 screen_mode,
603 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
604 );
605 if is_inline {
606 INLINE_ACTIVE_WIDGETS.fetch_add(1, Ordering::Relaxed);
607 }
608
609 let mut diff_scratch = BufferDiff::new();
610 diff_scratch
611 .tile_config_mut()
612 .clone_from(&diff_config.tile_diff_config);
613
614 let presenter = Presenter::new(writer, capabilities);
615
616 Self {
617 presenter: Some(presenter),
618 screen_mode,
619 auto_ui_height,
620 ui_anchor,
621 prev_buffer: None,
622 spare_buffer: None,
623 clone_buf: None,
624 pool: GraphemePool::new(),
625 links: LinkRegistry::new(),
626 capabilities,
627 term_width: 80,
628 term_height: 24,
629 in_sync_block: false,
630 cursor_saved: false,
631 cursor_visible: true,
632 inline_strategy,
633 scroll_region_active: false,
634 last_inline_region: None,
635 diff_strategy,
636 diff_scratch,
637 runs_buf: Vec::new(),
638 full_redraw_probe: 0,
639 diff_config,
640 evidence_sink: None,
641 diff_evidence_run_id: default_diff_run_id(),
642 diff_evidence_idx: 0,
643 last_diff_strategy: None,
644 render_trace: None,
645 timing_enabled: false,
646 last_present_timings: None,
647 }
648 }
649
650 #[inline]
656 fn writer(&mut self) -> &mut CountingWriter<BufWriter<W>> {
657 self.presenter_mut().counting_writer_mut()
658 }
659
660 #[inline]
666 fn presenter_mut(&mut self) -> &mut Presenter<W> {
667 self.presenter
668 .as_mut()
669 .expect("presenter has been consumed")
670 }
671
672 fn reset_diff_strategy(&mut self) {
674 if self.diff_config.reset_on_invalidation {
675 self.diff_strategy.reset();
676 }
677 self.full_redraw_probe = 0;
678 self.last_diff_strategy = None;
679 }
680
681 #[allow(dead_code)] fn reset_diff_on_resize(&mut self) {
684 if self.diff_config.reset_on_resize {
685 self.diff_strategy.reset();
686 }
687 self.full_redraw_probe = 0;
688 self.last_diff_strategy = None;
689 }
690
691 pub fn diff_config(&self) -> &RuntimeDiffConfig {
693 &self.diff_config
694 }
695
696 pub(crate) fn set_timing_enabled(&mut self, enabled: bool) {
698 self.timing_enabled = enabled;
699 if !enabled {
700 self.last_present_timings = None;
701 }
702 }
703
704 pub(crate) fn take_last_present_timings(&mut self) -> Option<PresentTimings> {
706 self.last_present_timings.take()
707 }
708
709 #[must_use]
711 pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
712 self.evidence_sink = Some(sink);
713 self
714 }
715
716 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
718 self.evidence_sink = sink;
719 }
720
721 #[must_use]
723 pub fn with_render_trace(mut self, recorder: RenderTraceRecorder) -> Self {
724 self.render_trace = Some(recorder);
725 self
726 }
727
728 pub fn set_render_trace(&mut self, recorder: Option<RenderTraceRecorder>) {
730 self.render_trace = recorder;
731 }
732
733 pub fn diff_strategy_mut(&mut self) -> &mut DiffStrategySelector {
737 &mut self.diff_strategy
738 }
739
740 pub fn diff_strategy(&self) -> &DiffStrategySelector {
742 &self.diff_strategy
743 }
744
745 pub fn last_diff_strategy(&self) -> Option<DiffStrategy> {
747 self.last_diff_strategy
748 }
749
750 pub fn set_size(&mut self, width: u16, height: u16) {
754 self.term_width = width;
755 self.term_height = height;
756 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. }) {
757 self.auto_ui_height = None;
758 }
759 self.prev_buffer = None;
761 self.spare_buffer = None;
762 self.clone_buf = None;
763 self.reset_diff_on_resize();
764 if self.scroll_region_active {
766 let _ = self.deactivate_scroll_region();
767 }
768 }
769
770 pub fn take_render_buffer(&mut self, width: u16, height: u16) -> Buffer {
774 if let Some(mut buffer) = self.spare_buffer.take()
775 && buffer.width() == width
776 && buffer.height() == height
777 {
778 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
779 buffer.reset_for_frame();
780 return buffer;
781 }
782
783 let mut buffer = Buffer::new(width, height);
784 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
785 buffer
786 }
787
788 #[inline]
790 pub fn width(&self) -> u16 {
791 self.term_width
792 }
793
794 #[inline]
796 pub fn height(&self) -> u16 {
797 self.term_height
798 }
799
800 #[inline]
802 pub fn screen_mode(&self) -> ScreenMode {
803 self.screen_mode
804 }
805
806 pub fn render_height_hint(&self) -> u16 {
811 match self.screen_mode {
812 ScreenMode::Inline { ui_height } => ui_height,
813 ScreenMode::InlineAuto {
814 min_height,
815 max_height,
816 } => {
817 let (min, max) = sanitize_auto_bounds(min_height, max_height);
818 let max = max.min(self.term_height);
819 let min = min.min(max);
820 if let Some(current) = self.auto_ui_height {
821 current.clamp(min, max).min(self.term_height).max(min)
822 } else {
823 max.max(min)
824 }
825 }
826 ScreenMode::AltScreen => self.term_height,
827 }
828 }
829
830 pub fn inline_auto_bounds(&self) -> Option<(u16, u16)> {
832 match self.screen_mode {
833 ScreenMode::InlineAuto {
834 min_height,
835 max_height,
836 } => {
837 let (min, max) = sanitize_auto_bounds(min_height, max_height);
838 Some((min.min(self.term_height), max.min(self.term_height)))
839 }
840 _ => None,
841 }
842 }
843
844 pub fn auto_ui_height(&self) -> Option<u16> {
846 match self.screen_mode {
847 ScreenMode::InlineAuto { .. } => self.auto_ui_height,
848 _ => None,
849 }
850 }
851
852 pub fn set_auto_ui_height(&mut self, height: u16) {
854 if let ScreenMode::InlineAuto {
855 min_height,
856 max_height,
857 } = self.screen_mode
858 {
859 let (min, max) = sanitize_auto_bounds(min_height, max_height);
860 let max = max.min(self.term_height);
861 let min = min.min(max);
862 let clamped = height.clamp(min, max);
863 let previous_effective = self.auto_ui_height.unwrap_or(min);
864 if self.auto_ui_height != Some(clamped) {
865 self.auto_ui_height = Some(clamped);
866 if clamped != previous_effective {
867 self.prev_buffer = None;
868 self.reset_diff_strategy();
869 if self.scroll_region_active {
870 let _ = self.deactivate_scroll_region();
871 }
872 }
873 }
874 }
875 }
876
877 pub fn clear_auto_ui_height(&mut self) {
879 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. })
880 && self.auto_ui_height.is_some()
881 {
882 self.auto_ui_height = None;
883 self.prev_buffer = None;
884 self.reset_diff_strategy();
885 if self.scroll_region_active {
886 let _ = self.deactivate_scroll_region();
887 }
888 }
889 }
890
891 fn effective_ui_height(&self) -> u16 {
892 match self.screen_mode {
893 ScreenMode::Inline { ui_height } => ui_height,
894 ScreenMode::InlineAuto {
895 min_height,
896 max_height,
897 } => {
898 let (min, max) = sanitize_auto_bounds(min_height, max_height);
899 let current = self.auto_ui_height.unwrap_or(min);
900 current.clamp(min, max).min(self.term_height)
901 }
902 ScreenMode::AltScreen => self.term_height,
903 }
904 }
905
906 pub fn ui_height(&self) -> u16 {
908 self.effective_ui_height()
909 }
910
911 fn ui_start_row(&self) -> u16 {
913 let ui_height = self.effective_ui_height().min(self.term_height);
914 match (self.screen_mode, self.ui_anchor) {
915 (ScreenMode::Inline { .. }, UiAnchor::Bottom)
916 | (ScreenMode::InlineAuto { .. }, UiAnchor::Bottom) => {
917 self.term_height.saturating_sub(ui_height)
918 }
919 (ScreenMode::Inline { .. }, UiAnchor::Top)
920 | (ScreenMode::InlineAuto { .. }, UiAnchor::Top) => 0,
921 (ScreenMode::AltScreen, _) => 0,
922 }
923 }
924
925 pub fn inline_strategy(&self) -> InlineStrategy {
927 self.inline_strategy
928 }
929
930 pub fn scroll_region_active(&self) -> bool {
932 self.scroll_region_active
933 }
934
935 fn activate_scroll_region(&mut self, ui_height: u16) -> io::Result<()> {
943 if self.scroll_region_active {
944 return Ok(());
945 }
946
947 let ui_height = ui_height.min(self.term_height);
948 if ui_height >= self.term_height {
949 return Ok(());
950 }
951
952 match self.ui_anchor {
953 UiAnchor::Bottom => {
954 let term_height = self.term_height;
955 let log_bottom = term_height.saturating_sub(ui_height);
956 if log_bottom > 0 {
957 write!(self.writer(), "\x1b[1;{}r", log_bottom)?;
959 self.scroll_region_active = true;
960 }
961 }
962 UiAnchor::Top => {
963 let term_height = self.term_height;
964 let log_top = ui_height.saturating_add(1);
965 if log_top <= term_height {
966 write!(self.writer(), "\x1b[{};{}r", log_top, term_height)?;
968 self.scroll_region_active = true;
969 write!(self.writer(), "\x1b[{};1H", log_top)?;
972 }
973 }
974 }
975 Ok(())
976 }
977
978 fn deactivate_scroll_region(&mut self) -> io::Result<()> {
980 if self.scroll_region_active {
981 self.writer().write_all(b"\x1b[r")?;
982 self.scroll_region_active = false;
983 }
984 Ok(())
985 }
986
987 fn clear_rows(&mut self, start_row: u16, height: u16) -> io::Result<()> {
988 let start_row = start_row.min(self.term_height);
989 let end_row = start_row.saturating_add(height).min(self.term_height);
990 for row in start_row..end_row {
991 write!(self.writer(), "\x1b[{};1H", row.saturating_add(1))?;
992 self.writer().write_all(ERASE_LINE)?;
993 }
994 Ok(())
995 }
996
997 fn clear_inline_region_diff(&mut self, current: InlineRegion) -> io::Result<()> {
998 let Some(previous) = self.last_inline_region else {
999 return Ok(());
1000 };
1001
1002 let prev_start = previous.start.min(self.term_height);
1003 let prev_end = previous
1004 .start
1005 .saturating_add(previous.height)
1006 .min(self.term_height);
1007 if prev_start >= prev_end {
1008 return Ok(());
1009 }
1010
1011 let curr_start = current.start.min(self.term_height);
1012 let curr_end = current
1013 .start
1014 .saturating_add(current.height)
1015 .min(self.term_height);
1016
1017 if curr_start > prev_start {
1018 let clear_end = curr_start.min(prev_end);
1019 if clear_end > prev_start {
1020 self.clear_rows(prev_start, clear_end - prev_start)?;
1021 }
1022 }
1023
1024 if curr_end < prev_end {
1025 let clear_start = curr_end.max(prev_start);
1026 if prev_end > clear_start {
1027 self.clear_rows(clear_start, prev_end - clear_start)?;
1028 }
1029 }
1030
1031 Ok(())
1032 }
1033
1034 pub fn present_ui(
1048 &mut self,
1049 buffer: &Buffer,
1050 cursor: Option<(u16, u16)>,
1051 cursor_visible: bool,
1052 ) -> io::Result<()> {
1053 let mode_str = match self.screen_mode {
1054 ScreenMode::Inline { .. } => "inline",
1055 ScreenMode::InlineAuto { .. } => "inline_auto",
1056 ScreenMode::AltScreen => "altscreen",
1057 };
1058 let trace_enabled = self.render_trace.is_some();
1059 if trace_enabled {
1060 self.writer().reset_counter();
1061 }
1062 let present_start = if trace_enabled {
1063 Some(Instant::now())
1064 } else {
1065 None
1066 };
1067 let _span = info_span!(
1068 "ftui.render.present",
1069 mode = mode_str,
1070 width = buffer.width(),
1071 height = buffer.height(),
1072 )
1073 .entered();
1074
1075 let result = match self.screen_mode {
1076 ScreenMode::Inline { ui_height } => {
1077 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1078 }
1079 ScreenMode::InlineAuto { .. } => {
1080 let ui_height = self.effective_ui_height();
1081 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1082 }
1083 ScreenMode::AltScreen => self.present_altscreen(buffer, cursor, cursor_visible),
1084 };
1085
1086 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1087 let present_bytes = if trace_enabled {
1088 {
1089 let w = self.writer();
1090 let count = w.bytes_written();
1091 w.reset_counter();
1092 Some(count)
1093 }
1094 } else {
1095 None
1096 };
1097 if trace_enabled {
1098 }
1100
1101 if let Ok(stats) = result {
1102 let new_prev = match self.clone_buf.take() {
1105 Some(mut buf)
1106 if buf.width() == buffer.width() && buf.height() == buffer.height() =>
1107 {
1108 buf.clone_from(buffer);
1109 buf
1110 }
1111 _ => buffer.clone(),
1112 };
1113 self.clone_buf = self.spare_buffer.take();
1114 self.spare_buffer = self.prev_buffer.take();
1115 self.prev_buffer = Some(new_prev);
1116
1117 if let Some(ref mut trace) = self.render_trace {
1118 let payload_info = match stats.diff_strategy {
1119 DiffStrategy::FullRedraw => {
1120 let payload = build_full_buffer_payload(buffer, &self.pool);
1121 trace.write_payload(&payload).ok()
1122 }
1123 _ => {
1124 let payload =
1125 build_diff_runs_payload(buffer, &self.diff_scratch, &self.pool);
1126 trace.write_payload(&payload).ok()
1127 }
1128 };
1129 let (payload_kind, payload_path) = match payload_info {
1130 Some(info) => (info.kind, Some(info.path)),
1131 None => ("none", None),
1132 };
1133 let payload_path_ref = payload_path.as_deref();
1134 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1135 let ui_anchor = ui_anchor_str(self.ui_anchor);
1136 let frame = RenderTraceFrame {
1137 cols: buffer.width(),
1138 rows: buffer.height(),
1139 mode: mode_str,
1140 ui_height: stats.ui_height,
1141 ui_anchor,
1142 diff_strategy,
1143 diff_cells: stats.diff_cells,
1144 diff_runs: stats.diff_runs,
1145 present_bytes: present_bytes.unwrap_or(0),
1146 render_us: None,
1147 present_us,
1148 payload_kind,
1149 payload_path: payload_path_ref,
1150 trace_us: None,
1151 };
1152 let _ = trace.record_frame(frame, buffer, &self.pool);
1153 }
1154 return Ok(());
1155 }
1156
1157 result.map(|_| ())
1158 }
1159
1160 pub fn present_ui_owned(
1165 &mut self,
1166 buffer: Buffer,
1167 cursor: Option<(u16, u16)>,
1168 cursor_visible: bool,
1169 ) -> io::Result<()> {
1170 let mode_str = match self.screen_mode {
1171 ScreenMode::Inline { .. } => "inline",
1172 ScreenMode::InlineAuto { .. } => "inline_auto",
1173 ScreenMode::AltScreen => "altscreen",
1174 };
1175 let trace_enabled = self.render_trace.is_some();
1176 if trace_enabled {
1177 self.writer().reset_counter();
1178 }
1179 let present_start = if trace_enabled {
1180 Some(Instant::now())
1181 } else {
1182 None
1183 };
1184 let _span = info_span!(
1185 "ftui.render.present",
1186 mode = mode_str,
1187 width = buffer.width(),
1188 height = buffer.height(),
1189 )
1190 .entered();
1191
1192 let result = match self.screen_mode {
1193 ScreenMode::Inline { ui_height } => {
1194 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1195 }
1196 ScreenMode::InlineAuto { .. } => {
1197 let ui_height = self.effective_ui_height();
1198 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1199 }
1200 ScreenMode::AltScreen => self.present_altscreen(&buffer, cursor, cursor_visible),
1201 };
1202
1203 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1204 let present_bytes = if trace_enabled {
1205 {
1206 let w = self.writer();
1207 let count = w.bytes_written();
1208 w.reset_counter();
1209 Some(count)
1210 }
1211 } else {
1212 None
1213 };
1214 if trace_enabled {
1215 }
1217
1218 if let Ok(stats) = result {
1219 if let Some(ref mut trace) = self.render_trace {
1220 let payload_info = match stats.diff_strategy {
1221 DiffStrategy::FullRedraw => {
1222 let payload = build_full_buffer_payload(&buffer, &self.pool);
1223 trace.write_payload(&payload).ok()
1224 }
1225 _ => {
1226 let payload =
1227 build_diff_runs_payload(&buffer, &self.diff_scratch, &self.pool);
1228 trace.write_payload(&payload).ok()
1229 }
1230 };
1231 let (payload_kind, payload_path) = match payload_info {
1232 Some(info) => (info.kind, Some(info.path)),
1233 None => ("none", None),
1234 };
1235 let payload_path_ref = payload_path.as_deref();
1236 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1237 let ui_anchor = ui_anchor_str(self.ui_anchor);
1238 let frame = RenderTraceFrame {
1239 cols: buffer.width(),
1240 rows: buffer.height(),
1241 mode: mode_str,
1242 ui_height: stats.ui_height,
1243 ui_anchor,
1244 diff_strategy,
1245 diff_cells: stats.diff_cells,
1246 diff_runs: stats.diff_runs,
1247 present_bytes: present_bytes.unwrap_or(0),
1248 render_us: None,
1249 present_us,
1250 payload_kind,
1251 payload_path: payload_path_ref,
1252 trace_us: None,
1253 };
1254 let _ = trace.record_frame(frame, &buffer, &self.pool);
1255 }
1256
1257 self.clone_buf = self.spare_buffer.take();
1259 self.spare_buffer = self.prev_buffer.take();
1260 self.prev_buffer = Some(buffer);
1261 return Ok(());
1262 }
1263
1264 result.map(|_| ())
1265 }
1266
1267 fn decide_diff(&mut self, buffer: &Buffer) -> DiffDecision {
1268 let prev_dims = self
1269 .prev_buffer
1270 .as_ref()
1271 .map(|prev| (prev.width(), prev.height()));
1272 if prev_dims.is_none() || prev_dims != Some((buffer.width(), buffer.height())) {
1273 self.full_redraw_probe = 0;
1274 self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1275 return DiffDecision {
1276 strategy: DiffStrategy::FullRedraw,
1277 has_diff: false,
1278 };
1279 }
1280
1281 let dirty_rows = buffer.dirty_row_count();
1282 let width = buffer.width() as usize;
1283 let height = buffer.height() as usize;
1284 let mut span_stats_snapshot: Option<DirtySpanStats> = None;
1285 let mut dirty_scan_cells_estimate = dirty_rows.saturating_mul(width);
1286
1287 if self.diff_config.bayesian_enabled {
1288 let span_stats = buffer.dirty_span_stats();
1289 if span_stats.span_coverage_cells > 0 {
1290 dirty_scan_cells_estimate = span_stats.span_coverage_cells;
1291 }
1292 span_stats_snapshot = Some(span_stats);
1293 }
1294
1295 let mut strategy = if self.diff_config.bayesian_enabled {
1297 self.diff_strategy.select_with_scan_estimate(
1299 buffer.width(),
1300 buffer.height(),
1301 dirty_rows,
1302 dirty_scan_cells_estimate,
1303 )
1304 } else {
1305 if self.diff_config.dirty_rows_enabled && dirty_rows < buffer.height() as usize {
1307 DiffStrategy::DirtyRows
1308 } else {
1309 DiffStrategy::Full
1310 }
1311 };
1312
1313 if !self.diff_config.dirty_rows_enabled && strategy == DiffStrategy::DirtyRows {
1315 strategy = DiffStrategy::Full;
1316 if self.diff_config.bayesian_enabled {
1317 self.diff_strategy
1318 .override_last_strategy(strategy, "dirty_rows_disabled");
1319 }
1320 }
1321
1322 if strategy == DiffStrategy::FullRedraw {
1324 if self.full_redraw_probe >= FULL_REDRAW_PROBE_INTERVAL {
1325 self.full_redraw_probe = 0;
1326 let probed = if self.diff_config.dirty_rows_enabled
1327 && dirty_rows < buffer.height() as usize
1328 {
1329 DiffStrategy::DirtyRows
1330 } else {
1331 DiffStrategy::Full
1332 };
1333 if probed != strategy {
1334 strategy = probed;
1335 if self.diff_config.bayesian_enabled {
1336 self.diff_strategy
1337 .override_last_strategy(strategy, "full_redraw_probe");
1338 }
1339 }
1340 } else {
1341 self.full_redraw_probe = self.full_redraw_probe.saturating_add(1);
1342 }
1343 } else {
1344 self.full_redraw_probe = 0;
1345 }
1346
1347 let mut has_diff = false;
1348 match strategy {
1349 DiffStrategy::Full => {
1350 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1351 self.diff_scratch.compute_into(prev, buffer);
1352 has_diff = true;
1353 }
1354 DiffStrategy::DirtyRows => {
1355 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1356 self.diff_scratch.compute_dirty_into(prev, buffer);
1357 has_diff = true;
1358 }
1359 DiffStrategy::FullRedraw => {}
1360 }
1361
1362 let mut scan_cost_estimate = 0usize;
1363 let mut fallback_reason: &'static str = "none";
1364 let tile_stats = if strategy == DiffStrategy::DirtyRows {
1365 self.diff_scratch.last_tile_stats()
1366 } else {
1367 None
1368 };
1369
1370 if self.diff_config.bayesian_enabled && has_diff {
1372 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1373 let (scan_cost, reason) = estimate_diff_scan_cost(
1374 strategy,
1375 dirty_rows,
1376 width,
1377 height,
1378 &span_stats,
1379 tile_stats,
1380 );
1381 let scanned_cells = scan_cost.max(self.diff_scratch.len());
1382 self.diff_strategy
1383 .observe(scanned_cells, self.diff_scratch.len());
1384 span_stats_snapshot = Some(span_stats);
1385 scan_cost_estimate = scan_cost;
1386 fallback_reason = reason;
1387 }
1388
1389 if let Some(evidence) = self.diff_strategy.last_evidence() {
1390 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1391 let (scan_cost, reason) = if span_stats_snapshot.is_some() {
1392 (scan_cost_estimate, fallback_reason)
1393 } else {
1394 estimate_diff_scan_cost(
1395 strategy,
1396 dirty_rows,
1397 width,
1398 height,
1399 &span_stats,
1400 tile_stats,
1401 )
1402 };
1403 let span_coverage_pct = if evidence.total_cells == 0 {
1404 0.0
1405 } else {
1406 (span_stats.span_coverage_cells as f64 / evidence.total_cells as f64) * 100.0
1407 };
1408 let span_count = span_stats.total_spans;
1409 let max_span_len = span_stats.max_span_len;
1410 let event_idx = self.diff_evidence_idx;
1411 self.diff_evidence_idx = self.diff_evidence_idx.saturating_add(1);
1412 let tile_used = tile_stats.is_some_and(|stats| stats.fallback.is_none());
1413 let tile_fallback = tile_stats
1414 .and_then(|stats| stats.fallback)
1415 .map(TileDiffFallback::as_str)
1416 .unwrap_or("none");
1417 let run_id = json_escape(&self.diff_evidence_run_id);
1418 let strategy_json = json_escape(&strategy.to_string());
1419 let guard_reason_json = json_escape(evidence.guard_reason);
1420 let fallback_reason_json = json_escape(reason);
1421 let tile_fallback_json = json_escape(tile_fallback);
1422 let schema_version = crate::evidence_sink::EVIDENCE_SCHEMA_VERSION;
1423 let screen_mode = match self.screen_mode {
1424 ScreenMode::Inline { .. } => "inline",
1425 ScreenMode::InlineAuto { .. } => "inline_auto",
1426 ScreenMode::AltScreen => "altscreen",
1427 };
1428 let (
1429 tile_w,
1430 tile_h,
1431 tiles_x,
1432 tiles_y,
1433 dirty_tiles,
1434 dirty_cells,
1435 dirty_tile_ratio,
1436 dirty_cell_ratio,
1437 scanned_tiles,
1438 skipped_tiles,
1439 scan_cells_estimate,
1440 sat_build_cells,
1441 ) = if let Some(stats) = tile_stats {
1442 (
1443 stats.tile_w,
1444 stats.tile_h,
1445 stats.tiles_x,
1446 stats.tiles_y,
1447 stats.dirty_tiles,
1448 stats.dirty_cells,
1449 stats.dirty_tile_ratio,
1450 stats.dirty_cell_ratio,
1451 stats.scanned_tiles,
1452 stats.skipped_tiles,
1453 stats.scan_cells_estimate,
1454 stats.sat_build_cells,
1455 )
1456 } else {
1457 (0, 0, 0, 0, 0, 0, 0.0, 0.0, 0, 0, 0, 0)
1458 };
1459 let tile_size = tile_w as usize * tile_h as usize;
1460 let dirty_tile_count = dirty_tiles;
1461 let skipped_tile_count = skipped_tiles;
1462 let sat_build_cost_est = sat_build_cells;
1463
1464 set_diff_snapshot(Some(DiffDecisionSnapshot {
1465 event_idx,
1466 screen_mode: screen_mode.to_string(),
1467 cols: u16::try_from(width).unwrap_or(u16::MAX),
1468 rows: u16::try_from(height).unwrap_or(u16::MAX),
1469 evidence: evidence.clone(),
1470 span_count,
1471 span_coverage_pct,
1472 max_span_len,
1473 scan_cost_estimate: scan_cost,
1474 fallback_reason: reason.to_string(),
1475 tile_used,
1476 tile_fallback: tile_fallback.to_string(),
1477 strategy_used: strategy,
1478 }));
1479
1480 trace!(
1481 strategy = %strategy,
1482 selected = %evidence.strategy,
1483 cost_full = evidence.cost_full,
1484 cost_dirty = evidence.cost_dirty,
1485 cost_redraw = evidence.cost_redraw,
1486 dirty_rows = evidence.dirty_rows,
1487 total_rows = evidence.total_rows,
1488 total_cells = evidence.total_cells,
1489 bayesian_enabled = self.diff_config.bayesian_enabled,
1490 dirty_rows_enabled = self.diff_config.dirty_rows_enabled,
1491 "diff strategy selected"
1492 );
1493 if let Some(ref sink) = self.evidence_sink {
1494 let line = format!(
1495 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":{}}}"#,
1496 schema_version,
1497 run_id,
1498 event_idx,
1499 screen_mode,
1500 width,
1501 height,
1502 strategy_json,
1503 evidence.cost_full,
1504 evidence.cost_dirty,
1505 evidence.cost_redraw,
1506 evidence.posterior_mean,
1507 evidence.posterior_variance,
1508 evidence.alpha,
1509 evidence.beta,
1510 guard_reason_json,
1511 evidence.hysteresis_applied,
1512 evidence.hysteresis_ratio,
1513 evidence.dirty_rows,
1514 evidence.total_rows,
1515 evidence.total_cells,
1516 span_count,
1517 span_coverage_pct,
1518 max_span_len,
1519 fallback_reason_json,
1520 scan_cost,
1521 tile_used,
1522 tile_fallback_json,
1523 tile_w,
1524 tile_h,
1525 tile_size,
1526 tiles_x,
1527 tiles_y,
1528 dirty_tiles,
1529 dirty_tile_count,
1530 dirty_cells,
1531 dirty_tile_ratio,
1532 dirty_cell_ratio,
1533 scanned_tiles,
1534 skipped_tiles,
1535 skipped_tile_count,
1536 scan_cells_estimate,
1537 sat_build_cost_est,
1538 self.diff_config.bayesian_enabled,
1539 self.diff_config.dirty_rows_enabled,
1540 );
1541 let _ = sink.write_jsonl(&line);
1542 }
1543 }
1544
1545 self.last_diff_strategy = Some(strategy);
1546 DiffDecision { strategy, has_diff }
1547 }
1548
1549 fn present_inline(
1555 &mut self,
1556 buffer: &Buffer,
1557 ui_height: u16,
1558 cursor: Option<(u16, u16)>,
1559 cursor_visible: bool,
1560 ) -> io::Result<FrameEmitStats> {
1561 let sync_output_enabled = self.capabilities.use_sync_output();
1562 let render_mode = inline_strategy_str(self.inline_strategy);
1563 let _inline_span = info_span!(
1564 "inline.render",
1565 inline_height = ui_height,
1566 scrollback_preserved = tracing::field::Empty,
1567 render_mode,
1568 )
1569 .entered();
1570
1571 let result = (|| -> io::Result<FrameEmitStats> {
1572 let visible_height = ui_height.min(self.term_height);
1573 let ui_y_start = self.ui_start_row();
1574 let current_region = InlineRegion {
1575 start: ui_y_start,
1576 height: visible_height,
1577 };
1578
1579 if sync_output_enabled && !self.in_sync_block {
1581 self.in_sync_block = true;
1584 if let Err(err) = self.writer().write_all(SYNC_BEGIN) {
1585 let _ = self.writer().write_all(SYNC_END);
1588 self.in_sync_block = false;
1589 let _ = self.writer().flush();
1590 return Err(err);
1591 }
1592 }
1593
1594 self.writer().write_all(CURSOR_SAVE)?;
1596 self.cursor_saved = true;
1597
1598 {
1600 let _span = debug_span!("ftui.render.scroll_region").entered();
1601 if visible_height > 0 {
1602 match self.inline_strategy {
1603 InlineStrategy::ScrollRegion | InlineStrategy::Hybrid => {
1604 self.activate_scroll_region(visible_height)?;
1605 }
1606 InlineStrategy::OverlayRedraw => {}
1607 }
1608 } else if self.scroll_region_active {
1609 self.deactivate_scroll_region()?;
1610 }
1611 }
1612
1613 self.clear_inline_region_diff(current_region)?;
1614
1615 let mut diff_strategy = DiffStrategy::FullRedraw;
1616 let mut diff_us = 0u64;
1617 let mut emit_stats = EmitStats {
1618 diff_cells: 0,
1619 diff_runs: 0,
1620 };
1621
1622 if visible_height > 0 {
1623 if self.prev_buffer.is_none() {
1626 self.clear_rows(ui_y_start, visible_height)?;
1627 } else {
1628 let buf_height = buffer.height().min(visible_height);
1631 if buf_height < visible_height {
1632 let clear_start = ui_y_start.saturating_add(buf_height);
1633 let clear_height = visible_height.saturating_sub(buf_height);
1634 self.clear_rows(clear_start, clear_height)?;
1635 }
1636 }
1637
1638 let diff_start = if self.timing_enabled {
1640 Some(Instant::now())
1641 } else {
1642 None
1643 };
1644 let decision = {
1645 let _span = debug_span!("ftui.render.diff_compute").entered();
1646 self.decide_diff(buffer)
1647 };
1648 if let Some(start) = diff_start {
1649 diff_us = start.elapsed().as_micros() as u64;
1650 }
1651 diff_strategy = decision.strategy;
1652
1653 {
1655 let _span = debug_span!("ftui.render.emit").entered();
1656
1657 let presenter = self.presenter.as_mut().expect("presenter consumed");
1660 presenter.reset();
1661 presenter.set_viewport_offset_y(ui_y_start);
1662
1663 if decision.has_diff {
1664 presenter.prepare_runs(&self.diff_scratch);
1665 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1667
1668 emit_stats.diff_cells = self.diff_scratch.len();
1669 emit_stats.diff_runs = self.diff_scratch.runs().len();
1670 } else {
1671 let full = BufferDiff::full(buffer.width(), visible_height);
1675 presenter.prepare_runs(&full);
1676 presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1677
1678 emit_stats.diff_cells =
1679 (buffer.width() as usize) * (visible_height as usize);
1680 emit_stats.diff_runs = visible_height as usize;
1681 }
1682 }
1683 }
1684
1685 self.writer().write_all(b"\x1b[0m")?;
1687
1688 self.writer().write_all(CURSOR_RESTORE)?;
1690 self.cursor_saved = false;
1691
1692 if cursor_visible {
1693 if let Some((cx, cy)) = cursor
1695 && cy < visible_height
1696 {
1697 let abs_y = ui_y_start.saturating_add(cy);
1699 write!(
1700 self.writer(),
1701 "\x1b[{};{}H",
1702 abs_y.saturating_add(1),
1703 cx.saturating_add(1)
1704 )?;
1705 }
1706 self.set_cursor_visibility(true)?;
1707 } else {
1708 self.set_cursor_visibility(false)?;
1709 }
1710
1711 if sync_output_enabled && self.in_sync_block {
1713 self.writer().write_all(SYNC_END)?;
1714 self.in_sync_block = false;
1715 } else if !sync_output_enabled {
1716 self.in_sync_block = false;
1719 }
1720
1721 self.writer().flush()?;
1722 self.last_inline_region = if visible_height > 0 {
1723 Some(current_region)
1724 } else {
1725 None
1726 };
1727
1728 if self.timing_enabled {
1729 self.last_present_timings = Some(PresentTimings { diff_us });
1730 }
1731
1732 Ok(FrameEmitStats {
1733 diff_strategy,
1734 diff_cells: emit_stats.diff_cells,
1735 diff_runs: emit_stats.diff_runs,
1736 ui_height: visible_height,
1737 })
1738 })();
1739
1740 if result.is_err() {
1741 _inline_span.record("scrollback_preserved", false);
1742 warn!(
1743 inline_height = ui_height,
1744 render_mode, "scrollback preservation failed during inline render"
1745 );
1746 self.best_effort_inline_cleanup();
1747 } else {
1748 _inline_span.record("scrollback_preserved", true);
1749 }
1750
1751 result
1752 }
1753
1754 fn present_altscreen(
1756 &mut self,
1757 buffer: &Buffer,
1758 cursor: Option<(u16, u16)>,
1759 cursor_visible: bool,
1760 ) -> io::Result<FrameEmitStats> {
1761 let sync_output_enabled = self.capabilities.use_sync_output();
1762 let diff_start = if self.timing_enabled {
1763 Some(Instant::now())
1764 } else {
1765 None
1766 };
1767 let decision = {
1768 let _span = debug_span!("ftui.render.diff_compute").entered();
1769 self.decide_diff(buffer)
1770 };
1771 let diff_us = diff_start
1772 .map(|start| start.elapsed().as_micros() as u64)
1773 .unwrap_or(0);
1774
1775 if sync_output_enabled && !self.in_sync_block {
1778 self.in_sync_block = true;
1781 if let Err(err) = self.writer().write_all(SYNC_BEGIN) {
1782 let _ = self.writer().write_all(SYNC_END);
1785 self.in_sync_block = false;
1786 let _ = self.writer().flush();
1787 return Err(err);
1788 }
1789 }
1790
1791 let operation_result = (|| -> io::Result<FrameEmitStats> {
1792 let emit_stats = {
1793 let _span = debug_span!("ftui.render.emit").entered();
1794 if decision.has_diff {
1795 let diff = std::mem::take(&mut self.diff_scratch);
1796 let result = self.emit_diff(buffer, &diff, None, 0);
1797 self.diff_scratch = diff;
1798 result?
1799 } else {
1800 self.emit_full_redraw(buffer, None, 0)?
1801 }
1802 };
1803
1804 self.writer().write_all(b"\x1b[0m")?;
1806
1807 if cursor_visible {
1808 if let Some((cx, cy)) = cursor {
1810 write!(
1811 self.writer(),
1812 "\x1b[{};{}H",
1813 cy.saturating_add(1),
1814 cx.saturating_add(1)
1815 )?;
1816 }
1817 self.set_cursor_visibility(true)?;
1818 } else {
1819 self.set_cursor_visibility(false)?;
1820 }
1821
1822 if self.timing_enabled {
1823 self.last_present_timings = Some(PresentTimings { diff_us });
1824 }
1825
1826 Ok(FrameEmitStats {
1827 diff_strategy: decision.strategy,
1828 diff_cells: emit_stats.diff_cells,
1829 diff_runs: emit_stats.diff_runs,
1830 ui_height: 0,
1831 })
1832 })();
1833
1834 let sync_end_result = if sync_output_enabled && self.in_sync_block {
1836 let res = self.writer().write_all(SYNC_END);
1837 if res.is_ok() {
1838 self.in_sync_block = false;
1839 }
1840 Some(res)
1841 } else {
1842 if !sync_output_enabled {
1843 self.in_sync_block = false;
1846 }
1847 None
1848 };
1849 let flush_result = self.writer().flush();
1850
1851 let cleanup_error = sync_end_result
1854 .and_then(Result::err)
1855 .or_else(|| flush_result.err());
1856 if let Some(err) = cleanup_error {
1857 return Err(err);
1858 }
1859 operation_result
1860 }
1861
1862 fn emit_diff(
1864 &mut self,
1865 buffer: &Buffer,
1866 diff: &BufferDiff,
1867 max_height: Option<u16>,
1868 ui_y_start: u16,
1869 ) -> io::Result<EmitStats> {
1870 use ftui_render::cell::{Cell, CellAttrs, StyleFlags};
1871
1872 diff.runs_into(&mut self.runs_buf);
1873 let diff_runs = self.runs_buf.len();
1874 let diff_cells = diff.len();
1875 let _span = debug_span!("ftui.render.emit_diff", run_count = self.runs_buf.len()).entered();
1876
1877 let mut current_style: Option<(
1878 ftui_render::cell::PackedRgba,
1879 ftui_render::cell::PackedRgba,
1880 StyleFlags,
1881 )> = None;
1882 let mut current_link: Option<u32> = None;
1883 let default_cell = Cell::default();
1884
1885 let writer = self
1887 .presenter
1888 .as_mut()
1889 .expect("presenter consumed")
1890 .counting_writer_mut();
1891 let hyperlinks_enabled = self.capabilities.use_hyperlinks();
1892
1893 for run in &self.runs_buf {
1894 if let Some(limit) = max_height
1895 && run.y >= limit
1896 {
1897 continue;
1898 }
1899 write!(
1901 writer,
1902 "\x1b[{};{}H",
1903 ui_y_start.saturating_add(run.y).saturating_add(1),
1904 run.x0.saturating_add(1)
1905 )?;
1906
1907 let mut cursor_x = run.x0;
1909 for x in run.x0..=run.x1 {
1910 let cell = buffer.get_unchecked(x, run.y);
1911
1912 let is_orphan = cell.is_continuation() && cursor_x <= x;
1914 if cell.is_continuation() && !is_orphan {
1915 continue;
1916 }
1917 let effective_cell = if is_orphan { &default_cell } else { cell };
1918
1919 let cell_style = (
1921 effective_cell.fg,
1922 effective_cell.bg,
1923 effective_cell.attrs.flags(),
1924 );
1925 if current_style != Some(cell_style) {
1926 writer.write_all(b"\x1b[0m")?;
1928
1929 if !cell_style.2.is_empty() {
1931 Self::emit_style_flags(writer, cell_style.2)?;
1932 }
1933
1934 if cell_style.0.a() > 0 {
1936 write!(
1937 writer,
1938 "\x1b[38;2;{};{};{}m",
1939 cell_style.0.r(),
1940 cell_style.0.g(),
1941 cell_style.0.b()
1942 )?;
1943 }
1944 if cell_style.1.a() > 0 {
1945 write!(
1946 writer,
1947 "\x1b[48;2;{};{};{}m",
1948 cell_style.1.r(),
1949 cell_style.1.g(),
1950 cell_style.1.b()
1951 )?;
1952 }
1953
1954 current_style = Some(cell_style);
1955 }
1956
1957 let raw_link_id = effective_cell.attrs.link_id();
1959 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1960 None
1961 } else {
1962 Some(raw_link_id)
1963 };
1964
1965 if !hyperlinks_enabled {
1966 if current_link.is_some() {
1967 writer.write_all(b"\x1b]8;;\x1b\\")?;
1968 current_link = None;
1969 }
1970 } else if current_link != new_link {
1971 if current_link.is_some() {
1973 writer.write_all(b"\x1b]8;;\x1b\\")?;
1974 }
1975 let actually_opened = if let Some(link_id) = new_link
1977 && let Some(url) = self.links.get(link_id)
1978 && is_safe_hyperlink_url(url)
1979 {
1980 write!(writer, "\x1b]8;;{}\x1b\\", url)?;
1981 true
1982 } else {
1983 false
1984 };
1985 current_link = if actually_opened { new_link } else { None };
1986 }
1987
1988 let raw_width = effective_cell.content.width();
1989 let is_zero_width_content = raw_width == 0
1990 && !effective_cell.is_empty()
1991 && !effective_cell.is_continuation();
1992
1993 if is_zero_width_content {
1995 writer.write_all(b"\xEF\xBF\xBD")?;
1996 } else if let Some(ch) = effective_cell.content.as_char() {
1997 let safe_ch = if ch.is_control() { ' ' } else { ch };
1998 let mut buf = [0u8; 4];
1999 let encoded = safe_ch.encode_utf8(&mut buf);
2000 writer.write_all(encoded.as_bytes())?;
2001 } else if let Some(gid) = effective_cell.content.grapheme_id() {
2002 if let Some(text) = self.pool.get(gid) {
2004 let safe = sanitize(text);
2005 if !safe.is_empty() {
2006 writer.write_all(safe.as_bytes())?;
2007 } else {
2008 for _ in 0..raw_width.max(1) {
2010 writer.write_all(b"?")?;
2011 }
2012 }
2013 } else {
2014 for _ in 0..raw_width.max(1) {
2016 writer.write_all(b"?")?;
2017 }
2018 }
2019 } else {
2020 writer.write_all(b" ")?;
2021 }
2022
2023 let advance = if effective_cell.is_empty() || is_zero_width_content {
2024 1
2025 } else {
2026 raw_width.max(1)
2027 };
2028 cursor_x = cursor_x.saturating_add(advance as u16);
2029 }
2030 }
2031
2032 writer.write_all(b"\x1b[0m")?;
2034
2035 if current_link.is_some() {
2037 writer.write_all(b"\x1b]8;;\x1b\\")?;
2038 }
2039
2040 trace!("emit_diff complete");
2041 Ok(EmitStats {
2042 diff_cells,
2043 diff_runs,
2044 })
2045 }
2046
2047 fn emit_full_redraw(
2049 &mut self,
2050 buffer: &Buffer,
2051 max_height: Option<u16>,
2052 ui_y_start: u16,
2053 ) -> io::Result<EmitStats> {
2054 use ftui_render::cell::{Cell, CellAttrs, StyleFlags};
2055
2056 let height = max_height.unwrap_or(buffer.height()).min(buffer.height());
2057 let width = buffer.width();
2058 let diff_cells = width as usize * height as usize;
2059 let diff_runs = height as usize;
2060
2061 let _span = debug_span!("ftui.render.emit_full_redraw").entered();
2062
2063 let mut current_style: Option<(
2064 ftui_render::cell::PackedRgba,
2065 ftui_render::cell::PackedRgba,
2066 StyleFlags,
2067 )> = None;
2068 let mut current_link: Option<u32> = None;
2069 let default_cell = Cell::default();
2070
2071 let writer = self
2073 .presenter
2074 .as_mut()
2075 .expect("presenter consumed")
2076 .counting_writer_mut();
2077 let hyperlinks_enabled = self.capabilities.use_hyperlinks();
2078
2079 for y in 0..height {
2080 write!(
2081 writer,
2082 "\x1b[{};{}H",
2083 ui_y_start.saturating_add(y).saturating_add(1),
2084 1
2085 )?;
2086
2087 let mut cursor_x = 0u16;
2088 for x in 0..width {
2089 let cell = buffer.get_unchecked(x, y);
2090
2091 let is_orphan = cell.is_continuation() && cursor_x <= x;
2093 if cell.is_continuation() && !is_orphan {
2094 continue;
2095 }
2096 let effective_cell = if is_orphan { &default_cell } else { cell };
2097
2098 let cell_style = (
2100 effective_cell.fg,
2101 effective_cell.bg,
2102 effective_cell.attrs.flags(),
2103 );
2104 if current_style != Some(cell_style) {
2105 writer.write_all(b"\x1b[0m")?;
2107
2108 if !cell_style.2.is_empty() {
2110 Self::emit_style_flags(writer, cell_style.2)?;
2111 }
2112
2113 if cell_style.0.a() > 0 {
2115 write!(
2116 writer,
2117 "\x1b[38;2;{};{};{}m",
2118 cell_style.0.r(),
2119 cell_style.0.g(),
2120 cell_style.0.b()
2121 )?;
2122 }
2123 if cell_style.1.a() > 0 {
2124 write!(
2125 writer,
2126 "\x1b[48;2;{};{};{}m",
2127 cell_style.1.r(),
2128 cell_style.1.g(),
2129 cell_style.1.b()
2130 )?;
2131 }
2132
2133 current_style = Some(cell_style);
2134 }
2135
2136 let raw_link_id = effective_cell.attrs.link_id();
2138 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
2139 None
2140 } else {
2141 Some(raw_link_id)
2142 };
2143
2144 if !hyperlinks_enabled {
2145 if current_link.is_some() {
2146 writer.write_all(b"\x1b]8;;\x1b\\")?;
2147 current_link = None;
2148 }
2149 } else if current_link != new_link {
2150 if current_link.is_some() {
2152 writer.write_all(b"\x1b]8;;\x1b\\")?;
2153 }
2154 let actually_opened = if let Some(link_id) = new_link
2156 && let Some(url) = self.links.get(link_id)
2157 && is_safe_hyperlink_url(url)
2158 {
2159 write!(writer, "\x1b]8;;{}\x1b\\", url)?;
2160 true
2161 } else {
2162 false
2163 };
2164 current_link = if actually_opened { new_link } else { None };
2165 }
2166
2167 let raw_width = effective_cell.content.width();
2168 let is_zero_width_content = raw_width == 0
2169 && !effective_cell.is_empty()
2170 && !effective_cell.is_continuation();
2171
2172 if is_zero_width_content {
2174 writer.write_all(b"\xEF\xBF\xBD")?;
2175 } else if let Some(ch) = effective_cell.content.as_char() {
2176 let safe_ch = if ch.is_control() { ' ' } else { ch };
2177 let mut buf = [0u8; 4];
2178 let encoded = safe_ch.encode_utf8(&mut buf);
2179 writer.write_all(encoded.as_bytes())?;
2180 } else if let Some(gid) = effective_cell.content.grapheme_id() {
2181 if let Some(text) = self.pool.get(gid) {
2183 let safe = sanitize(text);
2184 if !safe.is_empty() {
2185 writer.write_all(safe.as_bytes())?;
2186 } else {
2187 for _ in 0..raw_width.max(1) {
2189 writer.write_all(b"?")?;
2190 }
2191 }
2192 } else {
2193 for _ in 0..raw_width.max(1) {
2195 writer.write_all(b"?")?;
2196 }
2197 }
2198 } else {
2199 writer.write_all(b" ")?;
2200 }
2201
2202 let advance = if effective_cell.is_empty() || is_zero_width_content {
2203 1
2204 } else {
2205 raw_width.max(1)
2206 };
2207 cursor_x = cursor_x.saturating_add(advance as u16);
2208 }
2209 }
2210
2211 writer.write_all(b"\x1b[0m")?;
2213
2214 if current_link.is_some() {
2216 writer.write_all(b"\x1b]8;;\x1b\\")?;
2217 }
2218
2219 trace!("emit_full_redraw complete");
2220 Ok(EmitStats {
2221 diff_cells,
2222 diff_runs,
2223 })
2224 }
2225
2226 fn emit_style_flags(
2228 writer: &mut impl Write,
2229 flags: ftui_render::cell::StyleFlags,
2230 ) -> io::Result<()> {
2231 use ftui_render::cell::StyleFlags;
2232
2233 let mut codes = Vec::with_capacity(8);
2234
2235 if flags.contains(StyleFlags::BOLD) {
2236 codes.push("1");
2237 }
2238 if flags.contains(StyleFlags::DIM) {
2239 codes.push("2");
2240 }
2241 if flags.contains(StyleFlags::ITALIC) {
2242 codes.push("3");
2243 }
2244 if flags.contains(StyleFlags::UNDERLINE) {
2245 codes.push("4");
2246 }
2247 if flags.contains(StyleFlags::BLINK) {
2248 codes.push("5");
2249 }
2250 if flags.contains(StyleFlags::REVERSE) {
2251 codes.push("7");
2252 }
2253 if flags.contains(StyleFlags::HIDDEN) {
2254 codes.push("8");
2255 }
2256 if flags.contains(StyleFlags::STRIKETHROUGH) {
2257 codes.push("9");
2258 }
2259
2260 if !codes.is_empty() {
2261 write!(writer, "\x1b[{}m", codes.join(";"))?;
2262 }
2263
2264 Ok(())
2265 }
2266
2267 #[allow(dead_code)] fn create_full_diff(&self, buffer: &Buffer) -> BufferDiff {
2270 BufferDiff::full(buffer.width(), buffer.height())
2271 }
2272
2273 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
2284 let sanitized = sanitize(text);
2288 let text = sanitized.as_ref();
2289 match self.screen_mode {
2290 ScreenMode::Inline { ui_height } => {
2291 if !self.position_cursor_for_log(ui_height)? {
2292 return Ok(());
2293 }
2294 if !self.scroll_region_active {
2297 self.prev_buffer = None;
2298 self.last_inline_region = None;
2299 self.reset_diff_strategy();
2300 }
2301
2302 self.writer().write_all(text.as_bytes())?;
2303 self.writer().flush()
2304 }
2305 ScreenMode::InlineAuto { .. } => {
2306 let ui_height = self.effective_ui_height();
2308 if !self.position_cursor_for_log(ui_height)? {
2309 return Ok(());
2310 }
2311 if !self.scroll_region_active {
2313 self.prev_buffer = None;
2314 self.last_inline_region = None;
2315 self.reset_diff_strategy();
2316 }
2317
2318 self.writer().write_all(text.as_bytes())?;
2319 self.writer().flush()
2320 }
2321 ScreenMode::AltScreen => {
2322 Ok(())
2325 }
2326 }
2327 }
2328
2329 fn position_cursor_for_log(&mut self, ui_height: u16) -> io::Result<bool> {
2336 let visible_height = ui_height.min(self.term_height);
2337 if visible_height >= self.term_height {
2338 return Ok(false);
2340 }
2341
2342 let log_row = match self.ui_anchor {
2343 UiAnchor::Bottom => {
2344 self.term_height.saturating_sub(visible_height)
2347 }
2348 UiAnchor::Top => {
2349 self.term_height
2352 }
2353 };
2354
2355 write!(self.writer(), "\x1b[{};1H", log_row)?;
2357 Ok(true)
2358 }
2359
2360 pub fn clear_screen(&mut self) -> io::Result<()> {
2362 self.writer().write_all(b"\x1b[2J\x1b[1;1H")?;
2363 self.writer().flush()?;
2364 self.prev_buffer = None;
2365 self.last_inline_region = None;
2366 self.reset_diff_strategy();
2367 Ok(())
2368 }
2369
2370 fn set_cursor_visibility(&mut self, visible: bool) -> io::Result<()> {
2371 if self.cursor_visible == visible {
2372 return Ok(());
2373 }
2374 self.cursor_visible = visible;
2375 if visible {
2376 self.writer().write_all(b"\x1b[?25h")?;
2377 } else {
2378 self.writer().write_all(b"\x1b[?25l")?;
2379 }
2380 Ok(())
2381 }
2382
2383 pub fn hide_cursor(&mut self) -> io::Result<()> {
2385 self.set_cursor_visibility(false)?;
2386 self.writer().flush()
2387 }
2388
2389 pub fn show_cursor(&mut self) -> io::Result<()> {
2391 self.set_cursor_visibility(true)?;
2392 self.writer().flush()
2393 }
2394
2395 pub fn flush(&mut self) -> io::Result<()> {
2397 self.writer().flush()
2398 }
2399
2400 pub fn pool(&self) -> &GraphemePool {
2402 &self.pool
2403 }
2404
2405 pub fn pool_mut(&mut self) -> &mut GraphemePool {
2407 &mut self.pool
2408 }
2409
2410 pub fn links(&self) -> &LinkRegistry {
2412 &self.links
2413 }
2414
2415 pub fn links_mut(&mut self) -> &mut LinkRegistry {
2417 &mut self.links
2418 }
2419
2420 pub fn pool_and_links_mut(&mut self) -> (&mut GraphemePool, &mut LinkRegistry) {
2424 (&mut self.pool, &mut self.links)
2425 }
2426
2427 pub fn capabilities(&self) -> &TerminalCapabilities {
2429 &self.capabilities
2430 }
2431
2432 pub fn into_inner(mut self) -> Option<W> {
2437 self.cleanup();
2438 self.presenter.take()?.into_inner().ok()
2440 }
2441
2442 pub fn gc(&mut self, extra_buffer: Option<&Buffer>) {
2450 let mut buffers = Vec::with_capacity(2);
2451 if let Some(ref buf) = self.prev_buffer {
2452 buffers.push(buf);
2453 }
2454 if let Some(buf) = extra_buffer {
2455 buffers.push(buf);
2456 }
2457 self.pool.gc(&buffers);
2458 }
2459
2460 fn best_effort_inline_cleanup(&mut self) {
2464 let Some(ref mut presenter) = self.presenter else {
2465 return;
2466 };
2467 let writer = presenter.counting_writer_mut();
2468
2469 if self.in_sync_block {
2472 if self.capabilities.use_sync_output() {
2473 let _ = writer.write_all(SYNC_END);
2474 }
2475 self.in_sync_block = false;
2476 }
2477
2478 let _ = writer.write_all(CURSOR_RESTORE);
2479 self.cursor_saved = false;
2480
2481 let _ = writer.write_all(b"\x1b[r");
2482 self.scroll_region_active = false;
2483
2484 let _ = writer.write_all(b"\x1b[0m");
2485 let _ = writer.write_all(b"\x1b[?25h");
2486 self.cursor_visible = true;
2487 let _ = writer.flush();
2488 }
2489
2490 fn cleanup(&mut self) {
2492 let Some(ref mut presenter) = self.presenter else {
2493 return; };
2495 let writer = presenter.counting_writer_mut();
2496
2497 if self.in_sync_block {
2499 if self.capabilities.use_sync_output() {
2500 let _ = writer.write_all(SYNC_END);
2501 }
2502 self.in_sync_block = false;
2503 }
2504
2505 if self.cursor_saved {
2507 let _ = writer.write_all(CURSOR_RESTORE);
2508 self.cursor_saved = false;
2509 }
2510
2511 if self.scroll_region_active {
2513 let _ = writer.write_all(b"\x1b[r");
2514 self.scroll_region_active = false;
2515 }
2516
2517 let _ = writer.write_all(b"\x1b[0m");
2519
2520 let _ = writer.write_all(b"\x1b[?25h");
2522 self.cursor_visible = true;
2523
2524 let _ = writer.flush();
2526
2527 if let Some(ref mut trace) = self.render_trace {
2528 let _ = trace.finish(None);
2529 }
2530 }
2531}
2532
2533impl<W: Write> Drop for TerminalWriter<W> {
2534 fn drop(&mut self) {
2535 if matches!(
2537 self.screen_mode,
2538 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
2539 ) {
2540 INLINE_ACTIVE_WIDGETS.fetch_sub(1, Ordering::Relaxed);
2541 }
2542 self.cleanup();
2543 }
2544}
2545
2546#[cfg(test)]
2547mod tests {
2548 use super::*;
2549 use ftui_render::cell::{Cell, CellAttrs, CellContent, PackedRgba, StyleFlags};
2550 use std::path::PathBuf;
2551 use std::sync::atomic::{AtomicUsize, Ordering};
2552
2553 fn max_cursor_row(output: &[u8]) -> u16 {
2554 let mut max_row = 0u16;
2555 let mut i = 0;
2556 while i + 2 < output.len() {
2557 if output[i] == 0x1b && output[i + 1] == b'[' {
2558 let mut j = i + 2;
2559 let mut row: u16 = 0;
2560 let mut saw_row = false;
2561 while j < output.len() && output[j].is_ascii_digit() {
2562 saw_row = true;
2563 row = row
2564 .saturating_mul(10)
2565 .saturating_add((output[j] - b'0') as u16);
2566 j += 1;
2567 }
2568 if saw_row && j < output.len() && output[j] == b';' {
2569 j += 1;
2570 let mut saw_col = false;
2571 while j < output.len() && output[j].is_ascii_digit() {
2572 saw_col = true;
2573 j += 1;
2574 }
2575 if saw_col && j < output.len() && output[j] == b'H' {
2576 max_row = max_row.max(row);
2577 }
2578 }
2579 }
2580 i += 1;
2581 }
2582 max_row
2583 }
2584
2585 fn basic_caps() -> TerminalCapabilities {
2586 TerminalCapabilities::basic()
2587 }
2588
2589 fn full_caps() -> TerminalCapabilities {
2590 let mut caps = TerminalCapabilities::basic();
2591 caps.true_color = true;
2592 caps.sync_output = true;
2593 caps
2594 }
2595
2596 fn find_nth(haystack: &[u8], needle: &[u8], nth: usize) -> Option<usize> {
2597 if nth == 0 {
2598 return None;
2599 }
2600 let mut count = 0;
2601 let mut i = 0;
2602 while i + needle.len() <= haystack.len() {
2603 if &haystack[i..i + needle.len()] == needle {
2604 count += 1;
2605 if count == nth {
2606 return Some(i);
2607 }
2608 }
2609 i += 1;
2610 }
2611 None
2612 }
2613
2614 fn temp_evidence_path(label: &str) -> PathBuf {
2615 static COUNTER: AtomicUsize = AtomicUsize::new(0);
2616 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2617 let mut path = std::env::temp_dir();
2618 path.push(format!(
2619 "ftui_{}_{}_{}.jsonl",
2620 label,
2621 std::process::id(),
2622 id
2623 ));
2624 path
2625 }
2626
2627 #[test]
2628 fn new_creates_writer() {
2629 let output = Vec::new();
2630 let writer = TerminalWriter::new(
2631 output,
2632 ScreenMode::Inline { ui_height: 10 },
2633 UiAnchor::Bottom,
2634 basic_caps(),
2635 );
2636 assert_eq!(writer.ui_height(), 10);
2637 }
2638
2639 #[test]
2640 fn ui_start_row_bottom_anchor() {
2641 let output = Vec::new();
2642 let mut writer = TerminalWriter::new(
2643 output,
2644 ScreenMode::Inline { ui_height: 10 },
2645 UiAnchor::Bottom,
2646 basic_caps(),
2647 );
2648 writer.set_size(80, 24);
2649 assert_eq!(writer.ui_start_row(), 14); }
2651
2652 #[test]
2653 fn ui_start_row_top_anchor() {
2654 let output = Vec::new();
2655 let mut writer = TerminalWriter::new(
2656 output,
2657 ScreenMode::Inline { ui_height: 10 },
2658 UiAnchor::Top,
2659 basic_caps(),
2660 );
2661 writer.set_size(80, 24);
2662 assert_eq!(writer.ui_start_row(), 0);
2663 }
2664
2665 #[test]
2666 fn ui_start_row_altscreen() {
2667 let output = Vec::new();
2668 let mut writer = TerminalWriter::new(
2669 output,
2670 ScreenMode::AltScreen,
2671 UiAnchor::Bottom,
2672 basic_caps(),
2673 );
2674 writer.set_size(80, 24);
2675 assert_eq!(writer.ui_start_row(), 0);
2676 }
2677
2678 #[test]
2679 fn present_ui_inline_saves_restores_cursor() {
2680 let mut output = Vec::new();
2681 {
2682 let mut writer = TerminalWriter::new(
2683 &mut output,
2684 ScreenMode::Inline { ui_height: 5 },
2685 UiAnchor::Bottom,
2686 basic_caps(),
2687 );
2688 writer.set_size(10, 10);
2689
2690 let buffer = Buffer::new(10, 5);
2691 writer.present_ui(&buffer, None, true).unwrap();
2692 }
2693
2694 assert!(output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE));
2696 assert!(
2697 output
2698 .windows(CURSOR_RESTORE.len())
2699 .any(|w| w == CURSOR_RESTORE)
2700 );
2701 }
2702
2703 #[test]
2704 fn present_ui_with_sync_output() {
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 full_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(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN));
2721 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2722 }
2723
2724 #[test]
2725 fn present_ui_altscreen_closes_stale_sync_block_when_policy_allows_sync() {
2726 let mut output = Vec::new();
2727 {
2728 let mut writer = TerminalWriter::new(
2729 &mut output,
2730 ScreenMode::AltScreen,
2731 UiAnchor::Bottom,
2732 full_caps(),
2733 );
2734 writer.set_size(8, 2);
2735 writer.in_sync_block = true;
2736
2737 let mut buffer = Buffer::new(8, 2);
2738 buffer.set_raw(0, 0, Cell::from_char('X'));
2739 writer.present_ui(&buffer, None, true).unwrap();
2740
2741 assert!(
2742 !writer.in_sync_block,
2743 "present_altscreen must close stale sync blocks"
2744 );
2745 }
2746
2747 assert!(
2748 output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2749 "sync end should be emitted when stale sync state is detected"
2750 );
2751 }
2752
2753 #[test]
2754 fn present_ui_altscreen_stale_sync_block_skips_sync_end_in_mux() {
2755 let mut output = Vec::new();
2756 {
2757 let mut writer = TerminalWriter::new(
2758 &mut output,
2759 ScreenMode::AltScreen,
2760 UiAnchor::Bottom,
2761 mux_caps(),
2762 );
2763 writer.set_size(8, 2);
2764 writer.in_sync_block = true;
2765
2766 let mut buffer = Buffer::new(8, 2);
2767 buffer.set_raw(0, 0, Cell::from_char('X'));
2768 writer.present_ui(&buffer, None, true).unwrap();
2769
2770 assert!(
2771 !writer.in_sync_block,
2772 "present_altscreen must clear stale sync state"
2773 );
2774 }
2775
2776 assert!(
2777 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2778 "sync end must be suppressed when policy disables synchronized output"
2779 );
2780 }
2781
2782 #[test]
2783 fn present_ui_altscreen_sanitizes_grapheme_escape_payloads() {
2784 let mut output = Vec::new();
2785 {
2786 let mut writer = TerminalWriter::new(
2787 &mut output,
2788 ScreenMode::AltScreen,
2789 UiAnchor::Bottom,
2790 basic_caps(),
2791 );
2792 writer.set_size(12, 1);
2793
2794 let gid = writer
2795 .pool_mut()
2796 .intern("ok\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}", 6);
2797 let mut buffer = Buffer::new(12, 1);
2798 buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
2799
2800 writer.present_ui(&buffer, None, true).unwrap();
2801 }
2802
2803 let output_str = String::from_utf8_lossy(&output);
2804 assert!(
2805 output_str.contains("oktail"),
2806 "sanitized grapheme content should preserve visible payload"
2807 );
2808 assert!(
2809 !output_str.contains("52;c;SGVsbG8"),
2810 "OSC payload must not be forwarded by alt-screen emitter"
2811 );
2812 assert!(
2813 !output_str.contains('\u{009d}'),
2814 "C1 controls must be stripped from alt-screen grapheme output"
2815 );
2816 }
2817
2818 #[test]
2819 fn present_ui_inline_skips_sync_output_in_mux() {
2820 let mut output = Vec::new();
2821 {
2822 let mut writer = TerminalWriter::new(
2823 &mut output,
2824 ScreenMode::Inline { ui_height: 5 },
2825 UiAnchor::Bottom,
2826 mux_caps(),
2827 );
2828 writer.set_size(10, 10);
2829
2830 let buffer = Buffer::new(10, 5);
2831 writer.present_ui(&buffer, None, true).unwrap();
2832 }
2833
2834 assert!(
2835 !output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN),
2836 "sync begin must be suppressed in tmux/screen/zellij environments"
2837 );
2838 assert!(
2839 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2840 "sync end must be suppressed in tmux/screen/zellij environments"
2841 );
2842 }
2843
2844 #[test]
2845 fn present_ui_altscreen_skips_sync_output_in_mux() {
2846 let mut output = Vec::new();
2847 {
2848 let mut writer = TerminalWriter::new(
2849 &mut output,
2850 ScreenMode::AltScreen,
2851 UiAnchor::Bottom,
2852 mux_caps(),
2853 );
2854 writer.set_size(10, 10);
2855
2856 let buffer = Buffer::new(10, 5);
2857 writer.present_ui(&buffer, None, true).unwrap();
2858 }
2859
2860 assert!(
2861 !output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN),
2862 "sync begin must be suppressed in tmux/screen/zellij environments"
2863 );
2864 assert!(
2865 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2866 "sync end must be suppressed in tmux/screen/zellij environments"
2867 );
2868 }
2869
2870 #[test]
2871 fn present_ui_inline_skips_hyperlinks_in_mux() {
2872 let mut output = Vec::new();
2873 {
2874 let mut caps = mux_caps();
2875 caps.osc8_hyperlinks = true;
2876
2877 let mut writer = TerminalWriter::new(
2878 &mut output,
2879 ScreenMode::Inline { ui_height: 2 },
2880 UiAnchor::Bottom,
2881 caps,
2882 );
2883 writer.set_size(8, 4);
2884
2885 let link_id = writer.links_mut().register("https://example.com");
2886 let mut buffer = Buffer::new(8, 2);
2887 buffer.set_raw(
2888 0,
2889 0,
2890 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2891 );
2892 writer.present_ui(&buffer, None, true).unwrap();
2893 }
2894
2895 assert!(
2896 !output.windows(b"\x1b]8;".len()).any(|w| w == b"\x1b]8;"),
2897 "OSC 8 sequences must be suppressed by mux hyperlink policy"
2898 );
2899 }
2900
2901 #[test]
2902 fn present_ui_altscreen_skips_hyperlinks_in_mux() {
2903 let mut output = Vec::new();
2904 {
2905 let mut caps = mux_caps();
2906 caps.osc8_hyperlinks = true;
2907
2908 let mut writer =
2909 TerminalWriter::new(&mut output, ScreenMode::AltScreen, UiAnchor::Bottom, caps);
2910 writer.set_size(8, 4);
2911
2912 let link_id = writer.links_mut().register("https://example.com");
2913 let mut buffer = Buffer::new(8, 2);
2914 buffer.set_raw(
2915 0,
2916 0,
2917 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2918 );
2919 writer.present_ui(&buffer, None, true).unwrap();
2920 }
2921
2922 assert!(
2923 !output.windows(b"\x1b]8;".len()).any(|w| w == b"\x1b]8;"),
2924 "OSC 8 sequences must be suppressed by mux hyperlink policy"
2925 );
2926 }
2927
2928 #[test]
2929 fn present_ui_hides_cursor_when_requested() {
2930 let mut output = Vec::new();
2931 {
2932 let mut writer = TerminalWriter::new(
2933 &mut output,
2934 ScreenMode::AltScreen,
2935 UiAnchor::Bottom,
2936 basic_caps(),
2937 );
2938 writer.set_size(10, 5);
2939
2940 let buffer = Buffer::new(10, 5);
2941 writer.present_ui(&buffer, None, false).unwrap();
2942 }
2943
2944 assert!(
2945 output.windows(6).any(|w| w == b"\x1b[?25l"),
2946 "expected cursor hide sequence"
2947 );
2948 }
2949
2950 #[test]
2951 fn present_ui_visible_does_not_hide_cursor() {
2952 let mut output = Vec::new();
2953 {
2954 let mut writer = TerminalWriter::new(
2955 &mut output,
2956 ScreenMode::AltScreen,
2957 UiAnchor::Bottom,
2958 basic_caps(),
2959 );
2960 writer.set_size(10, 5);
2961
2962 let buffer = Buffer::new(10, 5);
2963 writer.present_ui(&buffer, None, true).unwrap();
2964 }
2965
2966 assert!(
2967 !output.windows(6).any(|w| w == b"\x1b[?25l"),
2968 "did not expect cursor hide sequence"
2969 );
2970 }
2971
2972 #[test]
2973 fn write_log_in_inline_mode() {
2974 let mut output = Vec::new();
2975 {
2976 let mut writer = TerminalWriter::new(
2977 &mut output,
2978 ScreenMode::Inline { ui_height: 5 },
2979 UiAnchor::Bottom,
2980 basic_caps(),
2981 );
2982 writer.write_log("test log\n").unwrap();
2983 }
2984
2985 let output_str = String::from_utf8_lossy(&output);
2986 assert!(output_str.contains("test log"));
2987 }
2988
2989 #[test]
2990 fn write_log_in_altscreen_is_noop() {
2991 let mut output = Vec::new();
2992 {
2993 let mut writer = TerminalWriter::new(
2994 &mut output,
2995 ScreenMode::AltScreen,
2996 UiAnchor::Bottom,
2997 basic_caps(),
2998 );
2999 writer.write_log("test log\n").unwrap();
3000 }
3001
3002 let output_str = String::from_utf8_lossy(&output);
3003 assert!(!output_str.contains("test log"));
3005 }
3006
3007 #[test]
3008 fn clear_screen_resets_prev_buffer() {
3009 let mut output = Vec::new();
3010 let mut writer = TerminalWriter::new(
3011 &mut output,
3012 ScreenMode::AltScreen,
3013 UiAnchor::Bottom,
3014 basic_caps(),
3015 );
3016
3017 let buffer = Buffer::new(10, 5);
3019 writer.present_ui(&buffer, None, true).unwrap();
3020 assert!(writer.prev_buffer.is_some());
3021
3022 writer.clear_screen().unwrap();
3024 assert!(writer.prev_buffer.is_none());
3025 }
3026
3027 #[test]
3028 fn set_size_clears_prev_buffer() {
3029 let output = Vec::new();
3030 let mut writer = TerminalWriter::new(
3031 output,
3032 ScreenMode::AltScreen,
3033 UiAnchor::Bottom,
3034 basic_caps(),
3035 );
3036
3037 writer.prev_buffer = Some(Buffer::new(10, 10));
3038 writer.set_size(20, 20);
3039
3040 assert!(writer.prev_buffer.is_none());
3041 }
3042
3043 #[test]
3044 fn inline_auto_resize_clears_cached_height() {
3045 let output = Vec::new();
3046 let mut writer = TerminalWriter::new(
3047 output,
3048 ScreenMode::InlineAuto {
3049 min_height: 3,
3050 max_height: 8,
3051 },
3052 UiAnchor::Bottom,
3053 basic_caps(),
3054 );
3055
3056 writer.set_size(80, 24);
3057 writer.set_auto_ui_height(6);
3058 assert_eq!(writer.auto_ui_height(), Some(6));
3059 assert_eq!(writer.render_height_hint(), 6);
3060
3061 writer.set_size(100, 30);
3062 assert_eq!(writer.auto_ui_height(), None);
3063 assert_eq!(writer.render_height_hint(), 8);
3064 }
3065
3066 #[test]
3067 fn drop_cleanup_restores_cursor() {
3068 let mut output = Vec::new();
3069 {
3070 let mut writer = TerminalWriter::new(
3071 &mut output,
3072 ScreenMode::Inline { ui_height: 5 },
3073 UiAnchor::Bottom,
3074 basic_caps(),
3075 );
3076 writer.cursor_saved = true;
3077 }
3079
3080 assert!(
3082 output
3083 .windows(CURSOR_RESTORE.len())
3084 .any(|w| w == CURSOR_RESTORE)
3085 );
3086 }
3087
3088 #[test]
3089 fn drop_cleanup_ends_sync_block() {
3090 let mut output = Vec::new();
3091 {
3092 let mut writer = TerminalWriter::new(
3093 &mut output,
3094 ScreenMode::Inline { ui_height: 5 },
3095 UiAnchor::Bottom,
3096 full_caps(),
3097 );
3098 writer.in_sync_block = true;
3099 }
3101
3102 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
3104 }
3105
3106 #[test]
3107 fn drop_cleanup_skips_sync_end_in_mux_even_with_stale_state() {
3108 let mut output = Vec::new();
3109 {
3110 let mut writer = TerminalWriter::new(
3111 &mut output,
3112 ScreenMode::Inline { ui_height: 5 },
3113 UiAnchor::Bottom,
3114 mux_caps(),
3115 );
3116 writer.in_sync_block = true;
3117 }
3119
3120 assert!(
3121 !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
3122 "drop cleanup must not emit sync_end in mux environments"
3123 );
3124 }
3125
3126 #[test]
3127 fn present_multiple_frames_uses_diff() {
3128 use std::io::Cursor;
3129
3130 let output = Cursor::new(Vec::new());
3132 let mut writer = TerminalWriter::new(
3133 output,
3134 ScreenMode::AltScreen,
3135 UiAnchor::Bottom,
3136 basic_caps(),
3137 );
3138 writer.set_size(10, 5);
3139
3140 let mut buffer1 = Buffer::new(10, 5);
3142 buffer1.set_raw(0, 0, Cell::from_char('A'));
3143 writer.present_ui(&buffer1, None, true).unwrap();
3144
3145 writer.present_ui(&buffer1, None, true).unwrap();
3147
3148 let mut buffer2 = buffer1.clone();
3150 buffer2.set_raw(1, 0, Cell::from_char('B'));
3151 writer.present_ui(&buffer2, None, true).unwrap();
3152
3153 }
3156
3157 #[test]
3158 fn cell_content_rendered_correctly() {
3159 let mut output = Vec::new();
3160 {
3161 let mut writer = TerminalWriter::new(
3162 &mut output,
3163 ScreenMode::AltScreen,
3164 UiAnchor::Bottom,
3165 basic_caps(),
3166 );
3167 writer.set_size(10, 5);
3168
3169 let mut buffer = Buffer::new(10, 5);
3170 buffer.set_raw(0, 0, Cell::from_char('H'));
3171 buffer.set_raw(1, 0, Cell::from_char('i'));
3172 buffer.set_raw(2, 0, Cell::from_char('!'));
3173 writer.present_ui(&buffer, None, true).unwrap();
3174 }
3175
3176 let output_str = String::from_utf8_lossy(&output);
3177 assert!(output_str.contains('H'));
3178 assert!(output_str.contains('i'));
3179 assert!(output_str.contains('!'));
3180 }
3181
3182 #[test]
3183 fn resize_reanchors_ui_region() {
3184 let output = Vec::new();
3185 let mut writer = TerminalWriter::new(
3186 output,
3187 ScreenMode::Inline { ui_height: 10 },
3188 UiAnchor::Bottom,
3189 basic_caps(),
3190 );
3191
3192 writer.set_size(80, 24);
3194 assert_eq!(writer.ui_start_row(), 14);
3195
3196 writer.set_size(80, 40);
3198 assert_eq!(writer.ui_start_row(), 30);
3199
3200 writer.set_size(80, 15);
3202 assert_eq!(writer.ui_start_row(), 5);
3203 }
3204
3205 #[test]
3206 fn inline_auto_height_clamps_and_uses_max_for_render() {
3207 let output = Vec::new();
3208 let mut writer = TerminalWriter::new(
3209 output,
3210 ScreenMode::InlineAuto {
3211 min_height: 3,
3212 max_height: 8,
3213 },
3214 UiAnchor::Bottom,
3215 basic_caps(),
3216 );
3217 writer.set_size(80, 24);
3218
3219 assert_eq!(writer.ui_height(), 3);
3221 assert_eq!(writer.auto_ui_height(), None);
3222
3223 assert_eq!(writer.render_height_hint(), 8);
3225
3226 writer.set_auto_ui_height(6);
3228 assert_eq!(writer.render_height_hint(), 6);
3229
3230 writer.clear_auto_ui_height();
3232 assert_eq!(writer.render_height_hint(), 8);
3233
3234 writer.set_auto_ui_height(3);
3236 assert_eq!(writer.auto_ui_height(), Some(3));
3237 assert_eq!(writer.ui_height(), 3);
3238
3239 writer.clear_auto_ui_height();
3240 assert_eq!(writer.render_height_hint(), 8);
3241
3242 writer.set_auto_ui_height(10);
3244 assert_eq!(writer.ui_height(), 8);
3245
3246 writer.set_auto_ui_height(1);
3248 assert_eq!(writer.ui_height(), 3);
3249 }
3250
3251 #[test]
3252 fn resize_with_top_anchor_stays_at_zero() {
3253 let output = Vec::new();
3254 let mut writer = TerminalWriter::new(
3255 output,
3256 ScreenMode::Inline { ui_height: 10 },
3257 UiAnchor::Top,
3258 basic_caps(),
3259 );
3260
3261 writer.set_size(80, 24);
3262 assert_eq!(writer.ui_start_row(), 0);
3263
3264 writer.set_size(80, 40);
3265 assert_eq!(writer.ui_start_row(), 0);
3266 }
3267
3268 #[test]
3269 fn inline_mode_never_clears_full_screen() {
3270 let mut output = Vec::new();
3271 {
3272 let mut writer = TerminalWriter::new(
3273 &mut output,
3274 ScreenMode::Inline { ui_height: 5 },
3275 UiAnchor::Bottom,
3276 basic_caps(),
3277 );
3278 writer.set_size(10, 10);
3279
3280 let buffer = Buffer::new(10, 5);
3281 writer.present_ui(&buffer, None, true).unwrap();
3282 }
3283
3284 let has_ed2 = output.windows(4).any(|w| w == b"\x1b[2J");
3286 assert!(!has_ed2, "Inline mode should never use full screen clear");
3287
3288 assert!(output.windows(ERASE_LINE.len()).any(|w| w == ERASE_LINE));
3290 }
3291
3292 #[test]
3293 fn present_after_log_maintains_cursor_position() {
3294 let mut output = Vec::new();
3295 {
3296 let mut writer = TerminalWriter::new(
3297 &mut output,
3298 ScreenMode::Inline { ui_height: 5 },
3299 UiAnchor::Bottom,
3300 basic_caps(),
3301 );
3302 writer.set_size(10, 10);
3303
3304 let buffer = Buffer::new(10, 5);
3306 writer.present_ui(&buffer, None, true).unwrap();
3307
3308 writer.write_log("log line\n").unwrap();
3310
3311 writer.present_ui(&buffer, None, true).unwrap();
3313 }
3314
3315 let save_count = output
3317 .windows(CURSOR_SAVE.len())
3318 .filter(|w| *w == CURSOR_SAVE)
3319 .count();
3320 assert_eq!(save_count, 2, "Should have saved cursor twice");
3321
3322 let restore_count = output
3324 .windows(CURSOR_RESTORE.len())
3325 .filter(|w| *w == CURSOR_RESTORE)
3326 .count();
3327 assert!(
3329 restore_count >= 2,
3330 "Should have restored cursor at least twice"
3331 );
3332 }
3333
3334 #[test]
3335 fn ui_height_bounds_check() {
3336 let output = Vec::new();
3337 let mut writer = TerminalWriter::new(
3338 output,
3339 ScreenMode::Inline { ui_height: 100 },
3340 UiAnchor::Bottom,
3341 basic_caps(),
3342 );
3343
3344 writer.set_size(80, 10);
3346
3347 assert_eq!(writer.ui_start_row(), 0);
3349 }
3350
3351 #[test]
3352 fn inline_ui_height_clamped_to_terminal_height() {
3353 let mut output = Vec::new();
3354 {
3355 let mut writer = TerminalWriter::new(
3356 &mut output,
3357 ScreenMode::Inline { ui_height: 10 },
3358 UiAnchor::Bottom,
3359 basic_caps(),
3360 );
3361 writer.set_size(8, 3);
3362 let buffer = Buffer::new(8, 10);
3363 writer.present_ui(&buffer, None, true).unwrap();
3364 }
3365
3366 let max_row = max_cursor_row(&output);
3367 assert!(
3368 max_row <= 3,
3369 "cursor row {} exceeds terminal height",
3370 max_row
3371 );
3372 }
3373
3374 #[test]
3375 fn inline_shrink_clears_stale_rows() {
3376 let mut output = Vec::new();
3377 {
3378 let mut writer = TerminalWriter::new(
3379 &mut output,
3380 ScreenMode::InlineAuto {
3381 min_height: 1,
3382 max_height: 6,
3383 },
3384 UiAnchor::Bottom,
3385 basic_caps(),
3386 );
3387 writer.set_size(10, 10);
3388
3389 let buffer = Buffer::new(10, 6);
3390 writer.set_auto_ui_height(6);
3391 writer.present_ui(&buffer, None, true).unwrap();
3392
3393 writer.set_auto_ui_height(3);
3394 writer.present_ui(&buffer, None, true).unwrap();
3395 }
3396
3397 let second_save = find_nth(&output, CURSOR_SAVE, 2).expect("expected second cursor save");
3398 let after_save = &output[second_save..];
3399 let restore_idx = after_save
3400 .windows(CURSOR_RESTORE.len())
3401 .position(|w| w == CURSOR_RESTORE)
3402 .expect("expected cursor restore after second save");
3403 let segment = &after_save[..restore_idx];
3404 let erase_count = segment
3405 .windows(ERASE_LINE.len())
3406 .filter(|w| *w == ERASE_LINE)
3407 .count();
3408
3409 assert_eq!(erase_count, 6, "expected clears for stale + new rows");
3410 }
3411
3412 fn scroll_region_caps() -> TerminalCapabilities {
3416 let mut caps = TerminalCapabilities::basic();
3417 caps.scroll_region = true;
3418 caps.sync_output = true;
3419 caps
3420 }
3421
3422 fn hybrid_caps() -> TerminalCapabilities {
3424 let mut caps = TerminalCapabilities::basic();
3425 caps.scroll_region = true;
3426 caps
3427 }
3428
3429 fn mux_caps() -> TerminalCapabilities {
3431 let mut caps = TerminalCapabilities::basic();
3432 caps.scroll_region = true;
3433 caps.sync_output = true;
3434 caps.in_tmux = true;
3435 caps
3436 }
3437
3438 #[test]
3439 fn scroll_region_bounds_bottom_anchor() {
3440 let mut output = Vec::new();
3441 {
3442 let mut writer = TerminalWriter::new(
3443 &mut output,
3444 ScreenMode::Inline { ui_height: 5 },
3445 UiAnchor::Bottom,
3446 scroll_region_caps(),
3447 );
3448 writer.set_size(10, 10);
3449 let buffer = Buffer::new(10, 5);
3450 writer.present_ui(&buffer, None, true).unwrap();
3451 }
3452
3453 let seq = b"\x1b[1;5r";
3454 assert!(
3455 output.windows(seq.len()).any(|w| w == seq),
3456 "expected scroll region for bottom anchor"
3457 );
3458 }
3459
3460 #[test]
3461 fn scroll_region_bounds_top_anchor() {
3462 let mut output = Vec::new();
3463 {
3464 let mut writer = TerminalWriter::new(
3465 &mut output,
3466 ScreenMode::Inline { ui_height: 5 },
3467 UiAnchor::Top,
3468 scroll_region_caps(),
3469 );
3470 writer.set_size(10, 10);
3471 let buffer = Buffer::new(10, 5);
3472 writer.present_ui(&buffer, None, true).unwrap();
3473 }
3474
3475 let seq = b"\x1b[6;10r";
3476 assert!(
3477 output.windows(seq.len()).any(|w| w == seq),
3478 "expected scroll region for top anchor"
3479 );
3480 let cursor_seq = b"\x1b[6;1H";
3481 assert!(
3482 output.windows(cursor_seq.len()).any(|w| w == cursor_seq),
3483 "expected cursor move into log region for top anchor"
3484 );
3485 }
3486
3487 #[test]
3488 fn present_ui_inline_resets_style_before_cursor_restore() {
3489 let mut output = Vec::new();
3490 {
3491 let mut writer = TerminalWriter::new(
3492 &mut output,
3493 ScreenMode::Inline { ui_height: 2 },
3494 UiAnchor::Bottom,
3495 basic_caps(),
3496 );
3497 writer.set_size(5, 5);
3498 let mut buffer = Buffer::new(5, 2);
3499 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(PackedRgba::RED));
3500 writer.present_ui(&buffer, None, true).unwrap();
3501 }
3502
3503 let seq = b"\x1b[0m\x1b8";
3504 assert!(
3505 output.windows(seq.len()).any(|w| w == seq),
3506 "expected SGR reset before cursor restore in inline mode"
3507 );
3508 }
3509
3510 #[test]
3511 fn strategy_selected_from_capabilities() {
3512 let w = TerminalWriter::new(
3514 Vec::new(),
3515 ScreenMode::Inline { ui_height: 5 },
3516 UiAnchor::Bottom,
3517 basic_caps(),
3518 );
3519 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3520
3521 let w = TerminalWriter::new(
3523 Vec::new(),
3524 ScreenMode::Inline { ui_height: 5 },
3525 UiAnchor::Bottom,
3526 scroll_region_caps(),
3527 );
3528 assert_eq!(w.inline_strategy(), InlineStrategy::ScrollRegion);
3529
3530 let w = TerminalWriter::new(
3532 Vec::new(),
3533 ScreenMode::Inline { ui_height: 5 },
3534 UiAnchor::Bottom,
3535 hybrid_caps(),
3536 );
3537 assert_eq!(w.inline_strategy(), InlineStrategy::Hybrid);
3538
3539 let w = TerminalWriter::new(
3541 Vec::new(),
3542 ScreenMode::Inline { ui_height: 5 },
3543 UiAnchor::Bottom,
3544 mux_caps(),
3545 );
3546 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3547 }
3548
3549 #[test]
3550 fn scroll_region_activated_on_present() {
3551 let mut output = Vec::new();
3552 {
3553 let mut writer = TerminalWriter::new(
3554 &mut output,
3555 ScreenMode::Inline { ui_height: 5 },
3556 UiAnchor::Bottom,
3557 scroll_region_caps(),
3558 );
3559 writer.set_size(80, 24);
3560 assert!(!writer.scroll_region_active());
3561
3562 let buffer = Buffer::new(80, 5);
3563 writer.present_ui(&buffer, None, true).unwrap();
3564 assert!(writer.scroll_region_active());
3565 }
3566
3567 let expected = b"\x1b[1;19r";
3569 assert!(
3570 output.windows(expected.len()).any(|w| w == expected),
3571 "Should set scroll region to rows 1-19"
3572 );
3573 }
3574
3575 #[test]
3576 fn scroll_region_not_activated_for_overlay() {
3577 let mut output = Vec::new();
3578 {
3579 let mut writer = TerminalWriter::new(
3580 &mut output,
3581 ScreenMode::Inline { ui_height: 5 },
3582 UiAnchor::Bottom,
3583 basic_caps(),
3584 );
3585 writer.set_size(80, 24);
3586
3587 let buffer = Buffer::new(80, 5);
3588 writer.present_ui(&buffer, None, true).unwrap();
3589 assert!(!writer.scroll_region_active());
3590 }
3591
3592 let decstbm = b"\x1b[1;19r";
3594 assert!(
3595 !output.windows(decstbm.len()).any(|w| w == decstbm),
3596 "OverlayRedraw should not set scroll region"
3597 );
3598 }
3599
3600 #[test]
3601 fn scroll_region_not_activated_in_mux() {
3602 let mut output = Vec::new();
3603 {
3604 let mut writer = TerminalWriter::new(
3605 &mut output,
3606 ScreenMode::Inline { ui_height: 5 },
3607 UiAnchor::Bottom,
3608 mux_caps(),
3609 );
3610 writer.set_size(80, 24);
3611
3612 let buffer = Buffer::new(80, 5);
3613 writer.present_ui(&buffer, None, true).unwrap();
3614 assert!(!writer.scroll_region_active());
3615 }
3616
3617 let decstbm = b"\x1b[1;19r";
3619 assert!(
3620 !output.windows(decstbm.len()).any(|w| w == decstbm),
3621 "Mux environment should not use scroll region"
3622 );
3623 }
3624
3625 #[test]
3626 fn scroll_region_reset_on_cleanup() {
3627 let mut output = Vec::new();
3628 {
3629 let mut writer = TerminalWriter::new(
3630 &mut output,
3631 ScreenMode::Inline { ui_height: 5 },
3632 UiAnchor::Bottom,
3633 scroll_region_caps(),
3634 );
3635 writer.set_size(80, 24);
3636
3637 let buffer = Buffer::new(80, 5);
3638 writer.present_ui(&buffer, None, true).unwrap();
3639 }
3641
3642 let reset = b"\x1b[r";
3644 assert!(
3645 output.windows(reset.len()).any(|w| w == reset),
3646 "Cleanup should reset scroll region"
3647 );
3648 }
3649
3650 #[test]
3651 fn scroll_region_reset_on_resize() {
3652 let output = Vec::new();
3653 let mut writer = TerminalWriter::new(
3654 output,
3655 ScreenMode::Inline { ui_height: 5 },
3656 UiAnchor::Bottom,
3657 scroll_region_caps(),
3658 );
3659 writer.set_size(80, 24);
3660
3661 writer.activate_scroll_region(5).unwrap();
3663 assert!(writer.scroll_region_active());
3664
3665 writer.set_size(80, 40);
3667 assert!(!writer.scroll_region_active());
3668 }
3669
3670 #[test]
3671 fn scroll_region_reactivated_after_resize() {
3672 let mut output = Vec::new();
3673 {
3674 let mut writer = TerminalWriter::new(
3675 &mut output,
3676 ScreenMode::Inline { ui_height: 5 },
3677 UiAnchor::Bottom,
3678 scroll_region_caps(),
3679 );
3680 writer.set_size(80, 24);
3681
3682 let buffer = Buffer::new(80, 5);
3684 writer.present_ui(&buffer, None, true).unwrap();
3685 assert!(writer.scroll_region_active());
3686
3687 writer.set_size(80, 40);
3689 assert!(!writer.scroll_region_active());
3690
3691 let buffer2 = Buffer::new(80, 5);
3693 writer.present_ui(&buffer2, None, true).unwrap();
3694 assert!(writer.scroll_region_active());
3695 }
3696
3697 let new_region = b"\x1b[1;35r";
3699 assert!(
3700 output.windows(new_region.len()).any(|w| w == new_region),
3701 "Should set scroll region to new dimensions after resize"
3702 );
3703 }
3704
3705 #[test]
3706 fn hybrid_strategy_activates_scroll_region() {
3707 let mut output = Vec::new();
3708 {
3709 let mut writer = TerminalWriter::new(
3710 &mut output,
3711 ScreenMode::Inline { ui_height: 5 },
3712 UiAnchor::Bottom,
3713 hybrid_caps(),
3714 );
3715 writer.set_size(80, 24);
3716
3717 let buffer = Buffer::new(80, 5);
3718 writer.present_ui(&buffer, None, true).unwrap();
3719 assert!(writer.scroll_region_active());
3720 }
3721
3722 let expected = b"\x1b[1;19r";
3724 assert!(
3725 output.windows(expected.len()).any(|w| w == expected),
3726 "Hybrid should activate scroll region as optimization"
3727 );
3728 }
3729
3730 #[test]
3731 fn altscreen_does_not_activate_scroll_region() {
3732 let output = Vec::new();
3733 let mut writer = TerminalWriter::new(
3734 output,
3735 ScreenMode::AltScreen,
3736 UiAnchor::Bottom,
3737 scroll_region_caps(),
3738 );
3739 writer.set_size(80, 24);
3740
3741 let buffer = Buffer::new(80, 24);
3742 writer.present_ui(&buffer, None, true).unwrap();
3743 assert!(!writer.scroll_region_active());
3744 }
3745
3746 #[test]
3747 fn scroll_region_still_saves_restores_cursor() {
3748 let mut output = Vec::new();
3749 {
3750 let mut writer = TerminalWriter::new(
3751 &mut output,
3752 ScreenMode::Inline { ui_height: 5 },
3753 UiAnchor::Bottom,
3754 scroll_region_caps(),
3755 );
3756 writer.set_size(80, 24);
3757
3758 let buffer = Buffer::new(80, 5);
3759 writer.present_ui(&buffer, None, true).unwrap();
3760 }
3761
3762 assert!(
3764 output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE),
3765 "Scroll region mode should still save cursor"
3766 );
3767 assert!(
3768 output
3769 .windows(CURSOR_RESTORE.len())
3770 .any(|w| w == CURSOR_RESTORE),
3771 "Scroll region mode should still restore cursor"
3772 );
3773 }
3774
3775 #[test]
3778 fn write_log_positions_cursor_bottom_anchor() {
3779 let mut output = Vec::new();
3782 {
3783 let mut writer = TerminalWriter::new(
3784 &mut output,
3785 ScreenMode::Inline { ui_height: 5 },
3786 UiAnchor::Bottom,
3787 basic_caps(),
3788 );
3789 writer.set_size(80, 24);
3790 writer.write_log("test log\n").unwrap();
3791 }
3792
3793 let expected_pos = b"\x1b[19;1H";
3797 assert!(
3798 output
3799 .windows(expected_pos.len())
3800 .any(|w| w == expected_pos),
3801 "Log write should position cursor at row 19 for bottom anchor"
3802 );
3803 }
3804
3805 #[test]
3806 fn write_log_positions_cursor_top_anchor() {
3807 let mut output = Vec::new();
3810 {
3811 let mut writer = TerminalWriter::new(
3812 &mut output,
3813 ScreenMode::Inline { ui_height: 5 },
3814 UiAnchor::Top,
3815 basic_caps(),
3816 );
3817 writer.set_size(80, 24);
3818 writer.write_log("test log\n").unwrap();
3819 }
3820
3821 let expected_pos = b"\x1b[24;1H";
3825 assert!(
3826 output
3827 .windows(expected_pos.len())
3828 .any(|w| w == expected_pos),
3829 "Log write should position cursor at row 24 for top anchor"
3830 );
3831 }
3832
3833 #[test]
3834 fn write_log_contains_text() {
3835 let mut output = Vec::new();
3837 {
3838 let mut writer = TerminalWriter::new(
3839 &mut output,
3840 ScreenMode::Inline { ui_height: 5 },
3841 UiAnchor::Bottom,
3842 basic_caps(),
3843 );
3844 writer.set_size(80, 24);
3845 writer.write_log("hello world\n").unwrap();
3846 }
3847
3848 let output_str = String::from_utf8_lossy(&output);
3849 assert!(output_str.contains("hello world"));
3850 }
3851
3852 #[test]
3853 fn write_log_sanitizes_escape_injection_payloads() {
3854 let mut output = Vec::new();
3855 {
3856 let mut writer = TerminalWriter::new(
3857 &mut output,
3858 ScreenMode::Inline { ui_height: 5 },
3859 UiAnchor::Bottom,
3860 basic_caps(),
3861 );
3862 writer.set_size(80, 24);
3863 writer
3864 .write_log("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x\n")
3865 .unwrap();
3866 }
3867
3868 let output_str = String::from_utf8_lossy(&output);
3869 assert!(output_str.contains("safetailx"));
3870 assert!(
3871 !output_str.contains("52;c;SGVsbG8"),
3872 "OSC payload must not be forwarded to terminal output"
3873 );
3874 assert!(
3875 !output_str.contains('\u{009d}'),
3876 "C1 controls must be stripped from log output"
3877 );
3878 }
3879
3880 #[test]
3881 fn write_log_multiple_writes_position_each_time() {
3882 let mut output = Vec::new();
3884 {
3885 let mut writer = TerminalWriter::new(
3886 &mut output,
3887 ScreenMode::Inline { ui_height: 5 },
3888 UiAnchor::Bottom,
3889 basic_caps(),
3890 );
3891 writer.set_size(80, 24);
3892 writer.write_log("first\n").unwrap();
3893 writer.write_log("second\n").unwrap();
3894 }
3895
3896 let expected_pos = b"\x1b[19;1H";
3898 let count = output
3899 .windows(expected_pos.len())
3900 .filter(|w| *w == expected_pos)
3901 .count();
3902 assert_eq!(count, 2, "Should position cursor for each log write");
3903 }
3904
3905 #[test]
3906 fn write_log_after_present_ui_works_correctly() {
3907 let mut output = Vec::new();
3909 {
3910 let mut writer = TerminalWriter::new(
3911 &mut output,
3912 ScreenMode::Inline { ui_height: 5 },
3913 UiAnchor::Bottom,
3914 basic_caps(),
3915 );
3916 writer.set_size(80, 24);
3917
3918 let buffer = Buffer::new(80, 5);
3920 writer.present_ui(&buffer, None, true).unwrap();
3921
3922 writer.write_log("after UI\n").unwrap();
3924 }
3925
3926 let output_str = String::from_utf8_lossy(&output);
3927 assert!(output_str.contains("after UI"));
3928
3929 let expected_pos = b"\x1b[19;1H";
3931 assert!(
3933 output
3934 .windows(expected_pos.len())
3935 .any(|w| w == expected_pos),
3936 "Log write after present_ui should position cursor"
3937 );
3938 }
3939
3940 #[test]
3941 fn write_log_ui_fills_terminal_is_noop() {
3942 let mut output = Vec::new();
3946 {
3947 let mut writer = TerminalWriter::new(
3948 &mut output,
3949 ScreenMode::Inline { ui_height: 24 },
3950 UiAnchor::Bottom,
3951 basic_caps(),
3952 );
3953 writer.set_size(80, 24);
3954 writer.write_log("should still write\n").unwrap();
3955 }
3956 assert!(
3958 !output
3959 .windows(b"should still write".len())
3960 .any(|w| w == b"should still write"),
3961 "write_log should not emit log text when UI fills the terminal"
3962 );
3963 }
3964
3965 #[test]
3966 fn write_log_with_scroll_region_active() {
3967 let mut output = Vec::new();
3969 {
3970 let mut writer = TerminalWriter::new(
3971 &mut output,
3972 ScreenMode::Inline { ui_height: 5 },
3973 UiAnchor::Bottom,
3974 scroll_region_caps(),
3975 );
3976 writer.set_size(80, 24);
3977
3978 let buffer = Buffer::new(80, 5);
3980 writer.present_ui(&buffer, None, true).unwrap();
3981 assert!(writer.scroll_region_active());
3982
3983 writer.write_log("with scroll region\n").unwrap();
3985 }
3986
3987 let output_str = String::from_utf8_lossy(&output);
3988 assert!(output_str.contains("with scroll region"));
3989 }
3990
3991 #[test]
3992 fn log_write_cursor_position_not_in_ui_region_bottom_anchor() {
3993 let mut output = Vec::new();
3999 {
4000 let mut writer = TerminalWriter::new(
4001 &mut output,
4002 ScreenMode::Inline { ui_height: 5 },
4003 UiAnchor::Bottom,
4004 basic_caps(),
4005 );
4006 writer.set_size(80, 24);
4007 writer.write_log("test\n").unwrap();
4008 }
4009
4010 let mut found_row = None;
4013 let mut i = 0;
4014 while i + 2 < output.len() {
4015 if output[i] == 0x1b && output[i + 1] == b'[' {
4016 let mut j = i + 2;
4017 let mut row: u16 = 0;
4018 while j < output.len() && output[j].is_ascii_digit() {
4019 row = row * 10 + (output[j] - b'0') as u16;
4020 j += 1;
4021 }
4022 if j < output.len() && output[j] == b';' {
4023 j += 1;
4024 while j < output.len() && output[j].is_ascii_digit() {
4025 j += 1;
4026 }
4027 if j < output.len() && output[j] == b'H' {
4028 found_row = Some(row);
4029 }
4030 }
4031 }
4032 i += 1;
4033 }
4034
4035 if let Some(row) = found_row {
4036 assert!(
4038 row < 20,
4039 "Log cursor row {} should be below UI start row 20",
4040 row
4041 );
4042 }
4043 }
4044
4045 #[test]
4046 fn log_write_cursor_position_not_in_ui_region_top_anchor() {
4047 let mut output = Vec::new();
4053 {
4054 let mut writer = TerminalWriter::new(
4055 &mut output,
4056 ScreenMode::Inline { ui_height: 5 },
4057 UiAnchor::Top,
4058 basic_caps(),
4059 );
4060 writer.set_size(80, 24);
4061 writer.write_log("test\n").unwrap();
4062 }
4063
4064 let mut found_row = None;
4066 let mut i = 0;
4067 while i + 2 < output.len() {
4068 if output[i] == 0x1b && output[i + 1] == b'[' {
4069 let mut j = i + 2;
4070 let mut row: u16 = 0;
4071 while j < output.len() && output[j].is_ascii_digit() {
4072 row = row * 10 + (output[j] - b'0') as u16;
4073 j += 1;
4074 }
4075 if j < output.len() && output[j] == b';' {
4076 j += 1;
4077 while j < output.len() && output[j].is_ascii_digit() {
4078 j += 1;
4079 }
4080 if j < output.len() && output[j] == b'H' {
4081 found_row = Some(row);
4082 }
4083 }
4084 }
4085 i += 1;
4086 }
4087
4088 if let Some(row) = found_row {
4089 assert!(
4091 row > 5,
4092 "Log cursor row {} should be above UI end row 5",
4093 row
4094 );
4095 }
4096 }
4097
4098 #[test]
4099 fn present_ui_positions_cursor_after_restore() {
4100 let mut output = Vec::new();
4101 {
4102 let mut writer = TerminalWriter::new(
4103 &mut output,
4104 ScreenMode::Inline { ui_height: 5 },
4105 UiAnchor::Bottom,
4106 basic_caps(),
4107 );
4108 writer.set_size(80, 24);
4109
4110 let buffer = Buffer::new(80, 5);
4111 writer.present_ui(&buffer, Some((2, 1)), true).unwrap();
4113 }
4114
4115 let expected_pos = b"\x1b[21;3H";
4119
4120 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4122 let after_restore = &output[restore_idx..];
4123
4124 assert!(
4126 after_restore
4127 .windows(expected_pos.len())
4128 .any(|w| w == expected_pos),
4129 "Cursor positioning should happen after restore"
4130 );
4131 }
4132
4133 #[test]
4138 fn runtime_diff_config_default() {
4139 let config = RuntimeDiffConfig::default();
4140 assert!(config.bayesian_enabled);
4141 assert!(config.dirty_rows_enabled);
4142 assert!(config.dirty_span_config.enabled);
4143 assert!(config.tile_diff_config.enabled);
4144 assert!(config.reset_on_resize);
4145 assert!(config.reset_on_invalidation);
4146 }
4147
4148 #[test]
4149 fn runtime_diff_config_builder() {
4150 let custom_span = DirtySpanConfig::default().with_max_spans_per_row(8);
4151 let tile_config = TileDiffConfig::default()
4152 .with_enabled(false)
4153 .with_tile_size(24, 12)
4154 .with_dense_tile_ratio(0.75)
4155 .with_max_tiles(2048);
4156 let config = RuntimeDiffConfig::new()
4157 .with_bayesian_enabled(false)
4158 .with_dirty_rows_enabled(false)
4159 .with_dirty_span_config(custom_span)
4160 .with_dirty_spans_enabled(false)
4161 .with_tile_diff_config(tile_config)
4162 .with_reset_on_resize(false)
4163 .with_reset_on_invalidation(false);
4164
4165 assert!(!config.bayesian_enabled);
4166 assert!(!config.dirty_rows_enabled);
4167 assert!(!config.dirty_span_config.enabled);
4168 assert_eq!(config.dirty_span_config.max_spans_per_row, 8);
4169 assert!(!config.tile_diff_config.enabled);
4170 assert_eq!(config.tile_diff_config.tile_w, 24);
4171 assert_eq!(config.tile_diff_config.tile_h, 12);
4172 assert_eq!(config.tile_diff_config.max_tiles, 2048);
4173 assert!(!config.reset_on_resize);
4174 assert!(!config.reset_on_invalidation);
4175 }
4176
4177 #[test]
4178 fn with_diff_config_applies_strategy_config() {
4179 use ftui_render::diff_strategy::DiffStrategyConfig;
4180
4181 let strategy_config = DiffStrategyConfig {
4182 prior_alpha: 5.0,
4183 prior_beta: 5.0,
4184 ..Default::default()
4185 };
4186
4187 let runtime_config =
4188 RuntimeDiffConfig::default().with_strategy_config(strategy_config.clone());
4189
4190 let writer = TerminalWriter::with_diff_config(
4191 Vec::<u8>::new(),
4192 ScreenMode::AltScreen,
4193 UiAnchor::Bottom,
4194 basic_caps(),
4195 runtime_config,
4196 );
4197
4198 let (alpha, beta) = writer.diff_strategy().posterior_params();
4200 assert!((alpha - 5.0).abs() < 0.001);
4201 assert!((beta - 5.0).abs() < 0.001);
4202 }
4203
4204 #[test]
4205 fn with_diff_config_applies_tile_config() {
4206 let tile_config = TileDiffConfig::default()
4207 .with_enabled(false)
4208 .with_tile_size(32, 16)
4209 .with_max_tiles(1024);
4210 let runtime_config = RuntimeDiffConfig::default().with_tile_diff_config(tile_config);
4211
4212 let mut writer = TerminalWriter::with_diff_config(
4213 Vec::<u8>::new(),
4214 ScreenMode::AltScreen,
4215 UiAnchor::Bottom,
4216 basic_caps(),
4217 runtime_config,
4218 );
4219
4220 let applied = writer.diff_scratch.tile_config_mut();
4221 assert!(!applied.enabled);
4222 assert_eq!(applied.tile_w, 32);
4223 assert_eq!(applied.tile_h, 16);
4224 assert_eq!(applied.max_tiles, 1024);
4225 }
4226
4227 #[test]
4228 fn diff_config_accessor() {
4229 let config = RuntimeDiffConfig::default().with_bayesian_enabled(false);
4230
4231 let writer = TerminalWriter::with_diff_config(
4232 Vec::<u8>::new(),
4233 ScreenMode::AltScreen,
4234 UiAnchor::Bottom,
4235 basic_caps(),
4236 config,
4237 );
4238
4239 assert!(!writer.diff_config().bayesian_enabled);
4240 }
4241
4242 #[test]
4243 fn last_diff_strategy_updates_after_present() {
4244 let mut output = Vec::new();
4245 let mut writer = TerminalWriter::with_diff_config(
4246 &mut output,
4247 ScreenMode::AltScreen,
4248 UiAnchor::Bottom,
4249 basic_caps(),
4250 RuntimeDiffConfig::default(),
4251 );
4252 writer.set_size(10, 3);
4253
4254 let mut buffer = Buffer::new(10, 3);
4255 buffer.set_raw(0, 0, Cell::from_char('X'));
4256
4257 assert!(writer.last_diff_strategy().is_none());
4258 writer.present_ui(&buffer, None, false).unwrap();
4259 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4260
4261 buffer.set_raw(1, 1, Cell::from_char('Y'));
4262 writer.present_ui(&buffer, None, false).unwrap();
4263 assert!(writer.last_diff_strategy().is_some());
4264 }
4265
4266 #[test]
4267 fn diff_decision_evidence_schema_includes_span_fields() {
4268 let evidence_path = temp_evidence_path("diff_decision_schema");
4269 let sink = EvidenceSink::from_config(
4270 &crate::evidence_sink::EvidenceSinkConfig::enabled_file(&evidence_path),
4271 )
4272 .expect("evidence sink config")
4273 .expect("evidence sink enabled");
4274
4275 let mut writer = TerminalWriter::with_diff_config(
4276 Vec::<u8>::new(),
4277 ScreenMode::AltScreen,
4278 UiAnchor::Bottom,
4279 basic_caps(),
4280 RuntimeDiffConfig::default(),
4281 )
4282 .with_evidence_sink(sink);
4283 writer.set_size(10, 3);
4284
4285 let mut buffer = Buffer::new(10, 3);
4286 buffer.set_raw(0, 0, Cell::from_char('X'));
4287 writer.present_ui(&buffer, None, false).unwrap();
4288
4289 buffer.set_raw(1, 1, Cell::from_char('Y'));
4290 writer.present_ui(&buffer, None, false).unwrap();
4291
4292 let jsonl = std::fs::read_to_string(&evidence_path).expect("read evidence jsonl");
4293 let line = jsonl
4294 .lines()
4295 .find(|line| line.contains("\"event\":\"diff_decision\""))
4296 .expect("diff_decision line");
4297 let value: serde_json::Value = serde_json::from_str(line).expect("valid json");
4298
4299 assert_eq!(
4300 value["schema_version"],
4301 crate::evidence_sink::EVIDENCE_SCHEMA_VERSION
4302 );
4303 assert_eq!(value["event"], "diff_decision");
4304 assert!(
4305 value["run_id"]
4306 .as_str()
4307 .map(|s| !s.is_empty())
4308 .unwrap_or(false),
4309 "run_id should be a non-empty string"
4310 );
4311 assert!(
4312 value["event_idx"].is_number(),
4313 "event_idx should be numeric"
4314 );
4315 assert_eq!(value["screen_mode"], "altscreen");
4316 assert!(value["cols"].is_number(), "cols should be numeric");
4317 assert!(value["rows"].is_number(), "rows should be numeric");
4318 assert!(
4319 value["span_count"].is_number(),
4320 "span_count should be numeric"
4321 );
4322 assert!(
4323 value["span_coverage_pct"].is_number(),
4324 "span_coverage_pct should be numeric"
4325 );
4326 assert!(
4327 value["tile_size"].is_number(),
4328 "tile_size should be numeric"
4329 );
4330 assert!(
4331 value["dirty_tile_count"].is_number(),
4332 "dirty_tile_count should be numeric"
4333 );
4334 assert!(
4335 value["skipped_tile_count"].is_number(),
4336 "skipped_tile_count should be numeric"
4337 );
4338 assert!(
4339 value["sat_build_cost_est"].is_number(),
4340 "sat_build_cost_est should be numeric"
4341 );
4342 assert!(
4343 value["fallback_reason"].is_string(),
4344 "fallback_reason should be string"
4345 );
4346 assert!(
4347 value["scan_cost_estimate"].is_number(),
4348 "scan_cost_estimate should be numeric"
4349 );
4350 assert!(
4351 value["max_span_len"].is_number(),
4352 "max_span_len should be numeric"
4353 );
4354 assert!(
4355 value["guard_reason"].is_string(),
4356 "guard_reason should be a string"
4357 );
4358 assert!(
4359 value["hysteresis_applied"].is_boolean(),
4360 "hysteresis_applied should be boolean"
4361 );
4362 assert!(
4363 value["hysteresis_ratio"].is_number(),
4364 "hysteresis_ratio should be numeric"
4365 );
4366 assert!(
4367 value["fallback_reason"].is_string(),
4368 "fallback_reason should be a string"
4369 );
4370 assert!(
4371 value["scan_cost_estimate"].is_number(),
4372 "scan_cost_estimate should be numeric"
4373 );
4374 }
4375
4376 #[test]
4377 fn diff_strategy_posterior_updates_with_total_cells() {
4378 let mut output = Vec::new();
4379 let mut writer = TerminalWriter::with_diff_config(
4380 &mut output,
4381 ScreenMode::AltScreen,
4382 UiAnchor::Bottom,
4383 basic_caps(),
4384 RuntimeDiffConfig::default(),
4385 );
4386 writer.set_size(10, 10);
4387
4388 let mut buffer = Buffer::new(10, 10);
4389 buffer.set_raw(0, 0, Cell::from_char('A'));
4390 writer.present_ui(&buffer, None, false).unwrap();
4391
4392 let mut buffer2 = Buffer::new(10, 10);
4393 for x in 0..10u16 {
4394 buffer2.set_raw(x, 0, Cell::from_char('X'));
4395 }
4396 writer.present_ui(&buffer2, None, false).unwrap();
4397
4398 let config = writer.diff_strategy().config().clone();
4399 let total_cells = 10usize * 10usize;
4400 let changed = 10usize;
4401 let alpha = config.prior_alpha * config.decay + changed as f64;
4402 let beta = config.prior_beta * config.decay + (total_cells - changed) as f64;
4403 let expected = alpha / (alpha + beta);
4404 let mean = writer.diff_strategy().posterior_mean();
4405 assert!(
4406 (mean - expected).abs() < 1e-9,
4407 "posterior mean should use total_cells; got {mean:.6}, expected {expected:.6}"
4408 );
4409 }
4410
4411 #[test]
4412 fn log_write_without_scroll_region_resets_diff_strategy() {
4413 let mut output = Vec::new();
4416 {
4417 let config = RuntimeDiffConfig::default();
4418 let mut writer = TerminalWriter::with_diff_config(
4419 &mut output,
4420 ScreenMode::Inline { ui_height: 5 },
4421 UiAnchor::Bottom,
4422 basic_caps(), config,
4424 );
4425 writer.set_size(80, 24);
4426
4427 let mut buffer = Buffer::new(80, 5);
4429 buffer.set_raw(0, 0, Cell::from_char('X'));
4430 writer.present_ui(&buffer, None, false).unwrap();
4431
4432 let (_alpha_before, _) = writer.diff_strategy().posterior_params();
4434
4435 buffer.set_raw(1, 1, Cell::from_char('Y'));
4437 writer.present_ui(&buffer, None, false).unwrap();
4438
4439 assert!(!writer.scroll_region_active());
4441 writer.write_log("log message\n").unwrap();
4442
4443 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4445 assert!(
4446 (alpha_after - 1.0).abs() < 0.01 && (beta_after - 19.0).abs() < 0.01,
4447 "posterior should reset to priors after log write: alpha={}, beta={}",
4448 alpha_after,
4449 beta_after
4450 );
4451 }
4452 }
4453
4454 #[test]
4455 fn log_write_with_scroll_region_preserves_diff_strategy() {
4456 let mut output = Vec::new();
4458 {
4459 let config = RuntimeDiffConfig::default();
4460 let mut writer = TerminalWriter::with_diff_config(
4461 &mut output,
4462 ScreenMode::Inline { ui_height: 5 },
4463 UiAnchor::Bottom,
4464 scroll_region_caps(), config,
4466 );
4467 writer.set_size(80, 24);
4468
4469 let mut buffer = Buffer::new(80, 5);
4471 buffer.set_raw(0, 0, Cell::from_char('X'));
4472 writer.present_ui(&buffer, None, false).unwrap();
4473
4474 buffer.set_raw(1, 1, Cell::from_char('Y'));
4475 writer.present_ui(&buffer, None, false).unwrap();
4476
4477 assert!(writer.scroll_region_active());
4478
4479 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4481
4482 writer.write_log("log message\n").unwrap();
4484
4485 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4486 assert!(
4487 (alpha_after - alpha_before).abs() < 0.01
4488 && (beta_after - beta_before).abs() < 0.01,
4489 "posterior should be preserved with scroll region: before=({}, {}), after=({}, {})",
4490 alpha_before,
4491 beta_before,
4492 alpha_after,
4493 beta_after
4494 );
4495 }
4496 }
4497
4498 #[test]
4499 fn strategy_selection_config_flags_applied() {
4500 let config = RuntimeDiffConfig::default()
4502 .with_dirty_rows_enabled(false)
4503 .with_bayesian_enabled(false);
4504
4505 let writer = TerminalWriter::with_diff_config(
4506 Vec::<u8>::new(),
4507 ScreenMode::AltScreen,
4508 UiAnchor::Bottom,
4509 basic_caps(),
4510 config,
4511 );
4512
4513 assert!(!writer.diff_config().dirty_rows_enabled);
4515 assert!(!writer.diff_config().bayesian_enabled);
4516
4517 let (alpha, beta) = writer.diff_strategy().posterior_params();
4519 assert!((alpha - 1.0).abs() < 0.01);
4521 assert!((beta - 19.0).abs() < 0.01);
4522 }
4523
4524 #[test]
4525 fn resize_respects_reset_toggle() {
4526 let config = RuntimeDiffConfig::default().with_reset_on_resize(false);
4528
4529 let mut writer = TerminalWriter::with_diff_config(
4530 Vec::<u8>::new(),
4531 ScreenMode::AltScreen,
4532 UiAnchor::Bottom,
4533 basic_caps(),
4534 config,
4535 );
4536 writer.set_size(80, 24);
4537
4538 let mut buffer = Buffer::new(80, 24);
4540 buffer.set_raw(0, 0, Cell::from_char('X'));
4541 writer.present_ui(&buffer, None, false).unwrap();
4542
4543 let mut buffer2 = Buffer::new(80, 24);
4544 buffer2.set_raw(1, 1, Cell::from_char('Y'));
4545 writer.present_ui(&buffer2, None, false).unwrap();
4546
4547 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4549
4550 writer.set_size(100, 30);
4552
4553 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4554 assert!(
4555 (alpha_after - alpha_before).abs() < 0.01 && (beta_after - beta_before).abs() < 0.01,
4556 "posterior should be preserved when reset_on_resize=false"
4557 );
4558 }
4559
4560 #[test]
4565 fn screen_mode_default_is_altscreen() {
4566 assert_eq!(ScreenMode::default(), ScreenMode::AltScreen);
4567 }
4568
4569 #[test]
4570 fn screen_mode_debug_format() {
4571 let dbg = format!("{:?}", ScreenMode::Inline { ui_height: 7 });
4572 assert!(dbg.contains("Inline"));
4573 assert!(dbg.contains('7'));
4574 }
4575
4576 #[test]
4577 fn screen_mode_inline_auto_debug_format() {
4578 let dbg = format!(
4579 "{:?}",
4580 ScreenMode::InlineAuto {
4581 min_height: 3,
4582 max_height: 10
4583 }
4584 );
4585 assert!(dbg.contains("InlineAuto"));
4586 }
4587
4588 #[test]
4589 fn screen_mode_eq_inline_auto() {
4590 let a = ScreenMode::InlineAuto {
4591 min_height: 2,
4592 max_height: 8,
4593 };
4594 let b = ScreenMode::InlineAuto {
4595 min_height: 2,
4596 max_height: 8,
4597 };
4598 assert_eq!(a, b);
4599 let c = ScreenMode::InlineAuto {
4600 min_height: 2,
4601 max_height: 9,
4602 };
4603 assert_ne!(a, c);
4604 }
4605
4606 #[test]
4607 fn ui_anchor_default_is_bottom() {
4608 assert_eq!(UiAnchor::default(), UiAnchor::Bottom);
4609 }
4610
4611 #[test]
4612 fn ui_anchor_debug_format() {
4613 assert_eq!(format!("{:?}", UiAnchor::Top), "Top");
4614 assert_eq!(format!("{:?}", UiAnchor::Bottom), "Bottom");
4615 }
4616
4617 #[test]
4622 fn width_height_accessors() {
4623 let output = Vec::new();
4624 let mut writer = TerminalWriter::new(
4625 output,
4626 ScreenMode::AltScreen,
4627 UiAnchor::Bottom,
4628 basic_caps(),
4629 );
4630 assert_eq!(writer.width(), 80);
4632 assert_eq!(writer.height(), 24);
4633
4634 writer.set_size(120, 40);
4635 assert_eq!(writer.width(), 120);
4636 assert_eq!(writer.height(), 40);
4637 }
4638
4639 #[test]
4640 fn screen_mode_accessor() {
4641 let writer = TerminalWriter::new(
4642 Vec::new(),
4643 ScreenMode::Inline { ui_height: 5 },
4644 UiAnchor::Top,
4645 basic_caps(),
4646 );
4647 assert_eq!(writer.screen_mode(), ScreenMode::Inline { ui_height: 5 });
4648 }
4649
4650 #[test]
4651 fn capabilities_accessor() {
4652 let caps = full_caps();
4653 let writer = TerminalWriter::new(Vec::new(), ScreenMode::AltScreen, UiAnchor::Bottom, caps);
4654 assert!(writer.capabilities().true_color);
4655 assert!(writer.capabilities().sync_output);
4656 }
4657
4658 #[test]
4663 fn into_inner_returns_writer() {
4664 let writer = TerminalWriter::new(
4665 Vec::new(),
4666 ScreenMode::AltScreen,
4667 UiAnchor::Bottom,
4668 basic_caps(),
4669 );
4670 let inner = writer.into_inner();
4671 assert!(inner.is_some());
4672 }
4673
4674 #[test]
4675 fn into_inner_performs_cleanup() {
4676 let mut writer = TerminalWriter::new(
4677 Vec::new(),
4678 ScreenMode::Inline { ui_height: 5 },
4679 UiAnchor::Bottom,
4680 basic_caps(),
4681 );
4682 writer.cursor_saved = true;
4683 writer.in_sync_block = false;
4684
4685 let inner = writer.into_inner().unwrap();
4686 assert!(
4688 inner
4689 .windows(CURSOR_RESTORE.len())
4690 .any(|w| w == CURSOR_RESTORE),
4691 "into_inner should perform cleanup before returning"
4692 );
4693 }
4694
4695 #[test]
4700 fn take_render_buffer_creates_new_when_no_spare() {
4701 let mut writer = TerminalWriter::new(
4702 Vec::new(),
4703 ScreenMode::AltScreen,
4704 UiAnchor::Bottom,
4705 basic_caps(),
4706 );
4707 let buf = writer.take_render_buffer(80, 24);
4708 assert_eq!(buf.width(), 80);
4709 assert_eq!(buf.height(), 24);
4710 }
4711
4712 #[test]
4713 fn take_render_buffer_reuses_spare_on_match() {
4714 let mut writer = TerminalWriter::new(
4715 Vec::new(),
4716 ScreenMode::AltScreen,
4717 UiAnchor::Bottom,
4718 basic_caps(),
4719 );
4720 writer.spare_buffer = Some(Buffer::new(80, 24));
4722 assert!(writer.spare_buffer.is_some());
4723
4724 let buf = writer.take_render_buffer(80, 24);
4725 assert_eq!(buf.width(), 80);
4726 assert_eq!(buf.height(), 24);
4727 assert!(writer.spare_buffer.is_none());
4729 }
4730
4731 #[test]
4732 fn take_render_buffer_ignores_spare_on_size_mismatch() {
4733 let mut writer = TerminalWriter::new(
4734 Vec::new(),
4735 ScreenMode::AltScreen,
4736 UiAnchor::Bottom,
4737 basic_caps(),
4738 );
4739 writer.spare_buffer = Some(Buffer::new(80, 24));
4740
4741 let buf = writer.take_render_buffer(100, 30);
4743 assert_eq!(buf.width(), 100);
4744 assert_eq!(buf.height(), 30);
4745 }
4746
4747 #[test]
4752 fn gc_with_no_prev_buffer() {
4753 let mut writer = TerminalWriter::new(
4754 Vec::new(),
4755 ScreenMode::AltScreen,
4756 UiAnchor::Bottom,
4757 basic_caps(),
4758 );
4759 assert!(writer.prev_buffer.is_none());
4760 writer.gc(None);
4762 }
4763
4764 #[test]
4765 fn gc_with_prev_buffer() {
4766 let mut writer = TerminalWriter::new(
4767 Vec::new(),
4768 ScreenMode::AltScreen,
4769 UiAnchor::Bottom,
4770 basic_caps(),
4771 );
4772 writer.prev_buffer = Some(Buffer::new(10, 5));
4773 writer.gc(None);
4775 }
4776
4777 #[test]
4782 fn hide_cursor_emits_sequence() {
4783 let mut output = Vec::new();
4784 {
4785 let mut writer = TerminalWriter::new(
4786 &mut output,
4787 ScreenMode::AltScreen,
4788 UiAnchor::Bottom,
4789 basic_caps(),
4790 );
4791 writer.hide_cursor().unwrap();
4792 }
4793 assert!(
4794 output.windows(6).any(|w| w == b"\x1b[?25l"),
4795 "hide_cursor should emit cursor hide sequence"
4796 );
4797 }
4798
4799 #[test]
4800 fn show_cursor_emits_sequence() {
4801 let mut output = Vec::new();
4802 {
4803 let mut writer = TerminalWriter::new(
4804 &mut output,
4805 ScreenMode::AltScreen,
4806 UiAnchor::Bottom,
4807 basic_caps(),
4808 );
4809 writer.hide_cursor().unwrap();
4811 writer.show_cursor().unwrap();
4812 }
4813 assert!(
4814 output.windows(6).any(|w| w == b"\x1b[?25h"),
4815 "show_cursor should emit cursor show sequence"
4816 );
4817 }
4818
4819 #[test]
4820 fn hide_cursor_idempotent() {
4821 use std::io::Cursor;
4823 let mut writer = TerminalWriter::new(
4824 Cursor::new(Vec::new()),
4825 ScreenMode::AltScreen,
4826 UiAnchor::Bottom,
4827 basic_caps(),
4828 );
4829 writer.hide_cursor().unwrap();
4830 let inner = writer.into_inner().unwrap().into_inner();
4831 let hide_count = inner.windows(6).filter(|w| *w == b"\x1b[?25l").count();
4832 assert_eq!(
4834 hide_count, 1,
4835 "hide_cursor called once should emit exactly one hide sequence"
4836 );
4837 }
4838
4839 #[test]
4840 fn show_cursor_idempotent_when_already_visible() {
4841 use std::io::Cursor;
4842 let mut writer = TerminalWriter::new(
4843 Cursor::new(Vec::new()),
4844 ScreenMode::AltScreen,
4845 UiAnchor::Bottom,
4846 basic_caps(),
4847 );
4848 writer.show_cursor().unwrap();
4850 let inner = writer.into_inner().unwrap().into_inner();
4851 let show_count = inner.windows(6).filter(|w| *w == b"\x1b[?25h").count();
4853 assert!(
4854 show_count <= 1,
4855 "show_cursor when already visible should not add extra show sequences"
4856 );
4857 }
4858
4859 #[test]
4864 fn pool_accessor() {
4865 let writer = TerminalWriter::new(
4866 Vec::new(),
4867 ScreenMode::AltScreen,
4868 UiAnchor::Bottom,
4869 basic_caps(),
4870 );
4871 let _pool = writer.pool();
4873 }
4874
4875 #[test]
4876 fn pool_mut_accessor() {
4877 let mut writer = TerminalWriter::new(
4878 Vec::new(),
4879 ScreenMode::AltScreen,
4880 UiAnchor::Bottom,
4881 basic_caps(),
4882 );
4883 let _pool = writer.pool_mut();
4884 }
4885
4886 #[test]
4887 fn links_accessor() {
4888 let writer = TerminalWriter::new(
4889 Vec::new(),
4890 ScreenMode::AltScreen,
4891 UiAnchor::Bottom,
4892 basic_caps(),
4893 );
4894 let _links = writer.links();
4895 }
4896
4897 #[test]
4898 fn links_mut_accessor() {
4899 let mut writer = TerminalWriter::new(
4900 Vec::new(),
4901 ScreenMode::AltScreen,
4902 UiAnchor::Bottom,
4903 basic_caps(),
4904 );
4905 let _links = writer.links_mut();
4906 }
4907
4908 #[test]
4909 fn pool_and_links_mut_accessor() {
4910 let mut writer = TerminalWriter::new(
4911 Vec::new(),
4912 ScreenMode::AltScreen,
4913 UiAnchor::Bottom,
4914 basic_caps(),
4915 );
4916 let (_pool, _links) = writer.pool_and_links_mut();
4917 }
4918
4919 #[test]
4924 fn sanitize_auto_bounds_normal() {
4925 assert_eq!(sanitize_auto_bounds(3, 10), (3, 10));
4926 }
4927
4928 #[test]
4929 fn sanitize_auto_bounds_zero_min() {
4930 assert_eq!(sanitize_auto_bounds(0, 10), (1, 10));
4932 }
4933
4934 #[test]
4935 fn sanitize_auto_bounds_max_less_than_min() {
4936 assert_eq!(sanitize_auto_bounds(5, 3), (5, 5));
4938 }
4939
4940 #[test]
4941 fn sanitize_auto_bounds_both_zero() {
4942 assert_eq!(sanitize_auto_bounds(0, 0), (1, 1));
4943 }
4944
4945 #[test]
4946 fn diff_strategy_str_variants() {
4947 assert_eq!(diff_strategy_str(DiffStrategy::Full), "full");
4948 assert_eq!(diff_strategy_str(DiffStrategy::DirtyRows), "dirty");
4949 assert_eq!(diff_strategy_str(DiffStrategy::FullRedraw), "redraw");
4950 }
4951
4952 #[test]
4953 fn ui_anchor_str_variants() {
4954 assert_eq!(ui_anchor_str(UiAnchor::Bottom), "bottom");
4955 assert_eq!(ui_anchor_str(UiAnchor::Top), "top");
4956 }
4957
4958 #[test]
4959 fn json_escape_plain_text() {
4960 assert_eq!(json_escape("hello"), "hello");
4961 }
4962
4963 #[test]
4964 fn json_escape_special_chars() {
4965 assert_eq!(json_escape(r#"a"b"#), r#"a\"b"#);
4966 assert_eq!(json_escape("a\\b"), r#"a\\b"#);
4967 assert_eq!(json_escape("a\nb"), r#"a\nb"#);
4968 assert_eq!(json_escape("a\rb"), r#"a\rb"#);
4969 assert_eq!(json_escape("a\tb"), r#"a\tb"#);
4970 }
4971
4972 #[test]
4973 fn json_escape_control_chars() {
4974 let s = String::from("\x00\x01\x1f");
4975 let escaped = json_escape(&s);
4976 assert!(escaped.contains("\\u0000"));
4977 assert!(escaped.contains("\\u0001"));
4978 assert!(escaped.contains("\\u001F"));
4979 }
4980
4981 #[test]
4982 fn json_escape_unicode_passthrough() {
4983 assert_eq!(json_escape("caf\u{00e9}"), "caf\u{00e9}");
4984 assert_eq!(json_escape("\u{1f600}"), "\u{1f600}");
4985 }
4986
4987 #[test]
4992 fn counting_writer_into_inner() {
4993 let mut cw = CountingWriter::new(Vec::new());
4994 cw.write_all(b"data").unwrap();
4995 let inner = cw.into_inner();
4996 assert_eq!(inner, b"data");
4997 }
4998
4999 fn zero_span_stats() -> DirtySpanStats {
5004 DirtySpanStats {
5005 rows_full_dirty: 0,
5006 rows_with_spans: 0,
5007 total_spans: 0,
5008 overflows: 0,
5009 span_coverage_cells: 0,
5010 max_span_len: 0,
5011 max_spans_per_row: 4,
5012 }
5013 }
5014
5015 #[test]
5016 fn estimate_diff_scan_cost_full_strategy() {
5017 let stats = zero_span_stats();
5018 let (cost, label) = estimate_diff_scan_cost(DiffStrategy::Full, 0, 80, 24, &stats, None);
5019 assert_eq!(cost, 80 * 24);
5020 assert_eq!(label, "full_strategy");
5021 }
5022
5023 #[test]
5024 fn estimate_diff_scan_cost_full_redraw() {
5025 let stats = zero_span_stats();
5026 let (cost, label) =
5027 estimate_diff_scan_cost(DiffStrategy::FullRedraw, 5, 80, 24, &stats, None);
5028 assert_eq!(cost, 0);
5029 assert_eq!(label, "full_redraw");
5030 }
5031
5032 #[test]
5033 fn estimate_diff_scan_cost_dirty_rows_no_dirty() {
5034 let stats = zero_span_stats();
5035 let (cost, label) =
5036 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 0, 80, 24, &stats, None);
5037 assert_eq!(cost, 0);
5038 assert_eq!(label, "no_dirty_rows");
5039 }
5040
5041 #[test]
5042 fn estimate_diff_scan_cost_dirty_rows_with_span_coverage() {
5043 let mut stats = zero_span_stats();
5044 stats.span_coverage_cells = 100;
5045 let (cost, label) =
5046 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5047 assert_eq!(cost, 100);
5048 assert_eq!(label, "none");
5049 }
5050
5051 #[test]
5052 fn estimate_diff_scan_cost_dirty_rows_no_spans() {
5053 let stats = zero_span_stats();
5054 let (cost, label) =
5055 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5056 assert_eq!(cost, 5 * 80);
5057 assert_eq!(label, "no_spans");
5058 }
5059
5060 #[test]
5061 fn estimate_diff_scan_cost_dirty_rows_overflow_with_span() {
5062 let mut stats = zero_span_stats();
5063 stats.span_coverage_cells = 150;
5064 stats.overflows = 1;
5065 let (cost, label) =
5066 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5067 assert_eq!(cost, 150);
5068 assert_eq!(label, "span_overflow");
5069 }
5070
5071 #[test]
5072 fn estimate_diff_scan_cost_dirty_rows_overflow_no_span() {
5073 let mut stats = zero_span_stats();
5074 stats.overflows = 1;
5075 let (cost, label) =
5076 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5077 assert_eq!(cost, 5 * 80);
5078 assert_eq!(label, "span_overflow");
5079 }
5080
5081 #[test]
5082 fn estimate_diff_scan_cost_tile_skip() {
5083 let stats = zero_span_stats();
5084 let tile = TileDiffStats {
5085 width: 80,
5086 height: 24,
5087 tile_w: 16,
5088 tile_h: 8,
5089 tiles_x: 5,
5090 tiles_y: 3,
5091 total_tiles: 15,
5092 dirty_cells: 10,
5093 dirty_tiles: 2,
5094 dirty_cell_ratio: 0.005,
5095 dirty_tile_ratio: 0.13,
5096 scanned_tiles: 2,
5097 skipped_tiles: 13,
5098 sat_build_cells: 1920,
5099 scan_cells_estimate: 42,
5100 fallback: None,
5101 };
5102 let (cost, label) =
5103 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
5104 assert_eq!(cost, 42);
5105 assert_eq!(label, "tile_skip");
5106 }
5107
5108 #[test]
5109 fn estimate_diff_scan_cost_tile_with_fallback_uses_spans() {
5110 let mut stats = zero_span_stats();
5111 stats.span_coverage_cells = 200;
5112 let tile = TileDiffStats {
5113 width: 80,
5114 height: 24,
5115 tile_w: 16,
5116 tile_h: 8,
5117 tiles_x: 5,
5118 tiles_y: 3,
5119 total_tiles: 15,
5120 dirty_cells: 10,
5121 dirty_tiles: 2,
5122 dirty_cell_ratio: 0.005,
5123 dirty_tile_ratio: 0.13,
5124 scanned_tiles: 2,
5125 skipped_tiles: 13,
5126 sat_build_cells: 1920,
5127 scan_cells_estimate: 42,
5128 fallback: Some(TileDiffFallback::SmallScreen),
5129 };
5130 let (cost, label) =
5131 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
5132 assert_eq!(cost, 200);
5134 assert_eq!(label, "none");
5135 }
5136
5137 #[test]
5142 fn inline_auto_bounds_accessor() {
5143 let mut writer = TerminalWriter::new(
5144 Vec::new(),
5145 ScreenMode::InlineAuto {
5146 min_height: 3,
5147 max_height: 10,
5148 },
5149 UiAnchor::Bottom,
5150 basic_caps(),
5151 );
5152 writer.set_size(80, 24);
5153 let bounds = writer.inline_auto_bounds();
5154 assert_eq!(bounds, Some((3, 10)));
5155 }
5156
5157 #[test]
5158 fn inline_auto_bounds_clamped_to_terminal() {
5159 let mut writer = TerminalWriter::new(
5160 Vec::new(),
5161 ScreenMode::InlineAuto {
5162 min_height: 3,
5163 max_height: 50,
5164 },
5165 UiAnchor::Bottom,
5166 basic_caps(),
5167 );
5168 writer.set_size(80, 20);
5169 let bounds = writer.inline_auto_bounds();
5170 assert_eq!(bounds, Some((3, 20)));
5171 }
5172
5173 #[test]
5174 fn inline_auto_bounds_returns_none_for_non_auto() {
5175 let writer = TerminalWriter::new(
5176 Vec::new(),
5177 ScreenMode::Inline { ui_height: 5 },
5178 UiAnchor::Bottom,
5179 basic_caps(),
5180 );
5181 assert_eq!(writer.inline_auto_bounds(), None);
5182
5183 let writer2 = TerminalWriter::new(
5184 Vec::new(),
5185 ScreenMode::AltScreen,
5186 UiAnchor::Bottom,
5187 basic_caps(),
5188 );
5189 assert_eq!(writer2.inline_auto_bounds(), None);
5190 }
5191
5192 #[test]
5193 fn auto_ui_height_returns_none_for_non_auto() {
5194 let writer = TerminalWriter::new(
5195 Vec::new(),
5196 ScreenMode::Inline { ui_height: 5 },
5197 UiAnchor::Bottom,
5198 basic_caps(),
5199 );
5200 assert_eq!(writer.auto_ui_height(), None);
5201 }
5202
5203 #[test]
5204 fn render_height_hint_altscreen() {
5205 let mut writer = TerminalWriter::new(
5206 Vec::new(),
5207 ScreenMode::AltScreen,
5208 UiAnchor::Bottom,
5209 basic_caps(),
5210 );
5211 writer.set_size(80, 24);
5212 assert_eq!(writer.render_height_hint(), 24);
5213 }
5214
5215 #[test]
5216 fn render_height_hint_inline_fixed() {
5217 let writer = TerminalWriter::new(
5218 Vec::new(),
5219 ScreenMode::Inline { ui_height: 7 },
5220 UiAnchor::Bottom,
5221 basic_caps(),
5222 );
5223 assert_eq!(writer.render_height_hint(), 7);
5224 }
5225
5226 #[test]
5231 fn runtime_diff_config_tile_skip_toggle() {
5232 let config = RuntimeDiffConfig::new().with_tile_skip_enabled(false);
5233 assert!(!config.tile_diff_config.enabled);
5234 }
5235
5236 #[test]
5237 fn runtime_diff_config_dirty_spans_toggle() {
5238 let config = RuntimeDiffConfig::new().with_dirty_spans_enabled(false);
5239 assert!(!config.dirty_span_config.enabled);
5240 }
5241
5242 #[test]
5247 fn present_ui_altscreen_no_cursor_save_restore() {
5248 let mut output = Vec::new();
5249 {
5250 let mut writer = TerminalWriter::new(
5251 &mut output,
5252 ScreenMode::AltScreen,
5253 UiAnchor::Bottom,
5254 basic_caps(),
5255 );
5256 writer.set_size(10, 5);
5257 let buffer = Buffer::new(10, 5);
5258 writer.present_ui(&buffer, None, true).unwrap();
5259 }
5260
5261 let save_count = output
5263 .windows(CURSOR_SAVE.len())
5264 .filter(|w| *w == CURSOR_SAVE)
5265 .count();
5266 assert_eq!(save_count, 0, "AltScreen should not save cursor");
5267 }
5268
5269 #[test]
5270 fn clear_screen_emits_ed2() {
5271 let mut output = Vec::new();
5272 {
5273 let mut writer = TerminalWriter::new(
5274 &mut output,
5275 ScreenMode::AltScreen,
5276 UiAnchor::Bottom,
5277 basic_caps(),
5278 );
5279 writer.clear_screen().unwrap();
5280 }
5281 assert!(
5282 output.windows(4).any(|w| w == b"\x1b[2J"),
5283 "clear_screen should emit ED2 sequence"
5284 );
5285 }
5286
5287 #[test]
5288 fn set_size_resets_scroll_region_and_spare_buffer() {
5289 let output = Vec::new();
5290 let mut writer = TerminalWriter::new(
5291 output,
5292 ScreenMode::Inline { ui_height: 5 },
5293 UiAnchor::Bottom,
5294 basic_caps(),
5295 );
5296 writer.spare_buffer = Some(Buffer::new(80, 24));
5297 writer.set_size(100, 30);
5298 assert!(writer.spare_buffer.is_none());
5299 }
5300
5301 static GAUGE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
5308
5309 #[test]
5310 fn inline_active_widgets_gauge_increments_for_inline_mode() {
5311 let _lock = GAUGE_TEST_LOCK.lock().unwrap();
5312 let before = inline_active_widgets();
5313 let writer = TerminalWriter::new(
5314 Vec::new(),
5315 ScreenMode::Inline { ui_height: 5 },
5316 UiAnchor::Bottom,
5317 basic_caps(),
5318 );
5319 assert_eq!(
5320 inline_active_widgets(),
5321 before + 1,
5322 "creating an inline writer should increment the gauge"
5323 );
5324 drop(writer);
5325 assert_eq!(
5326 inline_active_widgets(),
5327 before,
5328 "dropping an inline writer should decrement the gauge"
5329 );
5330 }
5331
5332 #[test]
5333 fn inline_active_widgets_gauge_increments_for_inline_auto_mode() {
5334 let _lock = GAUGE_TEST_LOCK.lock().unwrap();
5335 let before = inline_active_widgets();
5336 let writer = TerminalWriter::new(
5337 Vec::new(),
5338 ScreenMode::InlineAuto {
5339 min_height: 2,
5340 max_height: 10,
5341 },
5342 UiAnchor::Bottom,
5343 basic_caps(),
5344 );
5345 assert_eq!(inline_active_widgets(), before + 1);
5346 drop(writer);
5347 assert_eq!(inline_active_widgets(), before);
5348 }
5349
5350 #[test]
5351 fn inline_active_widgets_gauge_unchanged_for_altscreen() {
5352 let _lock = GAUGE_TEST_LOCK.lock().unwrap();
5353 let before = inline_active_widgets();
5354 let writer = TerminalWriter::new(
5355 Vec::new(),
5356 ScreenMode::AltScreen,
5357 UiAnchor::Bottom,
5358 basic_caps(),
5359 );
5360 assert_eq!(
5361 inline_active_widgets(),
5362 before,
5363 "altscreen writer should not affect the inline gauge"
5364 );
5365 drop(writer);
5366 }
5367
5368 const ALTSCREEN_ENTER: &[u8] = b"\x1b[?1049h";
5375
5376 const ALTSCREEN_EXIT: &[u8] = b"\x1b[?1049l";
5378
5379 fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
5381 haystack.windows(needle.len()).any(|w| w == needle)
5382 }
5383
5384 #[test]
5385 fn inline_render_never_emits_altscreen_enter() {
5386 let mut output = Vec::new();
5388 {
5389 let mut writer = TerminalWriter::new(
5390 &mut output,
5391 ScreenMode::Inline { ui_height: 5 },
5392 UiAnchor::Bottom,
5393 basic_caps(),
5394 );
5395 writer.set_size(80, 24);
5396
5397 let buffer = Buffer::new(80, 5);
5398 writer.present_ui(&buffer, None, true).unwrap();
5399 writer.write_log("hello\n").unwrap();
5400 writer.present_ui(&buffer, None, true).unwrap();
5402 }
5403
5404 assert!(
5405 !contains_bytes(&output, ALTSCREEN_ENTER),
5406 "inline mode must never emit CSI ?1049h (alternate screen enter)"
5407 );
5408 assert!(
5409 !contains_bytes(&output, ALTSCREEN_EXIT),
5410 "inline mode must never emit CSI ?1049l (alternate screen exit)"
5411 );
5412 }
5413
5414 #[test]
5415 fn inline_auto_render_never_emits_altscreen_enter() {
5416 let mut output = Vec::new();
5417 {
5418 let mut writer = TerminalWriter::new(
5419 &mut output,
5420 ScreenMode::InlineAuto {
5421 min_height: 3,
5422 max_height: 10,
5423 },
5424 UiAnchor::Bottom,
5425 basic_caps(),
5426 );
5427 writer.set_size(80, 24);
5428
5429 let buffer = Buffer::new(80, 5);
5430 writer.present_ui(&buffer, None, true).unwrap();
5431 }
5432
5433 assert!(
5434 !contains_bytes(&output, ALTSCREEN_ENTER),
5435 "InlineAuto mode must never emit CSI ?1049h"
5436 );
5437 }
5438
5439 #[test]
5440 fn inline_scrollback_preserved_after_present() {
5441 let mut output = Vec::new();
5446 {
5447 let mut writer = TerminalWriter::new(
5448 &mut output,
5449 ScreenMode::Inline { ui_height: 5 },
5450 UiAnchor::Bottom,
5451 basic_caps(),
5452 );
5453 writer.set_size(80, 24);
5454
5455 writer.write_log("scrollback line A\n").unwrap();
5456 writer.write_log("scrollback line B\n").unwrap();
5457
5458 let buffer = Buffer::new(80, 5);
5459 writer.present_ui(&buffer, None, true).unwrap();
5460
5461 writer.write_log("scrollback line C\n").unwrap();
5463 }
5464
5465 let text = String::from_utf8_lossy(&output);
5466 assert!(text.contains("scrollback line A"), "first log must survive");
5467 assert!(
5468 text.contains("scrollback line B"),
5469 "second log must survive"
5470 );
5471 assert!(
5472 text.contains("scrollback line C"),
5473 "post-render log must survive"
5474 );
5475
5476 assert!(
5479 contains_bytes(&output, CURSOR_SAVE),
5480 "present_ui must save cursor to protect scrollback"
5481 );
5482 assert!(
5483 contains_bytes(&output, CURSOR_RESTORE),
5484 "present_ui must restore cursor to protect scrollback"
5485 );
5486 }
5487
5488 #[test]
5489 fn multiple_inline_writers_coexist() {
5490 let mut writer_a = TerminalWriter::new(
5494 Vec::new(),
5495 ScreenMode::Inline { ui_height: 3 },
5496 UiAnchor::Bottom,
5497 basic_caps(),
5498 );
5499 writer_a.set_size(40, 12);
5500
5501 let mut writer_b = TerminalWriter::new(
5502 Vec::new(),
5503 ScreenMode::Inline { ui_height: 5 },
5504 UiAnchor::Bottom,
5505 basic_caps(),
5506 );
5507 writer_b.set_size(80, 24);
5508
5509 let buf_a = Buffer::new(40, 3);
5511 let buf_b = Buffer::new(80, 5);
5512 writer_a.present_ui(&buf_a, None, true).unwrap();
5513 writer_b.present_ui(&buf_b, None, true).unwrap();
5514
5515 writer_a.present_ui(&buf_a, None, true).unwrap();
5517 writer_b.present_ui(&buf_b, None, true).unwrap();
5518
5519 drop(writer_a);
5521 drop(writer_b);
5522 }
5523
5524 #[test]
5525 fn multiple_inline_writers_gauge_tracks_both() {
5526 let _lock = GAUGE_TEST_LOCK.lock().unwrap();
5528 let before = inline_active_widgets();
5529
5530 let writer_a = TerminalWriter::new(
5531 Vec::new(),
5532 ScreenMode::Inline { ui_height: 3 },
5533 UiAnchor::Bottom,
5534 basic_caps(),
5535 );
5536 assert_eq!(inline_active_widgets(), before + 1);
5537
5538 let writer_b = TerminalWriter::new(
5539 Vec::new(),
5540 ScreenMode::Inline { ui_height: 5 },
5541 UiAnchor::Bottom,
5542 basic_caps(),
5543 );
5544 assert_eq!(inline_active_widgets(), before + 2);
5545
5546 drop(writer_a);
5547 assert_eq!(inline_active_widgets(), before + 1);
5548
5549 drop(writer_b);
5550 assert_eq!(inline_active_widgets(), before);
5551 }
5552
5553 #[test]
5554 fn resize_during_inline_mode_preserves_scrollback() {
5555 let mut output = Vec::new();
5558 {
5559 let mut writer = TerminalWriter::new(
5560 &mut output,
5561 ScreenMode::Inline { ui_height: 5 },
5562 UiAnchor::Bottom,
5563 basic_caps(),
5564 );
5565 writer.set_size(80, 24);
5566
5567 let buffer = Buffer::new(80, 5);
5568 writer.present_ui(&buffer, None, true).unwrap();
5569
5570 writer.set_size(100, 30);
5572 assert_eq!(writer.ui_start_row(), 25); let buffer2 = Buffer::new(100, 5);
5576 writer.present_ui(&buffer2, None, true).unwrap();
5577
5578 writer.write_log("post-resize log\n").unwrap();
5580 }
5581
5582 let text = String::from_utf8_lossy(&output);
5583 assert!(text.contains("post-resize log"));
5584 assert!(
5585 !contains_bytes(&output, ALTSCREEN_ENTER),
5586 "resize must not trigger alternate screen"
5587 );
5588 }
5589
5590 #[test]
5591 fn resize_shrink_during_inline_mode_clamps_correctly() {
5592 let mut output = Vec::new();
5595 {
5596 let mut writer = TerminalWriter::new(
5597 &mut output,
5598 ScreenMode::Inline { ui_height: 10 },
5599 UiAnchor::Bottom,
5600 basic_caps(),
5601 );
5602 writer.set_size(80, 24);
5603 assert_eq!(writer.ui_start_row(), 14);
5604
5605 writer.set_size(80, 8);
5607 assert_eq!(writer.ui_start_row(), 0); let buffer = Buffer::new(80, 8);
5611 writer.present_ui(&buffer, None, true).unwrap();
5612 }
5613
5614 assert!(
5615 !contains_bytes(&output, ALTSCREEN_ENTER),
5616 "shrunken terminal must not switch to altscreen"
5617 );
5618 }
5619
5620 #[test]
5621 fn inline_render_emits_tracing_span_fields() {
5622 use std::sync::Arc;
5626 use std::sync::atomic::AtomicBool;
5627
5628 struct SpanChecker {
5629 saw_inline_render: Arc<AtomicBool>,
5630 }
5631
5632 impl tracing::Subscriber for SpanChecker {
5633 fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
5634 true
5635 }
5636 fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
5637 if span.metadata().name() == "inline.render" {
5638 self.saw_inline_render
5639 .store(true, std::sync::atomic::Ordering::SeqCst);
5640 }
5641 tracing::span::Id::from_u64(1)
5642 }
5643 fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
5644 fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
5645 }
5646 fn event(&self, _event: &tracing::Event<'_>) {}
5647 fn enter(&self, _span: &tracing::span::Id) {}
5648 fn exit(&self, _span: &tracing::span::Id) {}
5649 }
5650
5651 let saw_it = Arc::new(AtomicBool::new(false));
5652 let subscriber = SpanChecker {
5653 saw_inline_render: Arc::clone(&saw_it),
5654 };
5655
5656 let _guard = tracing::subscriber::set_default(subscriber);
5657
5658 let mut output = Vec::new();
5659 {
5660 let mut writer = TerminalWriter::new(
5661 &mut output,
5662 ScreenMode::Inline { ui_height: 5 },
5663 UiAnchor::Bottom,
5664 basic_caps(),
5665 );
5666 writer.set_size(80, 24);
5667
5668 let buffer = Buffer::new(80, 5);
5669 writer.present_ui(&buffer, None, true).unwrap();
5670 }
5671
5672 assert!(
5673 saw_it.load(std::sync::atomic::Ordering::SeqCst),
5674 "present_ui in inline mode must emit an inline.render tracing span"
5675 );
5676 }
5677
5678 #[test]
5679 fn inline_render_no_altscreen_with_scroll_region_strategy() {
5680 let mut output = Vec::new();
5682 {
5683 let mut writer = TerminalWriter::new(
5684 &mut output,
5685 ScreenMode::Inline { ui_height: 5 },
5686 UiAnchor::Bottom,
5687 scroll_region_caps(),
5688 );
5689 writer.set_size(80, 24);
5690
5691 let buffer = Buffer::new(80, 5);
5692 writer.present_ui(&buffer, None, true).unwrap();
5693 writer.present_ui(&buffer, None, true).unwrap();
5694 }
5695
5696 assert!(
5697 !contains_bytes(&output, ALTSCREEN_ENTER),
5698 "scroll region strategy must never emit altscreen enter"
5699 );
5700 }
5701
5702 #[test]
5703 fn inline_render_no_altscreen_with_hybrid_strategy() {
5704 let mut output = Vec::new();
5705 {
5706 let mut writer = TerminalWriter::new(
5707 &mut output,
5708 ScreenMode::Inline { ui_height: 5 },
5709 UiAnchor::Bottom,
5710 hybrid_caps(),
5711 );
5712 writer.set_size(80, 24);
5713
5714 let buffer = Buffer::new(80, 5);
5715 writer.present_ui(&buffer, None, true).unwrap();
5716 }
5717
5718 assert!(
5719 !contains_bytes(&output, ALTSCREEN_ENTER),
5720 "hybrid strategy must never emit altscreen enter"
5721 );
5722 }
5723
5724 #[test]
5725 fn inline_render_no_altscreen_with_mux_strategy() {
5726 let mut output = Vec::new();
5727 {
5728 let mut writer = TerminalWriter::new(
5729 &mut output,
5730 ScreenMode::Inline { ui_height: 5 },
5731 UiAnchor::Bottom,
5732 mux_caps(),
5733 );
5734 writer.set_size(80, 24);
5735
5736 let buffer = Buffer::new(80, 5);
5737 writer.present_ui(&buffer, None, true).unwrap();
5738 }
5739
5740 assert!(
5741 !contains_bytes(&output, ALTSCREEN_ENTER),
5742 "mux (overlay) strategy must never emit altscreen enter"
5743 );
5744 }
5745}