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::diff::{BufferDiff, ChangeRun, TileDiffConfig, TileDiffFallback, TileDiffStats};
74use ftui_render::diff_strategy::{DiffStrategy, DiffStrategyConfig, DiffStrategySelector};
75use ftui_render::grapheme_pool::GraphemePool;
76use ftui_render::link_registry::LinkRegistry;
77use tracing::{debug_span, info, info_span, trace, warn};
78
79const BUFFER_CAPACITY: usize = 64 * 1024;
81
82const CURSOR_SAVE: &[u8] = b"\x1b7";
84
85const CURSOR_RESTORE: &[u8] = b"\x1b8";
87
88const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
90
91const SYNC_END: &[u8] = b"\x1b[?2026l";
93
94const ERASE_LINE: &[u8] = b"\x1b[2K";
96
97#[allow(dead_code)] const FULL_REDRAW_PROBE_INTERVAL: u64 = 60;
100
101struct CountingWriter<W: Write> {
103 inner: W,
104 count_enabled: bool,
105 bytes_written: u64,
106}
107
108impl<W: Write> CountingWriter<W> {
109 fn new(inner: W) -> Self {
110 Self {
111 inner,
112 count_enabled: false,
113 bytes_written: 0,
114 }
115 }
116
117 #[allow(dead_code)]
118 fn enable_counting(&mut self) {
119 self.count_enabled = true;
120 self.bytes_written = 0;
121 }
122
123 #[allow(dead_code)]
124 fn disable_counting(&mut self) {
125 self.count_enabled = false;
126 }
127
128 #[allow(dead_code)]
129 fn take_count(&mut self) -> u64 {
130 let count = self.bytes_written;
131 self.bytes_written = 0;
132 count
133 }
134
135 fn into_inner(self) -> W {
136 self.inner
137 }
138}
139
140impl<W: Write> Write for CountingWriter<W> {
141 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
142 let written = self.inner.write(buf)?;
143 if self.count_enabled {
144 self.bytes_written = self.bytes_written.saturating_add(written as u64);
145 }
146 Ok(written)
147 }
148
149 fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
150 self.inner.write_all(buf)?;
151 if self.count_enabled {
152 self.bytes_written = self.bytes_written.saturating_add(buf.len() as u64);
153 }
154 Ok(())
155 }
156
157 fn flush(&mut self) -> io::Result<()> {
158 self.inner.flush()
159 }
160}
161
162fn default_diff_run_id() -> String {
163 format!("diff-{}", std::process::id())
164}
165
166fn diff_strategy_str(strategy: DiffStrategy) -> &'static str {
167 match strategy {
168 DiffStrategy::Full => "full",
169 DiffStrategy::DirtyRows => "dirty",
170 DiffStrategy::FullRedraw => "redraw",
171 }
172}
173
174fn inline_strategy_str(strategy: InlineStrategy) -> &'static str {
175 match strategy {
176 InlineStrategy::ScrollRegion => "scroll_region",
177 InlineStrategy::OverlayRedraw => "overlay_redraw",
178 InlineStrategy::Hybrid => "hybrid",
179 }
180}
181
182fn ui_anchor_str(anchor: UiAnchor) -> &'static str {
183 match anchor {
184 UiAnchor::Bottom => "bottom",
185 UiAnchor::Top => "top",
186 }
187}
188
189#[allow(dead_code)]
190#[inline]
191fn json_escape(value: &str) -> String {
192 let mut out = String::with_capacity(value.len());
193 for ch in value.chars() {
194 match ch {
195 '"' => out.push_str("\\\""),
196 '\\' => out.push_str("\\\\"),
197 '\n' => out.push_str("\\n"),
198 '\r' => out.push_str("\\r"),
199 '\t' => out.push_str("\\t"),
200 c if c.is_control() => {
201 use std::fmt::Write as _;
202 let _ = write!(out, "\\u{:04X}", c as u32);
203 }
204 _ => out.push(ch),
205 }
206 }
207 out
208}
209
210#[allow(dead_code)]
211fn estimate_diff_scan_cost(
212 strategy: DiffStrategy,
213 dirty_rows: usize,
214 width: usize,
215 height: usize,
216 span_stats: &DirtySpanStats,
217 tile_stats: Option<TileDiffStats>,
218) -> (usize, &'static str) {
219 match strategy {
220 DiffStrategy::Full => (width.saturating_mul(height), "full_strategy"),
221 DiffStrategy::FullRedraw => (0, "full_redraw"),
222 DiffStrategy::DirtyRows => {
223 if dirty_rows == 0 {
224 return (0, "no_dirty_rows");
225 }
226 if let Some(tile_stats) = tile_stats
227 && tile_stats.fallback.is_none()
228 {
229 return (tile_stats.scan_cells_estimate, "tile_skip");
230 }
231 let span_cells = span_stats.span_coverage_cells;
232 if span_stats.overflows > 0 {
233 let estimate = if span_cells > 0 {
234 span_cells
235 } else {
236 dirty_rows.saturating_mul(width)
237 };
238 return (estimate, "span_overflow");
239 }
240 if span_cells > 0 {
241 (span_cells, "none")
242 } else {
243 (dirty_rows.saturating_mul(width), "no_spans")
244 }
245 }
246 }
247}
248
249fn sanitize_auto_bounds(min_height: u16, max_height: u16) -> (u16, u16) {
250 let min = min_height.max(1);
251 let max = max_height.max(min);
252 (min, max)
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
257pub enum ScreenMode {
258 Inline {
260 ui_height: u16,
262 },
263 InlineAuto {
267 min_height: u16,
269 max_height: u16,
271 },
272 #[default]
274 AltScreen,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
279pub enum UiAnchor {
280 #[default]
282 Bottom,
283 Top,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288struct InlineRegion {
289 start: u16,
290 height: u16,
291}
292
293struct DiffDecision {
294 #[allow(dead_code)] strategy: DiffStrategy,
296 has_diff: bool,
297}
298
299#[derive(Debug, Clone, Copy)]
300#[allow(dead_code)]
301struct EmitStats {
302 diff_cells: usize,
303 diff_runs: usize,
304}
305
306#[derive(Debug, Clone, Copy)]
307#[allow(dead_code)]
308struct FrameEmitStats {
309 diff_strategy: DiffStrategy,
310 diff_cells: usize,
311 diff_runs: usize,
312 ui_height: u16,
313}
314
315#[derive(Debug, Clone, Copy)]
316#[allow(dead_code)]
317pub struct PresentTimings {
318 pub diff_us: u64,
319}
320
321#[derive(Debug, Clone)]
350pub struct RuntimeDiffConfig {
351 pub bayesian_enabled: bool,
361
362 pub dirty_rows_enabled: bool,
369
370 pub dirty_span_config: DirtySpanConfig,
374
375 pub tile_diff_config: TileDiffConfig,
379
380 pub reset_on_resize: bool,
387
388 pub reset_on_invalidation: bool,
395
396 pub strategy_config: DiffStrategyConfig,
400}
401
402impl Default for RuntimeDiffConfig {
403 fn default() -> Self {
404 Self {
405 bayesian_enabled: true,
406 dirty_rows_enabled: true,
407 dirty_span_config: DirtySpanConfig::default(),
408 tile_diff_config: TileDiffConfig::default(),
409 reset_on_resize: true,
410 reset_on_invalidation: true,
411 strategy_config: DiffStrategyConfig::default(),
412 }
413 }
414}
415
416impl RuntimeDiffConfig {
417 pub fn new() -> Self {
419 Self::default()
420 }
421
422 #[must_use]
424 pub fn with_bayesian_enabled(mut self, enabled: bool) -> Self {
425 self.bayesian_enabled = enabled;
426 self
427 }
428
429 #[must_use]
431 pub fn with_dirty_rows_enabled(mut self, enabled: bool) -> Self {
432 self.dirty_rows_enabled = enabled;
433 self
434 }
435
436 #[must_use]
438 pub fn with_dirty_spans_enabled(mut self, enabled: bool) -> Self {
439 self.dirty_span_config = self.dirty_span_config.with_enabled(enabled);
440 self
441 }
442
443 #[must_use]
445 pub fn with_dirty_span_config(mut self, config: DirtySpanConfig) -> Self {
446 self.dirty_span_config = config;
447 self
448 }
449
450 #[must_use]
452 pub fn with_tile_skip_enabled(mut self, enabled: bool) -> Self {
453 self.tile_diff_config = self.tile_diff_config.with_enabled(enabled);
454 self
455 }
456
457 #[must_use]
459 pub fn with_tile_diff_config(mut self, config: TileDiffConfig) -> Self {
460 self.tile_diff_config = config;
461 self
462 }
463
464 #[must_use]
466 pub fn with_reset_on_resize(mut self, enabled: bool) -> Self {
467 self.reset_on_resize = enabled;
468 self
469 }
470
471 #[must_use]
473 pub fn with_reset_on_invalidation(mut self, enabled: bool) -> Self {
474 self.reset_on_invalidation = enabled;
475 self
476 }
477
478 #[must_use]
480 pub fn with_strategy_config(mut self, config: DiffStrategyConfig) -> Self {
481 self.strategy_config = config;
482 self
483 }
484}
485
486pub struct TerminalWriter<W: Write> {
491 writer: Option<CountingWriter<BufWriter<W>>>,
493 screen_mode: ScreenMode,
495 auto_ui_height: Option<u16>,
497 ui_anchor: UiAnchor,
499 prev_buffer: Option<Buffer>,
501 spare_buffer: Option<Buffer>,
503 clone_buf: Option<Buffer>,
506 pool: GraphemePool,
508 links: LinkRegistry,
510 capabilities: TerminalCapabilities,
512 term_width: u16,
514 term_height: u16,
516 in_sync_block: bool,
518 cursor_saved: bool,
520 cursor_visible: bool,
522 inline_strategy: InlineStrategy,
524 scroll_region_active: bool,
526 last_inline_region: Option<InlineRegion>,
528 diff_strategy: DiffStrategySelector,
530 diff_scratch: BufferDiff,
532 runs_buf: Vec<ChangeRun>,
534 full_redraw_probe: u64,
536 #[allow(dead_code)] diff_config: RuntimeDiffConfig,
539 evidence_sink: Option<EvidenceSink>,
541 #[allow(dead_code)]
543 diff_evidence_run_id: String,
544 #[allow(dead_code)]
546 diff_evidence_idx: u64,
547 last_diff_strategy: Option<DiffStrategy>,
549 render_trace: Option<RenderTraceRecorder>,
551 timing_enabled: bool,
553 last_present_timings: Option<PresentTimings>,
555}
556
557impl<W: Write> TerminalWriter<W> {
558 pub fn new(
567 writer: W,
568 screen_mode: ScreenMode,
569 ui_anchor: UiAnchor,
570 capabilities: TerminalCapabilities,
571 ) -> Self {
572 Self::with_diff_config(
573 writer,
574 screen_mode,
575 ui_anchor,
576 capabilities,
577 RuntimeDiffConfig::default(),
578 )
579 }
580
581 pub fn with_diff_config(
610 writer: W,
611 screen_mode: ScreenMode,
612 ui_anchor: UiAnchor,
613 capabilities: TerminalCapabilities,
614 diff_config: RuntimeDiffConfig,
615 ) -> Self {
616 let inline_strategy = InlineStrategy::select(&capabilities);
617 let auto_ui_height = None;
618 let diff_strategy = DiffStrategySelector::new(diff_config.strategy_config.clone());
619
620 match screen_mode {
622 ScreenMode::Inline { ui_height } => {
623 info!(
624 inline_height = ui_height,
625 render_mode = %inline_strategy_str(inline_strategy),
626 "inline mode activated"
627 );
628 }
629 ScreenMode::InlineAuto {
630 min_height,
631 max_height,
632 } => {
633 info!(
634 min_height,
635 max_height,
636 render_mode = %inline_strategy_str(inline_strategy),
637 "inline auto mode activated"
638 );
639 }
640 ScreenMode::AltScreen => {}
641 }
642
643 let is_inline = matches!(
645 screen_mode,
646 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
647 );
648 if is_inline {
649 INLINE_ACTIVE_WIDGETS.fetch_add(1, Ordering::Relaxed);
650 }
651
652 let mut diff_scratch = BufferDiff::new();
653 diff_scratch
654 .tile_config_mut()
655 .clone_from(&diff_config.tile_diff_config);
656 Self {
657 writer: Some(CountingWriter::new(BufWriter::with_capacity(
658 BUFFER_CAPACITY,
659 writer,
660 ))),
661 screen_mode,
662 auto_ui_height,
663 ui_anchor,
664 prev_buffer: None,
665 spare_buffer: None,
666 clone_buf: None,
667 pool: GraphemePool::new(),
668 links: LinkRegistry::new(),
669 capabilities,
670 term_width: 80,
671 term_height: 24,
672 in_sync_block: false,
673 cursor_saved: false,
674 cursor_visible: true,
675 inline_strategy,
676 scroll_region_active: false,
677 last_inline_region: None,
678 diff_strategy,
679 diff_scratch,
680 runs_buf: Vec::new(),
681 full_redraw_probe: 0,
682 diff_config,
683 evidence_sink: None,
684 diff_evidence_run_id: default_diff_run_id(),
685 diff_evidence_idx: 0,
686 last_diff_strategy: None,
687 render_trace: None,
688 timing_enabled: false,
689 last_present_timings: None,
690 }
691 }
692
693 #[inline]
699 fn writer(&mut self) -> &mut CountingWriter<BufWriter<W>> {
700 self.writer.as_mut().expect("writer has been consumed")
701 }
702
703 fn reset_diff_strategy(&mut self) {
705 if self.diff_config.reset_on_invalidation {
706 self.diff_strategy.reset();
707 }
708 self.full_redraw_probe = 0;
709 self.last_diff_strategy = None;
710 }
711
712 #[allow(dead_code)] fn reset_diff_on_resize(&mut self) {
715 if self.diff_config.reset_on_resize {
716 self.diff_strategy.reset();
717 }
718 self.full_redraw_probe = 0;
719 self.last_diff_strategy = None;
720 }
721
722 pub fn diff_config(&self) -> &RuntimeDiffConfig {
724 &self.diff_config
725 }
726
727 pub(crate) fn set_timing_enabled(&mut self, enabled: bool) {
729 self.timing_enabled = enabled;
730 if !enabled {
731 self.last_present_timings = None;
732 }
733 }
734
735 pub(crate) fn take_last_present_timings(&mut self) -> Option<PresentTimings> {
737 self.last_present_timings.take()
738 }
739
740 #[must_use]
742 pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
743 self.evidence_sink = Some(sink);
744 self
745 }
746
747 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
749 self.evidence_sink = sink;
750 }
751
752 #[must_use]
754 pub fn with_render_trace(mut self, recorder: RenderTraceRecorder) -> Self {
755 self.render_trace = Some(recorder);
756 self
757 }
758
759 pub fn set_render_trace(&mut self, recorder: Option<RenderTraceRecorder>) {
761 self.render_trace = recorder;
762 }
763
764 pub fn diff_strategy_mut(&mut self) -> &mut DiffStrategySelector {
768 &mut self.diff_strategy
769 }
770
771 pub fn diff_strategy(&self) -> &DiffStrategySelector {
773 &self.diff_strategy
774 }
775
776 pub fn last_diff_strategy(&self) -> Option<DiffStrategy> {
778 self.last_diff_strategy
779 }
780
781 pub fn set_size(&mut self, width: u16, height: u16) {
785 self.term_width = width;
786 self.term_height = height;
787 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. }) {
788 self.auto_ui_height = None;
789 }
790 self.prev_buffer = None;
792 self.spare_buffer = None;
793 self.clone_buf = None;
794 self.reset_diff_on_resize();
795 if self.scroll_region_active {
797 let _ = self.deactivate_scroll_region();
798 }
799 }
800
801 pub fn take_render_buffer(&mut self, width: u16, height: u16) -> Buffer {
805 if let Some(mut buffer) = self.spare_buffer.take()
806 && buffer.width() == width
807 && buffer.height() == height
808 {
809 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
810 buffer.reset_for_frame();
811 return buffer;
812 }
813
814 let mut buffer = Buffer::new(width, height);
815 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
816 buffer
817 }
818
819 #[inline]
821 pub fn width(&self) -> u16 {
822 self.term_width
823 }
824
825 #[inline]
827 pub fn height(&self) -> u16 {
828 self.term_height
829 }
830
831 #[inline]
833 pub fn screen_mode(&self) -> ScreenMode {
834 self.screen_mode
835 }
836
837 pub fn render_height_hint(&self) -> u16 {
842 match self.screen_mode {
843 ScreenMode::Inline { ui_height } => ui_height,
844 ScreenMode::InlineAuto {
845 min_height,
846 max_height,
847 } => {
848 let (min, max) = sanitize_auto_bounds(min_height, max_height);
849 let max = max.min(self.term_height);
850 let min = min.min(max);
851 if let Some(current) = self.auto_ui_height {
852 current.clamp(min, max).min(self.term_height).max(min)
853 } else {
854 max.max(min)
855 }
856 }
857 ScreenMode::AltScreen => self.term_height,
858 }
859 }
860
861 pub fn inline_auto_bounds(&self) -> Option<(u16, u16)> {
863 match self.screen_mode {
864 ScreenMode::InlineAuto {
865 min_height,
866 max_height,
867 } => {
868 let (min, max) = sanitize_auto_bounds(min_height, max_height);
869 Some((min.min(self.term_height), max.min(self.term_height)))
870 }
871 _ => None,
872 }
873 }
874
875 pub fn auto_ui_height(&self) -> Option<u16> {
877 match self.screen_mode {
878 ScreenMode::InlineAuto { .. } => self.auto_ui_height,
879 _ => None,
880 }
881 }
882
883 pub fn set_auto_ui_height(&mut self, height: u16) {
885 if let ScreenMode::InlineAuto {
886 min_height,
887 max_height,
888 } = self.screen_mode
889 {
890 let (min, max) = sanitize_auto_bounds(min_height, max_height);
891 let max = max.min(self.term_height);
892 let min = min.min(max);
893 let clamped = height.clamp(min, max);
894 let previous_effective = self.auto_ui_height.unwrap_or(min);
895 if self.auto_ui_height != Some(clamped) {
896 self.auto_ui_height = Some(clamped);
897 if clamped != previous_effective {
898 self.prev_buffer = None;
899 self.reset_diff_strategy();
900 if self.scroll_region_active {
901 let _ = self.deactivate_scroll_region();
902 }
903 }
904 }
905 }
906 }
907
908 pub fn clear_auto_ui_height(&mut self) {
910 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. })
911 && self.auto_ui_height.is_some()
912 {
913 self.auto_ui_height = None;
914 self.prev_buffer = None;
915 self.reset_diff_strategy();
916 if self.scroll_region_active {
917 let _ = self.deactivate_scroll_region();
918 }
919 }
920 }
921
922 fn effective_ui_height(&self) -> u16 {
923 match self.screen_mode {
924 ScreenMode::Inline { ui_height } => ui_height,
925 ScreenMode::InlineAuto {
926 min_height,
927 max_height,
928 } => {
929 let (min, max) = sanitize_auto_bounds(min_height, max_height);
930 let current = self.auto_ui_height.unwrap_or(min);
931 current.clamp(min, max).min(self.term_height)
932 }
933 ScreenMode::AltScreen => self.term_height,
934 }
935 }
936
937 pub fn ui_height(&self) -> u16 {
939 self.effective_ui_height()
940 }
941
942 fn ui_start_row(&self) -> u16 {
944 let ui_height = self.effective_ui_height().min(self.term_height);
945 match (self.screen_mode, self.ui_anchor) {
946 (ScreenMode::Inline { .. }, UiAnchor::Bottom)
947 | (ScreenMode::InlineAuto { .. }, UiAnchor::Bottom) => {
948 self.term_height.saturating_sub(ui_height)
949 }
950 (ScreenMode::Inline { .. }, UiAnchor::Top)
951 | (ScreenMode::InlineAuto { .. }, UiAnchor::Top) => 0,
952 (ScreenMode::AltScreen, _) => 0,
953 }
954 }
955
956 pub fn inline_strategy(&self) -> InlineStrategy {
958 self.inline_strategy
959 }
960
961 pub fn scroll_region_active(&self) -> bool {
963 self.scroll_region_active
964 }
965
966 fn activate_scroll_region(&mut self, ui_height: u16) -> io::Result<()> {
974 if self.scroll_region_active {
975 return Ok(());
976 }
977
978 let ui_height = ui_height.min(self.term_height);
979 if ui_height >= self.term_height {
980 return Ok(());
981 }
982
983 match self.ui_anchor {
984 UiAnchor::Bottom => {
985 let term_height = self.term_height;
986 let log_bottom = term_height.saturating_sub(ui_height);
987 if log_bottom > 0 {
988 write!(self.writer(), "\x1b[1;{}r", log_bottom)?;
990 self.scroll_region_active = true;
991 }
992 }
993 UiAnchor::Top => {
994 let term_height = self.term_height;
995 let log_top = ui_height.saturating_add(1);
996 if log_top <= term_height {
997 write!(self.writer(), "\x1b[{};{}r", log_top, term_height)?;
999 self.scroll_region_active = true;
1000 write!(self.writer(), "\x1b[{};1H", log_top)?;
1003 }
1004 }
1005 }
1006 Ok(())
1007 }
1008
1009 fn deactivate_scroll_region(&mut self) -> io::Result<()> {
1011 if self.scroll_region_active {
1012 self.writer().write_all(b"\x1b[r")?;
1013 self.scroll_region_active = false;
1014 }
1015 Ok(())
1016 }
1017
1018 fn clear_rows(&mut self, start_row: u16, height: u16) -> io::Result<()> {
1019 let start_row = start_row.min(self.term_height);
1020 let end_row = start_row.saturating_add(height).min(self.term_height);
1021 for row in start_row..end_row {
1022 write!(self.writer(), "\x1b[{};1H", row.saturating_add(1))?;
1023 self.writer().write_all(ERASE_LINE)?;
1024 }
1025 Ok(())
1026 }
1027
1028 fn clear_inline_region_diff(&mut self, current: InlineRegion) -> io::Result<()> {
1029 let Some(previous) = self.last_inline_region else {
1030 return Ok(());
1031 };
1032
1033 let prev_start = previous.start.min(self.term_height);
1034 let prev_end = previous
1035 .start
1036 .saturating_add(previous.height)
1037 .min(self.term_height);
1038 if prev_start >= prev_end {
1039 return Ok(());
1040 }
1041
1042 let curr_start = current.start.min(self.term_height);
1043 let curr_end = current
1044 .start
1045 .saturating_add(current.height)
1046 .min(self.term_height);
1047
1048 if curr_start > prev_start {
1049 let clear_end = curr_start.min(prev_end);
1050 if clear_end > prev_start {
1051 self.clear_rows(prev_start, clear_end - prev_start)?;
1052 }
1053 }
1054
1055 if curr_end < prev_end {
1056 let clear_start = curr_end.max(prev_start);
1057 if prev_end > clear_start {
1058 self.clear_rows(clear_start, prev_end - clear_start)?;
1059 }
1060 }
1061
1062 Ok(())
1063 }
1064
1065 pub fn present_ui(
1079 &mut self,
1080 buffer: &Buffer,
1081 cursor: Option<(u16, u16)>,
1082 cursor_visible: bool,
1083 ) -> io::Result<()> {
1084 let mode_str = match self.screen_mode {
1085 ScreenMode::Inline { .. } => "inline",
1086 ScreenMode::InlineAuto { .. } => "inline_auto",
1087 ScreenMode::AltScreen => "altscreen",
1088 };
1089 let trace_enabled = self.render_trace.is_some();
1090 if trace_enabled {
1091 self.writer().enable_counting();
1092 }
1093 let present_start = if trace_enabled {
1094 Some(Instant::now())
1095 } else {
1096 None
1097 };
1098 let _span = info_span!(
1099 "ftui.render.present",
1100 mode = mode_str,
1101 width = buffer.width(),
1102 height = buffer.height(),
1103 )
1104 .entered();
1105
1106 let result = match self.screen_mode {
1107 ScreenMode::Inline { ui_height } => {
1108 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1109 }
1110 ScreenMode::InlineAuto { .. } => {
1111 let ui_height = self.effective_ui_height();
1112 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1113 }
1114 ScreenMode::AltScreen => self.present_altscreen(buffer, cursor, cursor_visible),
1115 };
1116
1117 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1118 let present_bytes = if trace_enabled {
1119 Some(self.writer().take_count())
1120 } else {
1121 None
1122 };
1123 if trace_enabled {
1124 self.writer().disable_counting();
1125 }
1126
1127 if let Ok(stats) = result {
1128 let new_prev = match self.clone_buf.take() {
1131 Some(mut buf)
1132 if buf.width() == buffer.width() && buf.height() == buffer.height() =>
1133 {
1134 buf.clone_from(buffer);
1135 buf
1136 }
1137 _ => buffer.clone(),
1138 };
1139 self.clone_buf = self.spare_buffer.take();
1140 self.spare_buffer = self.prev_buffer.take();
1141 self.prev_buffer = Some(new_prev);
1142
1143 if let Some(ref mut trace) = self.render_trace {
1144 let payload_info = match stats.diff_strategy {
1145 DiffStrategy::FullRedraw => {
1146 let payload = build_full_buffer_payload(buffer, &self.pool);
1147 trace.write_payload(&payload).ok()
1148 }
1149 _ => {
1150 let payload =
1151 build_diff_runs_payload(buffer, &self.diff_scratch, &self.pool);
1152 trace.write_payload(&payload).ok()
1153 }
1154 };
1155 let (payload_kind, payload_path) = match payload_info {
1156 Some(info) => (info.kind, Some(info.path)),
1157 None => ("none", None),
1158 };
1159 let payload_path_ref = payload_path.as_deref();
1160 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1161 let ui_anchor = ui_anchor_str(self.ui_anchor);
1162 let frame = RenderTraceFrame {
1163 cols: buffer.width(),
1164 rows: buffer.height(),
1165 mode: mode_str,
1166 ui_height: stats.ui_height,
1167 ui_anchor,
1168 diff_strategy,
1169 diff_cells: stats.diff_cells,
1170 diff_runs: stats.diff_runs,
1171 present_bytes: present_bytes.unwrap_or(0),
1172 render_us: None,
1173 present_us,
1174 payload_kind,
1175 payload_path: payload_path_ref,
1176 trace_us: None,
1177 };
1178 let _ = trace.record_frame(frame, buffer, &self.pool);
1179 }
1180 return Ok(());
1181 }
1182
1183 result.map(|_| ())
1184 }
1185
1186 pub fn present_ui_owned(
1191 &mut self,
1192 buffer: Buffer,
1193 cursor: Option<(u16, u16)>,
1194 cursor_visible: bool,
1195 ) -> io::Result<()> {
1196 let mode_str = match self.screen_mode {
1197 ScreenMode::Inline { .. } => "inline",
1198 ScreenMode::InlineAuto { .. } => "inline_auto",
1199 ScreenMode::AltScreen => "altscreen",
1200 };
1201 let trace_enabled = self.render_trace.is_some();
1202 if trace_enabled {
1203 self.writer().enable_counting();
1204 }
1205 let present_start = if trace_enabled {
1206 Some(Instant::now())
1207 } else {
1208 None
1209 };
1210 let _span = info_span!(
1211 "ftui.render.present",
1212 mode = mode_str,
1213 width = buffer.width(),
1214 height = buffer.height(),
1215 )
1216 .entered();
1217
1218 let result = match self.screen_mode {
1219 ScreenMode::Inline { ui_height } => {
1220 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1221 }
1222 ScreenMode::InlineAuto { .. } => {
1223 let ui_height = self.effective_ui_height();
1224 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1225 }
1226 ScreenMode::AltScreen => self.present_altscreen(&buffer, cursor, cursor_visible),
1227 };
1228
1229 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1230 let present_bytes = if trace_enabled {
1231 Some(self.writer().take_count())
1232 } else {
1233 None
1234 };
1235 if trace_enabled {
1236 self.writer().disable_counting();
1237 }
1238
1239 if let Ok(stats) = result {
1240 if let Some(ref mut trace) = self.render_trace {
1241 let payload_info = match stats.diff_strategy {
1242 DiffStrategy::FullRedraw => {
1243 let payload = build_full_buffer_payload(&buffer, &self.pool);
1244 trace.write_payload(&payload).ok()
1245 }
1246 _ => {
1247 let payload =
1248 build_diff_runs_payload(&buffer, &self.diff_scratch, &self.pool);
1249 trace.write_payload(&payload).ok()
1250 }
1251 };
1252 let (payload_kind, payload_path) = match payload_info {
1253 Some(info) => (info.kind, Some(info.path)),
1254 None => ("none", None),
1255 };
1256 let payload_path_ref = payload_path.as_deref();
1257 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1258 let ui_anchor = ui_anchor_str(self.ui_anchor);
1259 let frame = RenderTraceFrame {
1260 cols: buffer.width(),
1261 rows: buffer.height(),
1262 mode: mode_str,
1263 ui_height: stats.ui_height,
1264 ui_anchor,
1265 diff_strategy,
1266 diff_cells: stats.diff_cells,
1267 diff_runs: stats.diff_runs,
1268 present_bytes: present_bytes.unwrap_or(0),
1269 render_us: None,
1270 present_us,
1271 payload_kind,
1272 payload_path: payload_path_ref,
1273 trace_us: None,
1274 };
1275 let _ = trace.record_frame(frame, &buffer, &self.pool);
1276 }
1277
1278 self.clone_buf = self.spare_buffer.take();
1280 self.spare_buffer = self.prev_buffer.take();
1281 self.prev_buffer = Some(buffer);
1282 return Ok(());
1283 }
1284
1285 result.map(|_| ())
1286 }
1287
1288 fn decide_diff(&mut self, buffer: &Buffer) -> DiffDecision {
1289 let prev_dims = self
1290 .prev_buffer
1291 .as_ref()
1292 .map(|prev| (prev.width(), prev.height()));
1293 if prev_dims.is_none() || prev_dims != Some((buffer.width(), buffer.height())) {
1294 self.full_redraw_probe = 0;
1295 self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1296 return DiffDecision {
1297 strategy: DiffStrategy::FullRedraw,
1298 has_diff: false,
1299 };
1300 }
1301
1302 let dirty_rows = buffer.dirty_row_count();
1303 let width = buffer.width() as usize;
1304 let height = buffer.height() as usize;
1305 let mut span_stats_snapshot: Option<DirtySpanStats> = None;
1306 let mut dirty_scan_cells_estimate = dirty_rows.saturating_mul(width);
1307
1308 if self.diff_config.bayesian_enabled {
1309 let span_stats = buffer.dirty_span_stats();
1310 if span_stats.span_coverage_cells > 0 {
1311 dirty_scan_cells_estimate = span_stats.span_coverage_cells;
1312 }
1313 span_stats_snapshot = Some(span_stats);
1314 }
1315
1316 let mut strategy = if self.diff_config.bayesian_enabled {
1318 self.diff_strategy.select_with_scan_estimate(
1320 buffer.width(),
1321 buffer.height(),
1322 dirty_rows,
1323 dirty_scan_cells_estimate,
1324 )
1325 } else {
1326 if self.diff_config.dirty_rows_enabled && dirty_rows < buffer.height() as usize {
1328 DiffStrategy::DirtyRows
1329 } else {
1330 DiffStrategy::Full
1331 }
1332 };
1333
1334 if !self.diff_config.dirty_rows_enabled && strategy == DiffStrategy::DirtyRows {
1336 strategy = DiffStrategy::Full;
1337 if self.diff_config.bayesian_enabled {
1338 self.diff_strategy
1339 .override_last_strategy(strategy, "dirty_rows_disabled");
1340 }
1341 }
1342
1343 if strategy == DiffStrategy::FullRedraw {
1345 if self.full_redraw_probe >= FULL_REDRAW_PROBE_INTERVAL {
1346 self.full_redraw_probe = 0;
1347 let probed = if self.diff_config.dirty_rows_enabled
1348 && dirty_rows < buffer.height() as usize
1349 {
1350 DiffStrategy::DirtyRows
1351 } else {
1352 DiffStrategy::Full
1353 };
1354 if probed != strategy {
1355 strategy = probed;
1356 if self.diff_config.bayesian_enabled {
1357 self.diff_strategy
1358 .override_last_strategy(strategy, "full_redraw_probe");
1359 }
1360 }
1361 } else {
1362 self.full_redraw_probe = self.full_redraw_probe.saturating_add(1);
1363 }
1364 } else {
1365 self.full_redraw_probe = 0;
1366 }
1367
1368 let mut has_diff = false;
1369 match strategy {
1370 DiffStrategy::Full => {
1371 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1372 self.diff_scratch.compute_into(prev, buffer);
1373 has_diff = true;
1374 }
1375 DiffStrategy::DirtyRows => {
1376 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1377 self.diff_scratch.compute_dirty_into(prev, buffer);
1378 has_diff = true;
1379 }
1380 DiffStrategy::FullRedraw => {}
1381 }
1382
1383 let mut scan_cost_estimate = 0usize;
1384 let mut fallback_reason: &'static str = "none";
1385 let tile_stats = if strategy == DiffStrategy::DirtyRows {
1386 self.diff_scratch.last_tile_stats()
1387 } else {
1388 None
1389 };
1390
1391 if self.diff_config.bayesian_enabled && has_diff {
1393 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1394 let (scan_cost, reason) = estimate_diff_scan_cost(
1395 strategy,
1396 dirty_rows,
1397 width,
1398 height,
1399 &span_stats,
1400 tile_stats,
1401 );
1402 let scanned_cells = scan_cost.max(self.diff_scratch.len());
1403 self.diff_strategy
1404 .observe(scanned_cells, self.diff_scratch.len());
1405 span_stats_snapshot = Some(span_stats);
1406 scan_cost_estimate = scan_cost;
1407 fallback_reason = reason;
1408 }
1409
1410 if let Some(evidence) = self.diff_strategy.last_evidence() {
1411 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1412 let (scan_cost, reason) = if span_stats_snapshot.is_some() {
1413 (scan_cost_estimate, fallback_reason)
1414 } else {
1415 estimate_diff_scan_cost(
1416 strategy,
1417 dirty_rows,
1418 width,
1419 height,
1420 &span_stats,
1421 tile_stats,
1422 )
1423 };
1424 let span_coverage_pct = if evidence.total_cells == 0 {
1425 0.0
1426 } else {
1427 (span_stats.span_coverage_cells as f64 / evidence.total_cells as f64) * 100.0
1428 };
1429 let span_count = span_stats.total_spans;
1430 let max_span_len = span_stats.max_span_len;
1431 let event_idx = self.diff_evidence_idx;
1432 self.diff_evidence_idx = self.diff_evidence_idx.saturating_add(1);
1433 let tile_used = tile_stats.is_some_and(|stats| stats.fallback.is_none());
1434 let tile_fallback = tile_stats
1435 .and_then(|stats| stats.fallback)
1436 .map(TileDiffFallback::as_str)
1437 .unwrap_or("none");
1438 let run_id = json_escape(&self.diff_evidence_run_id);
1439 let strategy_json = json_escape(&strategy.to_string());
1440 let guard_reason_json = json_escape(evidence.guard_reason);
1441 let fallback_reason_json = json_escape(reason);
1442 let tile_fallback_json = json_escape(tile_fallback);
1443 let schema_version = crate::evidence_sink::EVIDENCE_SCHEMA_VERSION;
1444 let screen_mode = match self.screen_mode {
1445 ScreenMode::Inline { .. } => "inline",
1446 ScreenMode::InlineAuto { .. } => "inline_auto",
1447 ScreenMode::AltScreen => "altscreen",
1448 };
1449 let (
1450 tile_w,
1451 tile_h,
1452 tiles_x,
1453 tiles_y,
1454 dirty_tiles,
1455 dirty_cells,
1456 dirty_tile_ratio,
1457 dirty_cell_ratio,
1458 scanned_tiles,
1459 skipped_tiles,
1460 scan_cells_estimate,
1461 sat_build_cells,
1462 ) = if let Some(stats) = tile_stats {
1463 (
1464 stats.tile_w,
1465 stats.tile_h,
1466 stats.tiles_x,
1467 stats.tiles_y,
1468 stats.dirty_tiles,
1469 stats.dirty_cells,
1470 stats.dirty_tile_ratio,
1471 stats.dirty_cell_ratio,
1472 stats.scanned_tiles,
1473 stats.skipped_tiles,
1474 stats.scan_cells_estimate,
1475 stats.sat_build_cells,
1476 )
1477 } else {
1478 (0, 0, 0, 0, 0, 0, 0.0, 0.0, 0, 0, 0, 0)
1479 };
1480 let tile_size = tile_w as usize * tile_h as usize;
1481 let dirty_tile_count = dirty_tiles;
1482 let skipped_tile_count = skipped_tiles;
1483 let sat_build_cost_est = sat_build_cells;
1484
1485 set_diff_snapshot(Some(DiffDecisionSnapshot {
1486 event_idx,
1487 screen_mode: screen_mode.to_string(),
1488 cols: u16::try_from(width).unwrap_or(u16::MAX),
1489 rows: u16::try_from(height).unwrap_or(u16::MAX),
1490 evidence: evidence.clone(),
1491 span_count,
1492 span_coverage_pct,
1493 max_span_len,
1494 scan_cost_estimate: scan_cost,
1495 fallback_reason: reason.to_string(),
1496 tile_used,
1497 tile_fallback: tile_fallback.to_string(),
1498 strategy_used: strategy,
1499 }));
1500
1501 trace!(
1502 strategy = %strategy,
1503 selected = %evidence.strategy,
1504 cost_full = evidence.cost_full,
1505 cost_dirty = evidence.cost_dirty,
1506 cost_redraw = evidence.cost_redraw,
1507 dirty_rows = evidence.dirty_rows,
1508 total_rows = evidence.total_rows,
1509 total_cells = evidence.total_cells,
1510 bayesian_enabled = self.diff_config.bayesian_enabled,
1511 dirty_rows_enabled = self.diff_config.dirty_rows_enabled,
1512 "diff strategy selected"
1513 );
1514 if let Some(ref sink) = self.evidence_sink {
1515 let line = format!(
1516 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":{}}}"#,
1517 schema_version,
1518 run_id,
1519 event_idx,
1520 screen_mode,
1521 width,
1522 height,
1523 strategy_json,
1524 evidence.cost_full,
1525 evidence.cost_dirty,
1526 evidence.cost_redraw,
1527 evidence.posterior_mean,
1528 evidence.posterior_variance,
1529 evidence.alpha,
1530 evidence.beta,
1531 guard_reason_json,
1532 evidence.hysteresis_applied,
1533 evidence.hysteresis_ratio,
1534 evidence.dirty_rows,
1535 evidence.total_rows,
1536 evidence.total_cells,
1537 span_count,
1538 span_coverage_pct,
1539 max_span_len,
1540 fallback_reason_json,
1541 scan_cost,
1542 tile_used,
1543 tile_fallback_json,
1544 tile_w,
1545 tile_h,
1546 tile_size,
1547 tiles_x,
1548 tiles_y,
1549 dirty_tiles,
1550 dirty_tile_count,
1551 dirty_cells,
1552 dirty_tile_ratio,
1553 dirty_cell_ratio,
1554 scanned_tiles,
1555 skipped_tiles,
1556 skipped_tile_count,
1557 scan_cells_estimate,
1558 sat_build_cost_est,
1559 self.diff_config.bayesian_enabled,
1560 self.diff_config.dirty_rows_enabled,
1561 );
1562 let _ = sink.write_jsonl(&line);
1563 }
1564 }
1565
1566 self.last_diff_strategy = Some(strategy);
1567 DiffDecision { strategy, has_diff }
1568 }
1569
1570 fn present_inline(
1576 &mut self,
1577 buffer: &Buffer,
1578 ui_height: u16,
1579 cursor: Option<(u16, u16)>,
1580 cursor_visible: bool,
1581 ) -> io::Result<FrameEmitStats> {
1582 let render_mode = inline_strategy_str(self.inline_strategy);
1583 let _inline_span = info_span!(
1584 "inline.render",
1585 inline_height = ui_height,
1586 scrollback_preserved = tracing::field::Empty,
1587 render_mode,
1588 )
1589 .entered();
1590
1591 let result = (|| -> io::Result<FrameEmitStats> {
1592 let visible_height = ui_height.min(self.term_height);
1593 let ui_y_start = self.ui_start_row();
1594 let current_region = InlineRegion {
1595 start: ui_y_start,
1596 height: visible_height,
1597 };
1598
1599 if self.capabilities.sync_output && !self.in_sync_block {
1601 self.writer().write_all(SYNC_BEGIN)?;
1602 self.in_sync_block = true;
1603 }
1604
1605 self.writer().write_all(CURSOR_SAVE)?;
1607 self.cursor_saved = true;
1608
1609 {
1611 let _span = debug_span!("ftui.render.scroll_region").entered();
1612 if visible_height > 0 {
1613 match self.inline_strategy {
1614 InlineStrategy::ScrollRegion | InlineStrategy::Hybrid => {
1615 self.activate_scroll_region(visible_height)?;
1616 }
1617 InlineStrategy::OverlayRedraw => {}
1618 }
1619 } else if self.scroll_region_active {
1620 self.deactivate_scroll_region()?;
1621 }
1622 }
1623
1624 self.clear_inline_region_diff(current_region)?;
1625
1626 let mut diff_strategy = DiffStrategy::FullRedraw;
1627 let mut diff_us = 0u64;
1628 let mut emit_stats = EmitStats {
1629 diff_cells: 0,
1630 diff_runs: 0,
1631 };
1632
1633 if visible_height > 0 {
1634 if self.prev_buffer.is_none() {
1637 self.clear_rows(ui_y_start, visible_height)?;
1638 } else {
1639 let buf_height = buffer.height().min(visible_height);
1642 if buf_height < visible_height {
1643 let clear_start = ui_y_start.saturating_add(buf_height);
1644 let clear_height = visible_height.saturating_sub(buf_height);
1645 self.clear_rows(clear_start, clear_height)?;
1646 }
1647 }
1648
1649 let diff_start = if self.timing_enabled {
1651 Some(Instant::now())
1652 } else {
1653 None
1654 };
1655 let decision = {
1656 let _span = debug_span!("ftui.render.diff_compute").entered();
1657 self.decide_diff(buffer)
1658 };
1659 if let Some(start) = diff_start {
1660 diff_us = start.elapsed().as_micros() as u64;
1661 }
1662 diff_strategy = decision.strategy;
1663
1664 {
1666 let _span = debug_span!("ftui.render.emit").entered();
1667 if decision.has_diff {
1668 let diff = std::mem::take(&mut self.diff_scratch);
1669 let result =
1670 self.emit_diff(buffer, &diff, Some(visible_height), ui_y_start);
1671 self.diff_scratch = diff;
1672 emit_stats = result?;
1673 } else {
1674 emit_stats =
1675 self.emit_full_redraw(buffer, Some(visible_height), ui_y_start)?;
1676 }
1677 }
1678 }
1679
1680 self.writer().write_all(b"\x1b[0m")?;
1682
1683 self.writer().write_all(CURSOR_RESTORE)?;
1685 self.cursor_saved = false;
1686
1687 if cursor_visible {
1688 if let Some((cx, cy)) = cursor
1690 && cy < visible_height
1691 {
1692 let abs_y = ui_y_start.saturating_add(cy);
1694 write!(
1695 self.writer(),
1696 "\x1b[{};{}H",
1697 abs_y.saturating_add(1),
1698 cx.saturating_add(1)
1699 )?;
1700 }
1701 self.set_cursor_visibility(true)?;
1702 } else {
1703 self.set_cursor_visibility(false)?;
1704 }
1705
1706 if self.in_sync_block {
1708 self.writer().write_all(SYNC_END)?;
1709 self.in_sync_block = false;
1710 }
1711
1712 self.writer().flush()?;
1713 self.last_inline_region = if visible_height > 0 {
1714 Some(current_region)
1715 } else {
1716 None
1717 };
1718
1719 if self.timing_enabled {
1720 self.last_present_timings = Some(PresentTimings { diff_us });
1721 }
1722
1723 Ok(FrameEmitStats {
1724 diff_strategy,
1725 diff_cells: emit_stats.diff_cells,
1726 diff_runs: emit_stats.diff_runs,
1727 ui_height: visible_height,
1728 })
1729 })();
1730
1731 if result.is_err() {
1732 _inline_span.record("scrollback_preserved", false);
1733 warn!(
1734 inline_height = ui_height,
1735 render_mode, "scrollback preservation failed during inline render"
1736 );
1737 self.best_effort_inline_cleanup();
1738 } else {
1739 _inline_span.record("scrollback_preserved", true);
1740 }
1741
1742 result
1743 }
1744
1745 fn present_altscreen(
1747 &mut self,
1748 buffer: &Buffer,
1749 cursor: Option<(u16, u16)>,
1750 cursor_visible: bool,
1751 ) -> io::Result<FrameEmitStats> {
1752 let diff_start = if self.timing_enabled {
1753 Some(Instant::now())
1754 } else {
1755 None
1756 };
1757 let decision = {
1758 let _span = debug_span!("ftui.render.diff_compute").entered();
1759 self.decide_diff(buffer)
1760 };
1761 let diff_us = diff_start
1762 .map(|start| start.elapsed().as_micros() as u64)
1763 .unwrap_or(0);
1764
1765 if self.capabilities.sync_output {
1767 self.writer().write_all(SYNC_BEGIN)?;
1768 }
1769
1770 let emit_stats = {
1771 let _span = debug_span!("ftui.render.emit").entered();
1772 if decision.has_diff {
1773 let diff = std::mem::take(&mut self.diff_scratch);
1774 let result = self.emit_diff(buffer, &diff, None, 0);
1775 self.diff_scratch = diff;
1776 result?
1777 } else {
1778 self.emit_full_redraw(buffer, None, 0)?
1779 }
1780 };
1781
1782 self.writer().write_all(b"\x1b[0m")?;
1784
1785 if cursor_visible {
1786 if let Some((cx, cy)) = cursor {
1788 write!(
1789 self.writer(),
1790 "\x1b[{};{}H",
1791 cy.saturating_add(1),
1792 cx.saturating_add(1)
1793 )?;
1794 }
1795 self.set_cursor_visibility(true)?;
1796 } else {
1797 self.set_cursor_visibility(false)?;
1798 }
1799
1800 if self.capabilities.sync_output {
1801 self.writer().write_all(SYNC_END)?;
1802 }
1803
1804 self.writer().flush()?;
1805
1806 if self.timing_enabled {
1807 self.last_present_timings = Some(PresentTimings { diff_us });
1808 }
1809
1810 Ok(FrameEmitStats {
1811 diff_strategy: decision.strategy,
1812 diff_cells: emit_stats.diff_cells,
1813 diff_runs: emit_stats.diff_runs,
1814 ui_height: 0,
1815 })
1816 }
1817
1818 fn emit_diff(
1820 &mut self,
1821 buffer: &Buffer,
1822 diff: &BufferDiff,
1823 max_height: Option<u16>,
1824 ui_y_start: u16,
1825 ) -> io::Result<EmitStats> {
1826 use ftui_render::cell::{Cell, CellAttrs, StyleFlags};
1827
1828 diff.runs_into(&mut self.runs_buf);
1829 let diff_runs = self.runs_buf.len();
1830 let diff_cells = diff.len();
1831 let _span = debug_span!("ftui.render.emit_diff", run_count = self.runs_buf.len()).entered();
1832
1833 let mut current_style: Option<(
1834 ftui_render::cell::PackedRgba,
1835 ftui_render::cell::PackedRgba,
1836 StyleFlags,
1837 )> = None;
1838 let mut current_link: Option<u32> = None;
1839 let default_cell = Cell::default();
1840
1841 let writer = self.writer.as_mut().expect("writer has been consumed");
1843
1844 for run in &self.runs_buf {
1845 if let Some(limit) = max_height
1846 && run.y >= limit
1847 {
1848 continue;
1849 }
1850 write!(
1852 writer,
1853 "\x1b[{};{}H",
1854 ui_y_start.saturating_add(run.y).saturating_add(1),
1855 run.x0.saturating_add(1)
1856 )?;
1857
1858 let mut cursor_x = run.x0;
1860 for x in run.x0..=run.x1 {
1861 let cell = buffer.get_unchecked(x, run.y);
1862
1863 let is_orphan = cell.is_continuation() && cursor_x <= x;
1865 if cell.is_continuation() && !is_orphan {
1866 continue;
1867 }
1868 let effective_cell = if is_orphan { &default_cell } else { cell };
1869
1870 let cell_style = (
1872 effective_cell.fg,
1873 effective_cell.bg,
1874 effective_cell.attrs.flags(),
1875 );
1876 if current_style != Some(cell_style) {
1877 writer.write_all(b"\x1b[0m")?;
1879
1880 if !cell_style.2.is_empty() {
1882 Self::emit_style_flags(writer, cell_style.2)?;
1883 }
1884
1885 if cell_style.0.a() > 0 {
1887 write!(
1888 writer,
1889 "\x1b[38;2;{};{};{}m",
1890 cell_style.0.r(),
1891 cell_style.0.g(),
1892 cell_style.0.b()
1893 )?;
1894 }
1895 if cell_style.1.a() > 0 {
1896 write!(
1897 writer,
1898 "\x1b[48;2;{};{};{}m",
1899 cell_style.1.r(),
1900 cell_style.1.g(),
1901 cell_style.1.b()
1902 )?;
1903 }
1904
1905 current_style = Some(cell_style);
1906 }
1907
1908 let raw_link_id = effective_cell.attrs.link_id();
1910 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1911 None
1912 } else {
1913 Some(raw_link_id)
1914 };
1915
1916 if current_link != new_link {
1917 if current_link.is_some() {
1919 writer.write_all(b"\x1b]8;;\x1b\\")?;
1920 }
1921 let actually_opened = if let Some(link_id) = new_link
1923 && let Some(url) = self.links.get(link_id)
1924 {
1925 write!(writer, "\x1b]8;;{}\x1b\\", url)?;
1926 true
1927 } else {
1928 false
1929 };
1930 current_link = if actually_opened { new_link } else { None };
1931 }
1932
1933 let raw_width = effective_cell.content.width();
1934 let is_zero_width_content = raw_width == 0
1935 && !effective_cell.is_empty()
1936 && !effective_cell.is_continuation();
1937
1938 if is_zero_width_content {
1940 writer.write_all(b"\xEF\xBF\xBD")?;
1941 } else if let Some(ch) = effective_cell.content.as_char() {
1942 let mut buf = [0u8; 4];
1943 let encoded = ch.encode_utf8(&mut buf);
1944 writer.write_all(encoded.as_bytes())?;
1945 } else if let Some(gid) = effective_cell.content.grapheme_id() {
1946 if let Some(text) = self.pool.get(gid) {
1948 writer.write_all(text.as_bytes())?;
1949 } else {
1950 for _ in 0..raw_width.max(1) {
1952 writer.write_all(b"?")?;
1953 }
1954 }
1955 } else {
1956 writer.write_all(b" ")?;
1957 }
1958
1959 let advance = if effective_cell.is_empty() || is_zero_width_content {
1960 1
1961 } else {
1962 raw_width.max(1)
1963 };
1964 cursor_x = cursor_x.saturating_add(advance as u16);
1965 }
1966 }
1967
1968 writer.write_all(b"\x1b[0m")?;
1970
1971 if current_link.is_some() {
1973 writer.write_all(b"\x1b]8;;\x1b\\")?;
1974 }
1975
1976 trace!("emit_diff complete");
1977 Ok(EmitStats {
1978 diff_cells,
1979 diff_runs,
1980 })
1981 }
1982
1983 fn emit_full_redraw(
1985 &mut self,
1986 buffer: &Buffer,
1987 max_height: Option<u16>,
1988 ui_y_start: u16,
1989 ) -> io::Result<EmitStats> {
1990 use ftui_render::cell::{Cell, CellAttrs, StyleFlags};
1991
1992 let height = max_height.unwrap_or(buffer.height()).min(buffer.height());
1993 let width = buffer.width();
1994 let diff_cells = width as usize * height as usize;
1995 let diff_runs = height as usize;
1996
1997 let _span = debug_span!("ftui.render.emit_full_redraw").entered();
1998
1999 let mut current_style: Option<(
2000 ftui_render::cell::PackedRgba,
2001 ftui_render::cell::PackedRgba,
2002 StyleFlags,
2003 )> = None;
2004 let mut current_link: Option<u32> = None;
2005 let default_cell = Cell::default();
2006
2007 let writer = self.writer.as_mut().expect("writer has been consumed");
2009
2010 for y in 0..height {
2011 write!(
2012 writer,
2013 "\x1b[{};{}H",
2014 ui_y_start.saturating_add(y).saturating_add(1),
2015 1
2016 )?;
2017
2018 let mut cursor_x = 0u16;
2019 for x in 0..width {
2020 let cell = buffer.get_unchecked(x, y);
2021
2022 let is_orphan = cell.is_continuation() && cursor_x <= x;
2024 if cell.is_continuation() && !is_orphan {
2025 continue;
2026 }
2027 let effective_cell = if is_orphan { &default_cell } else { cell };
2028
2029 let cell_style = (
2031 effective_cell.fg,
2032 effective_cell.bg,
2033 effective_cell.attrs.flags(),
2034 );
2035 if current_style != Some(cell_style) {
2036 writer.write_all(b"\x1b[0m")?;
2038
2039 if !cell_style.2.is_empty() {
2041 Self::emit_style_flags(writer, cell_style.2)?;
2042 }
2043
2044 if cell_style.0.a() > 0 {
2046 write!(
2047 writer,
2048 "\x1b[38;2;{};{};{}m",
2049 cell_style.0.r(),
2050 cell_style.0.g(),
2051 cell_style.0.b()
2052 )?;
2053 }
2054 if cell_style.1.a() > 0 {
2055 write!(
2056 writer,
2057 "\x1b[48;2;{};{};{}m",
2058 cell_style.1.r(),
2059 cell_style.1.g(),
2060 cell_style.1.b()
2061 )?;
2062 }
2063
2064 current_style = Some(cell_style);
2065 }
2066
2067 let raw_link_id = effective_cell.attrs.link_id();
2069 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
2070 None
2071 } else {
2072 Some(raw_link_id)
2073 };
2074
2075 if current_link != new_link {
2076 if current_link.is_some() {
2078 writer.write_all(b"\x1b]8;;\x1b\\")?;
2079 }
2080 let actually_opened = if let Some(link_id) = new_link
2082 && let Some(url) = self.links.get(link_id)
2083 {
2084 write!(writer, "\x1b]8;;{}\x1b\\", url)?;
2085 true
2086 } else {
2087 false
2088 };
2089 current_link = if actually_opened { new_link } else { None };
2090 }
2091
2092 let raw_width = effective_cell.content.width();
2093 let is_zero_width_content = raw_width == 0
2094 && !effective_cell.is_empty()
2095 && !effective_cell.is_continuation();
2096
2097 if is_zero_width_content {
2099 writer.write_all(b"\xEF\xBF\xBD")?;
2100 } else if let Some(ch) = effective_cell.content.as_char() {
2101 let mut buf = [0u8; 4];
2102 let encoded = ch.encode_utf8(&mut buf);
2103 writer.write_all(encoded.as_bytes())?;
2104 } else if let Some(gid) = effective_cell.content.grapheme_id() {
2105 if let Some(text) = self.pool.get(gid) {
2107 writer.write_all(text.as_bytes())?;
2108 } else {
2109 for _ in 0..raw_width.max(1) {
2111 writer.write_all(b"?")?;
2112 }
2113 }
2114 } else {
2115 writer.write_all(b" ")?;
2116 }
2117
2118 let advance = if effective_cell.is_empty() || is_zero_width_content {
2119 1
2120 } else {
2121 raw_width.max(1)
2122 };
2123 cursor_x = cursor_x.saturating_add(advance as u16);
2124 }
2125 }
2126
2127 writer.write_all(b"\x1b[0m")?;
2129
2130 if current_link.is_some() {
2132 writer.write_all(b"\x1b]8;;\x1b\\")?;
2133 }
2134
2135 trace!("emit_full_redraw complete");
2136 Ok(EmitStats {
2137 diff_cells,
2138 diff_runs,
2139 })
2140 }
2141
2142 fn emit_style_flags(
2144 writer: &mut impl Write,
2145 flags: ftui_render::cell::StyleFlags,
2146 ) -> io::Result<()> {
2147 use ftui_render::cell::StyleFlags;
2148
2149 let mut codes = Vec::with_capacity(8);
2150
2151 if flags.contains(StyleFlags::BOLD) {
2152 codes.push("1");
2153 }
2154 if flags.contains(StyleFlags::DIM) {
2155 codes.push("2");
2156 }
2157 if flags.contains(StyleFlags::ITALIC) {
2158 codes.push("3");
2159 }
2160 if flags.contains(StyleFlags::UNDERLINE) {
2161 codes.push("4");
2162 }
2163 if flags.contains(StyleFlags::BLINK) {
2164 codes.push("5");
2165 }
2166 if flags.contains(StyleFlags::REVERSE) {
2167 codes.push("7");
2168 }
2169 if flags.contains(StyleFlags::HIDDEN) {
2170 codes.push("8");
2171 }
2172 if flags.contains(StyleFlags::STRIKETHROUGH) {
2173 codes.push("9");
2174 }
2175
2176 if !codes.is_empty() {
2177 write!(writer, "\x1b[{}m", codes.join(";"))?;
2178 }
2179
2180 Ok(())
2181 }
2182
2183 #[allow(dead_code)] fn create_full_diff(&self, buffer: &Buffer) -> BufferDiff {
2186 BufferDiff::full(buffer.width(), buffer.height())
2187 }
2188
2189 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
2200 match self.screen_mode {
2201 ScreenMode::Inline { ui_height } => {
2202 if !self.position_cursor_for_log(ui_height)? {
2203 return Ok(());
2204 }
2205 if !self.scroll_region_active {
2208 self.prev_buffer = None;
2209 self.last_inline_region = None;
2210 self.reset_diff_strategy();
2211 }
2212
2213 self.writer().write_all(text.as_bytes())?;
2214 self.writer().flush()
2215 }
2216 ScreenMode::InlineAuto { .. } => {
2217 let ui_height = self.effective_ui_height();
2219 if !self.position_cursor_for_log(ui_height)? {
2220 return Ok(());
2221 }
2222 if !self.scroll_region_active {
2224 self.prev_buffer = None;
2225 self.last_inline_region = None;
2226 self.reset_diff_strategy();
2227 }
2228
2229 self.writer().write_all(text.as_bytes())?;
2230 self.writer().flush()
2231 }
2232 ScreenMode::AltScreen => {
2233 Ok(())
2236 }
2237 }
2238 }
2239
2240 fn position_cursor_for_log(&mut self, ui_height: u16) -> io::Result<bool> {
2247 let visible_height = ui_height.min(self.term_height);
2248 if visible_height >= self.term_height {
2249 return Ok(false);
2251 }
2252
2253 let log_row = match self.ui_anchor {
2254 UiAnchor::Bottom => {
2255 self.term_height.saturating_sub(visible_height)
2258 }
2259 UiAnchor::Top => {
2260 self.term_height
2263 }
2264 };
2265
2266 write!(self.writer(), "\x1b[{};1H", log_row)?;
2268 Ok(true)
2269 }
2270
2271 pub fn clear_screen(&mut self) -> io::Result<()> {
2273 self.writer().write_all(b"\x1b[2J\x1b[1;1H")?;
2274 self.writer().flush()?;
2275 self.prev_buffer = None;
2276 self.last_inline_region = None;
2277 self.reset_diff_strategy();
2278 Ok(())
2279 }
2280
2281 fn set_cursor_visibility(&mut self, visible: bool) -> io::Result<()> {
2282 if self.cursor_visible == visible {
2283 return Ok(());
2284 }
2285 self.cursor_visible = visible;
2286 if visible {
2287 self.writer().write_all(b"\x1b[?25h")?;
2288 } else {
2289 self.writer().write_all(b"\x1b[?25l")?;
2290 }
2291 Ok(())
2292 }
2293
2294 pub fn hide_cursor(&mut self) -> io::Result<()> {
2296 self.set_cursor_visibility(false)?;
2297 self.writer().flush()
2298 }
2299
2300 pub fn show_cursor(&mut self) -> io::Result<()> {
2302 self.set_cursor_visibility(true)?;
2303 self.writer().flush()
2304 }
2305
2306 pub fn flush(&mut self) -> io::Result<()> {
2308 self.writer().flush()
2309 }
2310
2311 pub fn pool(&self) -> &GraphemePool {
2313 &self.pool
2314 }
2315
2316 pub fn pool_mut(&mut self) -> &mut GraphemePool {
2318 &mut self.pool
2319 }
2320
2321 pub fn links(&self) -> &LinkRegistry {
2323 &self.links
2324 }
2325
2326 pub fn links_mut(&mut self) -> &mut LinkRegistry {
2328 &mut self.links
2329 }
2330
2331 pub fn pool_and_links_mut(&mut self) -> (&mut GraphemePool, &mut LinkRegistry) {
2335 (&mut self.pool, &mut self.links)
2336 }
2337
2338 pub fn capabilities(&self) -> &TerminalCapabilities {
2340 &self.capabilities
2341 }
2342
2343 pub fn into_inner(mut self) -> Option<W> {
2348 self.cleanup();
2349 self.writer.take()?.into_inner().into_inner().ok()
2351 }
2352
2353 pub fn gc(&mut self) {
2359 let buffers = if let Some(ref buf) = self.prev_buffer {
2360 vec![buf]
2361 } else {
2362 vec![]
2363 };
2364 self.pool.gc(&buffers);
2365 }
2366
2367 fn best_effort_inline_cleanup(&mut self) {
2371 let Some(ref mut writer) = self.writer else {
2372 return;
2373 };
2374
2375 if self.capabilities.sync_output {
2378 let _ = writer.write_all(SYNC_END);
2379 }
2380 self.in_sync_block = false;
2381
2382 let _ = writer.write_all(CURSOR_RESTORE);
2383 self.cursor_saved = false;
2384
2385 let _ = writer.write_all(b"\x1b[r");
2386 self.scroll_region_active = false;
2387
2388 let _ = writer.write_all(b"\x1b[0m");
2389 let _ = writer.write_all(b"\x1b[?25h");
2390 self.cursor_visible = true;
2391 let _ = writer.flush();
2392 }
2393
2394 fn cleanup(&mut self) {
2396 let Some(ref mut writer) = self.writer else {
2397 return; };
2399
2400 if self.in_sync_block {
2402 let _ = writer.write_all(SYNC_END);
2403 self.in_sync_block = false;
2404 }
2405
2406 if self.cursor_saved {
2408 let _ = writer.write_all(CURSOR_RESTORE);
2409 self.cursor_saved = false;
2410 }
2411
2412 if self.scroll_region_active {
2414 let _ = writer.write_all(b"\x1b[r");
2415 self.scroll_region_active = false;
2416 }
2417
2418 let _ = writer.write_all(b"\x1b[0m");
2420
2421 let _ = writer.write_all(b"\x1b[?25h");
2423 self.cursor_visible = true;
2424
2425 let _ = writer.flush();
2427
2428 if let Some(ref mut trace) = self.render_trace {
2429 let _ = trace.finish(None);
2430 }
2431 }
2432}
2433
2434impl<W: Write> Drop for TerminalWriter<W> {
2435 fn drop(&mut self) {
2436 if matches!(
2438 self.screen_mode,
2439 ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
2440 ) {
2441 INLINE_ACTIVE_WIDGETS.fetch_sub(1, Ordering::Relaxed);
2442 }
2443 self.cleanup();
2444 }
2445}
2446
2447#[cfg(test)]
2448mod tests {
2449 use super::*;
2450 use ftui_render::cell::{Cell, PackedRgba};
2451 use std::path::PathBuf;
2452 use std::sync::atomic::{AtomicUsize, Ordering};
2453
2454 fn max_cursor_row(output: &[u8]) -> u16 {
2455 let mut max_row = 0u16;
2456 let mut i = 0;
2457 while i + 2 < output.len() {
2458 if output[i] == 0x1b && output[i + 1] == b'[' {
2459 let mut j = i + 2;
2460 let mut row: u16 = 0;
2461 let mut saw_row = false;
2462 while j < output.len() && output[j].is_ascii_digit() {
2463 saw_row = true;
2464 row = row
2465 .saturating_mul(10)
2466 .saturating_add((output[j] - b'0') as u16);
2467 j += 1;
2468 }
2469 if saw_row && j < output.len() && output[j] == b';' {
2470 j += 1;
2471 let mut saw_col = false;
2472 while j < output.len() && output[j].is_ascii_digit() {
2473 saw_col = true;
2474 j += 1;
2475 }
2476 if saw_col && j < output.len() && output[j] == b'H' {
2477 max_row = max_row.max(row);
2478 }
2479 }
2480 }
2481 i += 1;
2482 }
2483 max_row
2484 }
2485
2486 fn basic_caps() -> TerminalCapabilities {
2487 TerminalCapabilities::basic()
2488 }
2489
2490 fn full_caps() -> TerminalCapabilities {
2491 let mut caps = TerminalCapabilities::basic();
2492 caps.true_color = true;
2493 caps.sync_output = true;
2494 caps
2495 }
2496
2497 fn find_nth(haystack: &[u8], needle: &[u8], nth: usize) -> Option<usize> {
2498 if nth == 0 {
2499 return None;
2500 }
2501 let mut count = 0;
2502 let mut i = 0;
2503 while i + needle.len() <= haystack.len() {
2504 if &haystack[i..i + needle.len()] == needle {
2505 count += 1;
2506 if count == nth {
2507 return Some(i);
2508 }
2509 }
2510 i += 1;
2511 }
2512 None
2513 }
2514
2515 fn temp_evidence_path(label: &str) -> PathBuf {
2516 static COUNTER: AtomicUsize = AtomicUsize::new(0);
2517 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2518 let mut path = std::env::temp_dir();
2519 path.push(format!(
2520 "ftui_{}_{}_{}.jsonl",
2521 label,
2522 std::process::id(),
2523 id
2524 ));
2525 path
2526 }
2527
2528 #[test]
2529 fn new_creates_writer() {
2530 let output = Vec::new();
2531 let writer = TerminalWriter::new(
2532 output,
2533 ScreenMode::Inline { ui_height: 10 },
2534 UiAnchor::Bottom,
2535 basic_caps(),
2536 );
2537 assert_eq!(writer.ui_height(), 10);
2538 }
2539
2540 #[test]
2541 fn ui_start_row_bottom_anchor() {
2542 let output = Vec::new();
2543 let mut writer = TerminalWriter::new(
2544 output,
2545 ScreenMode::Inline { ui_height: 10 },
2546 UiAnchor::Bottom,
2547 basic_caps(),
2548 );
2549 writer.set_size(80, 24);
2550 assert_eq!(writer.ui_start_row(), 14); }
2552
2553 #[test]
2554 fn ui_start_row_top_anchor() {
2555 let output = Vec::new();
2556 let mut writer = TerminalWriter::new(
2557 output,
2558 ScreenMode::Inline { ui_height: 10 },
2559 UiAnchor::Top,
2560 basic_caps(),
2561 );
2562 writer.set_size(80, 24);
2563 assert_eq!(writer.ui_start_row(), 0);
2564 }
2565
2566 #[test]
2567 fn ui_start_row_altscreen() {
2568 let output = Vec::new();
2569 let mut writer = TerminalWriter::new(
2570 output,
2571 ScreenMode::AltScreen,
2572 UiAnchor::Bottom,
2573 basic_caps(),
2574 );
2575 writer.set_size(80, 24);
2576 assert_eq!(writer.ui_start_row(), 0);
2577 }
2578
2579 #[test]
2580 fn present_ui_inline_saves_restores_cursor() {
2581 let mut output = Vec::new();
2582 {
2583 let mut writer = TerminalWriter::new(
2584 &mut output,
2585 ScreenMode::Inline { ui_height: 5 },
2586 UiAnchor::Bottom,
2587 basic_caps(),
2588 );
2589 writer.set_size(10, 10);
2590
2591 let buffer = Buffer::new(10, 5);
2592 writer.present_ui(&buffer, None, true).unwrap();
2593 }
2594
2595 assert!(output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE));
2597 assert!(
2598 output
2599 .windows(CURSOR_RESTORE.len())
2600 .any(|w| w == CURSOR_RESTORE)
2601 );
2602 }
2603
2604 #[test]
2605 fn present_ui_with_sync_output() {
2606 let mut output = Vec::new();
2607 {
2608 let mut writer = TerminalWriter::new(
2609 &mut output,
2610 ScreenMode::Inline { ui_height: 5 },
2611 UiAnchor::Bottom,
2612 full_caps(),
2613 );
2614 writer.set_size(10, 10);
2615
2616 let buffer = Buffer::new(10, 5);
2617 writer.present_ui(&buffer, None, true).unwrap();
2618 }
2619
2620 assert!(output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN));
2622 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2623 }
2624
2625 #[test]
2626 fn present_ui_hides_cursor_when_requested() {
2627 let mut output = Vec::new();
2628 {
2629 let mut writer = TerminalWriter::new(
2630 &mut output,
2631 ScreenMode::AltScreen,
2632 UiAnchor::Bottom,
2633 basic_caps(),
2634 );
2635 writer.set_size(10, 5);
2636
2637 let buffer = Buffer::new(10, 5);
2638 writer.present_ui(&buffer, None, false).unwrap();
2639 }
2640
2641 assert!(
2642 output.windows(6).any(|w| w == b"\x1b[?25l"),
2643 "expected cursor hide sequence"
2644 );
2645 }
2646
2647 #[test]
2648 fn present_ui_visible_does_not_hide_cursor() {
2649 let mut output = Vec::new();
2650 {
2651 let mut writer = TerminalWriter::new(
2652 &mut output,
2653 ScreenMode::AltScreen,
2654 UiAnchor::Bottom,
2655 basic_caps(),
2656 );
2657 writer.set_size(10, 5);
2658
2659 let buffer = Buffer::new(10, 5);
2660 writer.present_ui(&buffer, None, true).unwrap();
2661 }
2662
2663 assert!(
2664 !output.windows(6).any(|w| w == b"\x1b[?25l"),
2665 "did not expect cursor hide sequence"
2666 );
2667 }
2668
2669 #[test]
2670 fn write_log_in_inline_mode() {
2671 let mut output = Vec::new();
2672 {
2673 let mut writer = TerminalWriter::new(
2674 &mut output,
2675 ScreenMode::Inline { ui_height: 5 },
2676 UiAnchor::Bottom,
2677 basic_caps(),
2678 );
2679 writer.write_log("test log\n").unwrap();
2680 }
2681
2682 let output_str = String::from_utf8_lossy(&output);
2683 assert!(output_str.contains("test log"));
2684 }
2685
2686 #[test]
2687 fn write_log_in_altscreen_is_noop() {
2688 let mut output = Vec::new();
2689 {
2690 let mut writer = TerminalWriter::new(
2691 &mut output,
2692 ScreenMode::AltScreen,
2693 UiAnchor::Bottom,
2694 basic_caps(),
2695 );
2696 writer.write_log("test log\n").unwrap();
2697 }
2698
2699 let output_str = String::from_utf8_lossy(&output);
2700 assert!(!output_str.contains("test log"));
2702 }
2703
2704 #[test]
2705 fn clear_screen_resets_prev_buffer() {
2706 let mut output = Vec::new();
2707 let mut writer = TerminalWriter::new(
2708 &mut output,
2709 ScreenMode::AltScreen,
2710 UiAnchor::Bottom,
2711 basic_caps(),
2712 );
2713
2714 let buffer = Buffer::new(10, 5);
2716 writer.present_ui(&buffer, None, true).unwrap();
2717 assert!(writer.prev_buffer.is_some());
2718
2719 writer.clear_screen().unwrap();
2721 assert!(writer.prev_buffer.is_none());
2722 }
2723
2724 #[test]
2725 fn set_size_clears_prev_buffer() {
2726 let output = Vec::new();
2727 let mut writer = TerminalWriter::new(
2728 output,
2729 ScreenMode::AltScreen,
2730 UiAnchor::Bottom,
2731 basic_caps(),
2732 );
2733
2734 writer.prev_buffer = Some(Buffer::new(10, 10));
2735 writer.set_size(20, 20);
2736
2737 assert!(writer.prev_buffer.is_none());
2738 }
2739
2740 #[test]
2741 fn inline_auto_resize_clears_cached_height() {
2742 let output = Vec::new();
2743 let mut writer = TerminalWriter::new(
2744 output,
2745 ScreenMode::InlineAuto {
2746 min_height: 3,
2747 max_height: 8,
2748 },
2749 UiAnchor::Bottom,
2750 basic_caps(),
2751 );
2752
2753 writer.set_size(80, 24);
2754 writer.set_auto_ui_height(6);
2755 assert_eq!(writer.auto_ui_height(), Some(6));
2756 assert_eq!(writer.render_height_hint(), 6);
2757
2758 writer.set_size(100, 30);
2759 assert_eq!(writer.auto_ui_height(), None);
2760 assert_eq!(writer.render_height_hint(), 8);
2761 }
2762
2763 #[test]
2764 fn drop_cleanup_restores_cursor() {
2765 let mut output = Vec::new();
2766 {
2767 let mut writer = TerminalWriter::new(
2768 &mut output,
2769 ScreenMode::Inline { ui_height: 5 },
2770 UiAnchor::Bottom,
2771 basic_caps(),
2772 );
2773 writer.cursor_saved = true;
2774 }
2776
2777 assert!(
2779 output
2780 .windows(CURSOR_RESTORE.len())
2781 .any(|w| w == CURSOR_RESTORE)
2782 );
2783 }
2784
2785 #[test]
2786 fn drop_cleanup_ends_sync_block() {
2787 let mut output = Vec::new();
2788 {
2789 let mut writer = TerminalWriter::new(
2790 &mut output,
2791 ScreenMode::Inline { ui_height: 5 },
2792 UiAnchor::Bottom,
2793 full_caps(),
2794 );
2795 writer.in_sync_block = true;
2796 }
2798
2799 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2801 }
2802
2803 #[test]
2804 fn present_multiple_frames_uses_diff() {
2805 use std::io::Cursor;
2806
2807 let output = Cursor::new(Vec::new());
2809 let mut writer = TerminalWriter::new(
2810 output,
2811 ScreenMode::AltScreen,
2812 UiAnchor::Bottom,
2813 basic_caps(),
2814 );
2815 writer.set_size(10, 5);
2816
2817 let mut buffer1 = Buffer::new(10, 5);
2819 buffer1.set_raw(0, 0, Cell::from_char('A'));
2820 writer.present_ui(&buffer1, None, true).unwrap();
2821
2822 writer.present_ui(&buffer1, None, true).unwrap();
2824
2825 let mut buffer2 = buffer1.clone();
2827 buffer2.set_raw(1, 0, Cell::from_char('B'));
2828 writer.present_ui(&buffer2, None, true).unwrap();
2829
2830 }
2833
2834 #[test]
2835 fn cell_content_rendered_correctly() {
2836 let mut output = Vec::new();
2837 {
2838 let mut writer = TerminalWriter::new(
2839 &mut output,
2840 ScreenMode::AltScreen,
2841 UiAnchor::Bottom,
2842 basic_caps(),
2843 );
2844 writer.set_size(10, 5);
2845
2846 let mut buffer = Buffer::new(10, 5);
2847 buffer.set_raw(0, 0, Cell::from_char('H'));
2848 buffer.set_raw(1, 0, Cell::from_char('i'));
2849 buffer.set_raw(2, 0, Cell::from_char('!'));
2850 writer.present_ui(&buffer, None, true).unwrap();
2851 }
2852
2853 let output_str = String::from_utf8_lossy(&output);
2854 assert!(output_str.contains('H'));
2855 assert!(output_str.contains('i'));
2856 assert!(output_str.contains('!'));
2857 }
2858
2859 #[test]
2860 fn resize_reanchors_ui_region() {
2861 let output = Vec::new();
2862 let mut writer = TerminalWriter::new(
2863 output,
2864 ScreenMode::Inline { ui_height: 10 },
2865 UiAnchor::Bottom,
2866 basic_caps(),
2867 );
2868
2869 writer.set_size(80, 24);
2871 assert_eq!(writer.ui_start_row(), 14);
2872
2873 writer.set_size(80, 40);
2875 assert_eq!(writer.ui_start_row(), 30);
2876
2877 writer.set_size(80, 15);
2879 assert_eq!(writer.ui_start_row(), 5);
2880 }
2881
2882 #[test]
2883 fn inline_auto_height_clamps_and_uses_max_for_render() {
2884 let output = Vec::new();
2885 let mut writer = TerminalWriter::new(
2886 output,
2887 ScreenMode::InlineAuto {
2888 min_height: 3,
2889 max_height: 8,
2890 },
2891 UiAnchor::Bottom,
2892 basic_caps(),
2893 );
2894 writer.set_size(80, 24);
2895
2896 assert_eq!(writer.ui_height(), 3);
2898 assert_eq!(writer.auto_ui_height(), None);
2899
2900 assert_eq!(writer.render_height_hint(), 8);
2902
2903 writer.set_auto_ui_height(6);
2905 assert_eq!(writer.render_height_hint(), 6);
2906
2907 writer.clear_auto_ui_height();
2909 assert_eq!(writer.render_height_hint(), 8);
2910
2911 writer.set_auto_ui_height(3);
2913 assert_eq!(writer.auto_ui_height(), Some(3));
2914 assert_eq!(writer.ui_height(), 3);
2915
2916 writer.clear_auto_ui_height();
2917 assert_eq!(writer.render_height_hint(), 8);
2918
2919 writer.set_auto_ui_height(10);
2921 assert_eq!(writer.ui_height(), 8);
2922
2923 writer.set_auto_ui_height(1);
2925 assert_eq!(writer.ui_height(), 3);
2926 }
2927
2928 #[test]
2929 fn resize_with_top_anchor_stays_at_zero() {
2930 let output = Vec::new();
2931 let mut writer = TerminalWriter::new(
2932 output,
2933 ScreenMode::Inline { ui_height: 10 },
2934 UiAnchor::Top,
2935 basic_caps(),
2936 );
2937
2938 writer.set_size(80, 24);
2939 assert_eq!(writer.ui_start_row(), 0);
2940
2941 writer.set_size(80, 40);
2942 assert_eq!(writer.ui_start_row(), 0);
2943 }
2944
2945 #[test]
2946 fn inline_mode_never_clears_full_screen() {
2947 let mut output = Vec::new();
2948 {
2949 let mut writer = TerminalWriter::new(
2950 &mut output,
2951 ScreenMode::Inline { ui_height: 5 },
2952 UiAnchor::Bottom,
2953 basic_caps(),
2954 );
2955 writer.set_size(10, 10);
2956
2957 let buffer = Buffer::new(10, 5);
2958 writer.present_ui(&buffer, None, true).unwrap();
2959 }
2960
2961 let has_ed2 = output.windows(4).any(|w| w == b"\x1b[2J");
2963 assert!(!has_ed2, "Inline mode should never use full screen clear");
2964
2965 assert!(output.windows(ERASE_LINE.len()).any(|w| w == ERASE_LINE));
2967 }
2968
2969 #[test]
2970 fn present_after_log_maintains_cursor_position() {
2971 let mut output = Vec::new();
2972 {
2973 let mut writer = TerminalWriter::new(
2974 &mut output,
2975 ScreenMode::Inline { ui_height: 5 },
2976 UiAnchor::Bottom,
2977 basic_caps(),
2978 );
2979 writer.set_size(10, 10);
2980
2981 let buffer = Buffer::new(10, 5);
2983 writer.present_ui(&buffer, None, true).unwrap();
2984
2985 writer.write_log("log line\n").unwrap();
2987
2988 writer.present_ui(&buffer, None, true).unwrap();
2990 }
2991
2992 let save_count = output
2994 .windows(CURSOR_SAVE.len())
2995 .filter(|w| *w == CURSOR_SAVE)
2996 .count();
2997 assert_eq!(save_count, 2, "Should have saved cursor twice");
2998
2999 let restore_count = output
3001 .windows(CURSOR_RESTORE.len())
3002 .filter(|w| *w == CURSOR_RESTORE)
3003 .count();
3004 assert!(
3006 restore_count >= 2,
3007 "Should have restored cursor at least twice"
3008 );
3009 }
3010
3011 #[test]
3012 fn ui_height_bounds_check() {
3013 let output = Vec::new();
3014 let mut writer = TerminalWriter::new(
3015 output,
3016 ScreenMode::Inline { ui_height: 100 },
3017 UiAnchor::Bottom,
3018 basic_caps(),
3019 );
3020
3021 writer.set_size(80, 10);
3023
3024 assert_eq!(writer.ui_start_row(), 0);
3026 }
3027
3028 #[test]
3029 fn inline_ui_height_clamped_to_terminal_height() {
3030 let mut output = Vec::new();
3031 {
3032 let mut writer = TerminalWriter::new(
3033 &mut output,
3034 ScreenMode::Inline { ui_height: 10 },
3035 UiAnchor::Bottom,
3036 basic_caps(),
3037 );
3038 writer.set_size(8, 3);
3039 let buffer = Buffer::new(8, 10);
3040 writer.present_ui(&buffer, None, true).unwrap();
3041 }
3042
3043 let max_row = max_cursor_row(&output);
3044 assert!(
3045 max_row <= 3,
3046 "cursor row {} exceeds terminal height",
3047 max_row
3048 );
3049 }
3050
3051 #[test]
3052 fn inline_shrink_clears_stale_rows() {
3053 let mut output = Vec::new();
3054 {
3055 let mut writer = TerminalWriter::new(
3056 &mut output,
3057 ScreenMode::InlineAuto {
3058 min_height: 1,
3059 max_height: 6,
3060 },
3061 UiAnchor::Bottom,
3062 basic_caps(),
3063 );
3064 writer.set_size(10, 10);
3065
3066 let buffer = Buffer::new(10, 6);
3067 writer.set_auto_ui_height(6);
3068 writer.present_ui(&buffer, None, true).unwrap();
3069
3070 writer.set_auto_ui_height(3);
3071 writer.present_ui(&buffer, None, true).unwrap();
3072 }
3073
3074 let second_save = find_nth(&output, CURSOR_SAVE, 2).expect("expected second cursor save");
3075 let after_save = &output[second_save..];
3076 let restore_idx = after_save
3077 .windows(CURSOR_RESTORE.len())
3078 .position(|w| w == CURSOR_RESTORE)
3079 .expect("expected cursor restore after second save");
3080 let segment = &after_save[..restore_idx];
3081 let erase_count = segment
3082 .windows(ERASE_LINE.len())
3083 .filter(|w| *w == ERASE_LINE)
3084 .count();
3085
3086 assert_eq!(erase_count, 6, "expected clears for stale + new rows");
3087 }
3088
3089 fn scroll_region_caps() -> TerminalCapabilities {
3093 let mut caps = TerminalCapabilities::basic();
3094 caps.scroll_region = true;
3095 caps.sync_output = true;
3096 caps
3097 }
3098
3099 fn hybrid_caps() -> TerminalCapabilities {
3101 let mut caps = TerminalCapabilities::basic();
3102 caps.scroll_region = true;
3103 caps
3104 }
3105
3106 fn mux_caps() -> TerminalCapabilities {
3108 let mut caps = TerminalCapabilities::basic();
3109 caps.scroll_region = true;
3110 caps.sync_output = true;
3111 caps.in_tmux = true;
3112 caps
3113 }
3114
3115 #[test]
3116 fn scroll_region_bounds_bottom_anchor() {
3117 let mut output = Vec::new();
3118 {
3119 let mut writer = TerminalWriter::new(
3120 &mut output,
3121 ScreenMode::Inline { ui_height: 5 },
3122 UiAnchor::Bottom,
3123 scroll_region_caps(),
3124 );
3125 writer.set_size(10, 10);
3126 let buffer = Buffer::new(10, 5);
3127 writer.present_ui(&buffer, None, true).unwrap();
3128 }
3129
3130 let seq = b"\x1b[1;5r";
3131 assert!(
3132 output.windows(seq.len()).any(|w| w == seq),
3133 "expected scroll region for bottom anchor"
3134 );
3135 }
3136
3137 #[test]
3138 fn scroll_region_bounds_top_anchor() {
3139 let mut output = Vec::new();
3140 {
3141 let mut writer = TerminalWriter::new(
3142 &mut output,
3143 ScreenMode::Inline { ui_height: 5 },
3144 UiAnchor::Top,
3145 scroll_region_caps(),
3146 );
3147 writer.set_size(10, 10);
3148 let buffer = Buffer::new(10, 5);
3149 writer.present_ui(&buffer, None, true).unwrap();
3150 }
3151
3152 let seq = b"\x1b[6;10r";
3153 assert!(
3154 output.windows(seq.len()).any(|w| w == seq),
3155 "expected scroll region for top anchor"
3156 );
3157 let cursor_seq = b"\x1b[6;1H";
3158 assert!(
3159 output.windows(cursor_seq.len()).any(|w| w == cursor_seq),
3160 "expected cursor move into log region for top anchor"
3161 );
3162 }
3163
3164 #[test]
3165 fn present_ui_inline_resets_style_before_cursor_restore() {
3166 let mut output = Vec::new();
3167 {
3168 let mut writer = TerminalWriter::new(
3169 &mut output,
3170 ScreenMode::Inline { ui_height: 2 },
3171 UiAnchor::Bottom,
3172 basic_caps(),
3173 );
3174 writer.set_size(5, 5);
3175 let mut buffer = Buffer::new(5, 2);
3176 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(PackedRgba::RED));
3177 writer.present_ui(&buffer, None, true).unwrap();
3178 }
3179
3180 let seq = b"\x1b[0m\x1b8";
3181 assert!(
3182 output.windows(seq.len()).any(|w| w == seq),
3183 "expected SGR reset before cursor restore in inline mode"
3184 );
3185 }
3186
3187 #[test]
3188 fn strategy_selected_from_capabilities() {
3189 let w = TerminalWriter::new(
3191 Vec::new(),
3192 ScreenMode::Inline { ui_height: 5 },
3193 UiAnchor::Bottom,
3194 basic_caps(),
3195 );
3196 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3197
3198 let w = TerminalWriter::new(
3200 Vec::new(),
3201 ScreenMode::Inline { ui_height: 5 },
3202 UiAnchor::Bottom,
3203 scroll_region_caps(),
3204 );
3205 assert_eq!(w.inline_strategy(), InlineStrategy::ScrollRegion);
3206
3207 let w = TerminalWriter::new(
3209 Vec::new(),
3210 ScreenMode::Inline { ui_height: 5 },
3211 UiAnchor::Bottom,
3212 hybrid_caps(),
3213 );
3214 assert_eq!(w.inline_strategy(), InlineStrategy::Hybrid);
3215
3216 let w = TerminalWriter::new(
3218 Vec::new(),
3219 ScreenMode::Inline { ui_height: 5 },
3220 UiAnchor::Bottom,
3221 mux_caps(),
3222 );
3223 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3224 }
3225
3226 #[test]
3227 fn scroll_region_activated_on_present() {
3228 let mut output = Vec::new();
3229 {
3230 let mut writer = TerminalWriter::new(
3231 &mut output,
3232 ScreenMode::Inline { ui_height: 5 },
3233 UiAnchor::Bottom,
3234 scroll_region_caps(),
3235 );
3236 writer.set_size(80, 24);
3237 assert!(!writer.scroll_region_active());
3238
3239 let buffer = Buffer::new(80, 5);
3240 writer.present_ui(&buffer, None, true).unwrap();
3241 assert!(writer.scroll_region_active());
3242 }
3243
3244 let expected = b"\x1b[1;19r";
3246 assert!(
3247 output.windows(expected.len()).any(|w| w == expected),
3248 "Should set scroll region to rows 1-19"
3249 );
3250 }
3251
3252 #[test]
3253 fn scroll_region_not_activated_for_overlay() {
3254 let mut output = Vec::new();
3255 {
3256 let mut writer = TerminalWriter::new(
3257 &mut output,
3258 ScreenMode::Inline { ui_height: 5 },
3259 UiAnchor::Bottom,
3260 basic_caps(),
3261 );
3262 writer.set_size(80, 24);
3263
3264 let buffer = Buffer::new(80, 5);
3265 writer.present_ui(&buffer, None, true).unwrap();
3266 assert!(!writer.scroll_region_active());
3267 }
3268
3269 let decstbm = b"\x1b[1;19r";
3271 assert!(
3272 !output.windows(decstbm.len()).any(|w| w == decstbm),
3273 "OverlayRedraw should not set scroll region"
3274 );
3275 }
3276
3277 #[test]
3278 fn scroll_region_not_activated_in_mux() {
3279 let mut output = Vec::new();
3280 {
3281 let mut writer = TerminalWriter::new(
3282 &mut output,
3283 ScreenMode::Inline { ui_height: 5 },
3284 UiAnchor::Bottom,
3285 mux_caps(),
3286 );
3287 writer.set_size(80, 24);
3288
3289 let buffer = Buffer::new(80, 5);
3290 writer.present_ui(&buffer, None, true).unwrap();
3291 assert!(!writer.scroll_region_active());
3292 }
3293
3294 let decstbm = b"\x1b[1;19r";
3296 assert!(
3297 !output.windows(decstbm.len()).any(|w| w == decstbm),
3298 "Mux environment should not use scroll region"
3299 );
3300 }
3301
3302 #[test]
3303 fn scroll_region_reset_on_cleanup() {
3304 let mut output = Vec::new();
3305 {
3306 let mut writer = TerminalWriter::new(
3307 &mut output,
3308 ScreenMode::Inline { ui_height: 5 },
3309 UiAnchor::Bottom,
3310 scroll_region_caps(),
3311 );
3312 writer.set_size(80, 24);
3313
3314 let buffer = Buffer::new(80, 5);
3315 writer.present_ui(&buffer, None, true).unwrap();
3316 }
3318
3319 let reset = b"\x1b[r";
3321 assert!(
3322 output.windows(reset.len()).any(|w| w == reset),
3323 "Cleanup should reset scroll region"
3324 );
3325 }
3326
3327 #[test]
3328 fn scroll_region_reset_on_resize() {
3329 let output = Vec::new();
3330 let mut writer = TerminalWriter::new(
3331 output,
3332 ScreenMode::Inline { ui_height: 5 },
3333 UiAnchor::Bottom,
3334 scroll_region_caps(),
3335 );
3336 writer.set_size(80, 24);
3337
3338 writer.activate_scroll_region(5).unwrap();
3340 assert!(writer.scroll_region_active());
3341
3342 writer.set_size(80, 40);
3344 assert!(!writer.scroll_region_active());
3345 }
3346
3347 #[test]
3348 fn scroll_region_reactivated_after_resize() {
3349 let mut output = Vec::new();
3350 {
3351 let mut writer = TerminalWriter::new(
3352 &mut output,
3353 ScreenMode::Inline { ui_height: 5 },
3354 UiAnchor::Bottom,
3355 scroll_region_caps(),
3356 );
3357 writer.set_size(80, 24);
3358
3359 let buffer = Buffer::new(80, 5);
3361 writer.present_ui(&buffer, None, true).unwrap();
3362 assert!(writer.scroll_region_active());
3363
3364 writer.set_size(80, 40);
3366 assert!(!writer.scroll_region_active());
3367
3368 let buffer2 = Buffer::new(80, 5);
3370 writer.present_ui(&buffer2, None, true).unwrap();
3371 assert!(writer.scroll_region_active());
3372 }
3373
3374 let new_region = b"\x1b[1;35r";
3376 assert!(
3377 output.windows(new_region.len()).any(|w| w == new_region),
3378 "Should set scroll region to new dimensions after resize"
3379 );
3380 }
3381
3382 #[test]
3383 fn hybrid_strategy_activates_scroll_region() {
3384 let mut output = Vec::new();
3385 {
3386 let mut writer = TerminalWriter::new(
3387 &mut output,
3388 ScreenMode::Inline { ui_height: 5 },
3389 UiAnchor::Bottom,
3390 hybrid_caps(),
3391 );
3392 writer.set_size(80, 24);
3393
3394 let buffer = Buffer::new(80, 5);
3395 writer.present_ui(&buffer, None, true).unwrap();
3396 assert!(writer.scroll_region_active());
3397 }
3398
3399 let expected = b"\x1b[1;19r";
3401 assert!(
3402 output.windows(expected.len()).any(|w| w == expected),
3403 "Hybrid should activate scroll region as optimization"
3404 );
3405 }
3406
3407 #[test]
3408 fn altscreen_does_not_activate_scroll_region() {
3409 let output = Vec::new();
3410 let mut writer = TerminalWriter::new(
3411 output,
3412 ScreenMode::AltScreen,
3413 UiAnchor::Bottom,
3414 scroll_region_caps(),
3415 );
3416 writer.set_size(80, 24);
3417
3418 let buffer = Buffer::new(80, 24);
3419 writer.present_ui(&buffer, None, true).unwrap();
3420 assert!(!writer.scroll_region_active());
3421 }
3422
3423 #[test]
3424 fn scroll_region_still_saves_restores_cursor() {
3425 let mut output = Vec::new();
3426 {
3427 let mut writer = TerminalWriter::new(
3428 &mut output,
3429 ScreenMode::Inline { ui_height: 5 },
3430 UiAnchor::Bottom,
3431 scroll_region_caps(),
3432 );
3433 writer.set_size(80, 24);
3434
3435 let buffer = Buffer::new(80, 5);
3436 writer.present_ui(&buffer, None, true).unwrap();
3437 }
3438
3439 assert!(
3441 output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE),
3442 "Scroll region mode should still save cursor"
3443 );
3444 assert!(
3445 output
3446 .windows(CURSOR_RESTORE.len())
3447 .any(|w| w == CURSOR_RESTORE),
3448 "Scroll region mode should still restore cursor"
3449 );
3450 }
3451
3452 #[test]
3455 fn write_log_positions_cursor_bottom_anchor() {
3456 let mut output = Vec::new();
3459 {
3460 let mut writer = TerminalWriter::new(
3461 &mut output,
3462 ScreenMode::Inline { ui_height: 5 },
3463 UiAnchor::Bottom,
3464 basic_caps(),
3465 );
3466 writer.set_size(80, 24);
3467 writer.write_log("test log\n").unwrap();
3468 }
3469
3470 let expected_pos = b"\x1b[19;1H";
3474 assert!(
3475 output
3476 .windows(expected_pos.len())
3477 .any(|w| w == expected_pos),
3478 "Log write should position cursor at row 19 for bottom anchor"
3479 );
3480 }
3481
3482 #[test]
3483 fn write_log_positions_cursor_top_anchor() {
3484 let mut output = Vec::new();
3487 {
3488 let mut writer = TerminalWriter::new(
3489 &mut output,
3490 ScreenMode::Inline { ui_height: 5 },
3491 UiAnchor::Top,
3492 basic_caps(),
3493 );
3494 writer.set_size(80, 24);
3495 writer.write_log("test log\n").unwrap();
3496 }
3497
3498 let expected_pos = b"\x1b[24;1H";
3502 assert!(
3503 output
3504 .windows(expected_pos.len())
3505 .any(|w| w == expected_pos),
3506 "Log write should position cursor at row 24 for top anchor"
3507 );
3508 }
3509
3510 #[test]
3511 fn write_log_contains_text() {
3512 let mut output = Vec::new();
3514 {
3515 let mut writer = TerminalWriter::new(
3516 &mut output,
3517 ScreenMode::Inline { ui_height: 5 },
3518 UiAnchor::Bottom,
3519 basic_caps(),
3520 );
3521 writer.set_size(80, 24);
3522 writer.write_log("hello world\n").unwrap();
3523 }
3524
3525 let output_str = String::from_utf8_lossy(&output);
3526 assert!(output_str.contains("hello world"));
3527 }
3528
3529 #[test]
3530 fn write_log_multiple_writes_position_each_time() {
3531 let mut output = Vec::new();
3533 {
3534 let mut writer = TerminalWriter::new(
3535 &mut output,
3536 ScreenMode::Inline { ui_height: 5 },
3537 UiAnchor::Bottom,
3538 basic_caps(),
3539 );
3540 writer.set_size(80, 24);
3541 writer.write_log("first\n").unwrap();
3542 writer.write_log("second\n").unwrap();
3543 }
3544
3545 let expected_pos = b"\x1b[19;1H";
3547 let count = output
3548 .windows(expected_pos.len())
3549 .filter(|w| *w == expected_pos)
3550 .count();
3551 assert_eq!(count, 2, "Should position cursor for each log write");
3552 }
3553
3554 #[test]
3555 fn write_log_after_present_ui_works_correctly() {
3556 let mut output = Vec::new();
3558 {
3559 let mut writer = TerminalWriter::new(
3560 &mut output,
3561 ScreenMode::Inline { ui_height: 5 },
3562 UiAnchor::Bottom,
3563 basic_caps(),
3564 );
3565 writer.set_size(80, 24);
3566
3567 let buffer = Buffer::new(80, 5);
3569 writer.present_ui(&buffer, None, true).unwrap();
3570
3571 writer.write_log("after UI\n").unwrap();
3573 }
3574
3575 let output_str = String::from_utf8_lossy(&output);
3576 assert!(output_str.contains("after UI"));
3577
3578 let expected_pos = b"\x1b[19;1H";
3580 assert!(
3582 output
3583 .windows(expected_pos.len())
3584 .any(|w| w == expected_pos),
3585 "Log write after present_ui should position cursor"
3586 );
3587 }
3588
3589 #[test]
3590 fn write_log_ui_fills_terminal_is_noop() {
3591 let mut output = Vec::new();
3595 {
3596 let mut writer = TerminalWriter::new(
3597 &mut output,
3598 ScreenMode::Inline { ui_height: 24 },
3599 UiAnchor::Bottom,
3600 basic_caps(),
3601 );
3602 writer.set_size(80, 24);
3603 writer.write_log("should still write\n").unwrap();
3604 }
3605 assert!(
3607 !output
3608 .windows(b"should still write".len())
3609 .any(|w| w == b"should still write"),
3610 "write_log should not emit log text when UI fills the terminal"
3611 );
3612 }
3613
3614 #[test]
3615 fn write_log_with_scroll_region_active() {
3616 let mut output = Vec::new();
3618 {
3619 let mut writer = TerminalWriter::new(
3620 &mut output,
3621 ScreenMode::Inline { ui_height: 5 },
3622 UiAnchor::Bottom,
3623 scroll_region_caps(),
3624 );
3625 writer.set_size(80, 24);
3626
3627 let buffer = Buffer::new(80, 5);
3629 writer.present_ui(&buffer, None, true).unwrap();
3630 assert!(writer.scroll_region_active());
3631
3632 writer.write_log("with scroll region\n").unwrap();
3634 }
3635
3636 let output_str = String::from_utf8_lossy(&output);
3637 assert!(output_str.contains("with scroll region"));
3638 }
3639
3640 #[test]
3641 fn log_write_cursor_position_not_in_ui_region_bottom_anchor() {
3642 let mut output = Vec::new();
3648 {
3649 let mut writer = TerminalWriter::new(
3650 &mut output,
3651 ScreenMode::Inline { ui_height: 5 },
3652 UiAnchor::Bottom,
3653 basic_caps(),
3654 );
3655 writer.set_size(80, 24);
3656 writer.write_log("test\n").unwrap();
3657 }
3658
3659 let mut found_row = None;
3662 let mut i = 0;
3663 while i + 2 < output.len() {
3664 if output[i] == 0x1b && output[i + 1] == b'[' {
3665 let mut j = i + 2;
3666 let mut row: u16 = 0;
3667 while j < output.len() && output[j].is_ascii_digit() {
3668 row = row * 10 + (output[j] - b'0') as u16;
3669 j += 1;
3670 }
3671 if j < output.len() && output[j] == b';' {
3672 j += 1;
3673 while j < output.len() && output[j].is_ascii_digit() {
3674 j += 1;
3675 }
3676 if j < output.len() && output[j] == b'H' {
3677 found_row = Some(row);
3678 }
3679 }
3680 }
3681 i += 1;
3682 }
3683
3684 if let Some(row) = found_row {
3685 assert!(
3687 row < 20,
3688 "Log cursor row {} should be below UI start row 20",
3689 row
3690 );
3691 }
3692 }
3693
3694 #[test]
3695 fn log_write_cursor_position_not_in_ui_region_top_anchor() {
3696 let mut output = Vec::new();
3702 {
3703 let mut writer = TerminalWriter::new(
3704 &mut output,
3705 ScreenMode::Inline { ui_height: 5 },
3706 UiAnchor::Top,
3707 basic_caps(),
3708 );
3709 writer.set_size(80, 24);
3710 writer.write_log("test\n").unwrap();
3711 }
3712
3713 let mut found_row = None;
3715 let mut i = 0;
3716 while i + 2 < output.len() {
3717 if output[i] == 0x1b && output[i + 1] == b'[' {
3718 let mut j = i + 2;
3719 let mut row: u16 = 0;
3720 while j < output.len() && output[j].is_ascii_digit() {
3721 row = row * 10 + (output[j] - b'0') as u16;
3722 j += 1;
3723 }
3724 if j < output.len() && output[j] == b';' {
3725 j += 1;
3726 while j < output.len() && output[j].is_ascii_digit() {
3727 j += 1;
3728 }
3729 if j < output.len() && output[j] == b'H' {
3730 found_row = Some(row);
3731 }
3732 }
3733 }
3734 i += 1;
3735 }
3736
3737 if let Some(row) = found_row {
3738 assert!(
3740 row > 5,
3741 "Log cursor row {} should be above UI end row 5",
3742 row
3743 );
3744 }
3745 }
3746
3747 #[test]
3748 fn present_ui_positions_cursor_after_restore() {
3749 let mut output = Vec::new();
3750 {
3751 let mut writer = TerminalWriter::new(
3752 &mut output,
3753 ScreenMode::Inline { ui_height: 5 },
3754 UiAnchor::Bottom,
3755 basic_caps(),
3756 );
3757 writer.set_size(80, 24);
3758
3759 let buffer = Buffer::new(80, 5);
3760 writer.present_ui(&buffer, Some((2, 1)), true).unwrap();
3762 }
3763
3764 let expected_pos = b"\x1b[21;3H";
3768
3769 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
3771 let after_restore = &output[restore_idx..];
3772
3773 assert!(
3775 after_restore
3776 .windows(expected_pos.len())
3777 .any(|w| w == expected_pos),
3778 "Cursor positioning should happen after restore"
3779 );
3780 }
3781
3782 #[test]
3787 fn runtime_diff_config_default() {
3788 let config = RuntimeDiffConfig::default();
3789 assert!(config.bayesian_enabled);
3790 assert!(config.dirty_rows_enabled);
3791 assert!(config.dirty_span_config.enabled);
3792 assert!(config.tile_diff_config.enabled);
3793 assert!(config.reset_on_resize);
3794 assert!(config.reset_on_invalidation);
3795 }
3796
3797 #[test]
3798 fn runtime_diff_config_builder() {
3799 let custom_span = DirtySpanConfig::default().with_max_spans_per_row(8);
3800 let tile_config = TileDiffConfig::default()
3801 .with_enabled(false)
3802 .with_tile_size(24, 12)
3803 .with_dense_tile_ratio(0.75)
3804 .with_max_tiles(2048);
3805 let config = RuntimeDiffConfig::new()
3806 .with_bayesian_enabled(false)
3807 .with_dirty_rows_enabled(false)
3808 .with_dirty_span_config(custom_span)
3809 .with_dirty_spans_enabled(false)
3810 .with_tile_diff_config(tile_config)
3811 .with_reset_on_resize(false)
3812 .with_reset_on_invalidation(false);
3813
3814 assert!(!config.bayesian_enabled);
3815 assert!(!config.dirty_rows_enabled);
3816 assert!(!config.dirty_span_config.enabled);
3817 assert_eq!(config.dirty_span_config.max_spans_per_row, 8);
3818 assert!(!config.tile_diff_config.enabled);
3819 assert_eq!(config.tile_diff_config.tile_w, 24);
3820 assert_eq!(config.tile_diff_config.tile_h, 12);
3821 assert_eq!(config.tile_diff_config.max_tiles, 2048);
3822 assert!(!config.reset_on_resize);
3823 assert!(!config.reset_on_invalidation);
3824 }
3825
3826 #[test]
3827 fn with_diff_config_applies_strategy_config() {
3828 use ftui_render::diff_strategy::DiffStrategyConfig;
3829
3830 let strategy_config = DiffStrategyConfig {
3831 prior_alpha: 5.0,
3832 prior_beta: 5.0,
3833 ..Default::default()
3834 };
3835
3836 let runtime_config =
3837 RuntimeDiffConfig::default().with_strategy_config(strategy_config.clone());
3838
3839 let writer = TerminalWriter::with_diff_config(
3840 Vec::<u8>::new(),
3841 ScreenMode::AltScreen,
3842 UiAnchor::Bottom,
3843 basic_caps(),
3844 runtime_config,
3845 );
3846
3847 let (alpha, beta) = writer.diff_strategy().posterior_params();
3849 assert!((alpha - 5.0).abs() < 0.001);
3850 assert!((beta - 5.0).abs() < 0.001);
3851 }
3852
3853 #[test]
3854 fn with_diff_config_applies_tile_config() {
3855 let tile_config = TileDiffConfig::default()
3856 .with_enabled(false)
3857 .with_tile_size(32, 16)
3858 .with_max_tiles(1024);
3859 let runtime_config = RuntimeDiffConfig::default().with_tile_diff_config(tile_config);
3860
3861 let mut writer = TerminalWriter::with_diff_config(
3862 Vec::<u8>::new(),
3863 ScreenMode::AltScreen,
3864 UiAnchor::Bottom,
3865 basic_caps(),
3866 runtime_config,
3867 );
3868
3869 let applied = writer.diff_scratch.tile_config_mut();
3870 assert!(!applied.enabled);
3871 assert_eq!(applied.tile_w, 32);
3872 assert_eq!(applied.tile_h, 16);
3873 assert_eq!(applied.max_tiles, 1024);
3874 }
3875
3876 #[test]
3877 fn diff_config_accessor() {
3878 let config = RuntimeDiffConfig::default().with_bayesian_enabled(false);
3879
3880 let writer = TerminalWriter::with_diff_config(
3881 Vec::<u8>::new(),
3882 ScreenMode::AltScreen,
3883 UiAnchor::Bottom,
3884 basic_caps(),
3885 config,
3886 );
3887
3888 assert!(!writer.diff_config().bayesian_enabled);
3889 }
3890
3891 #[test]
3892 fn last_diff_strategy_updates_after_present() {
3893 let mut output = Vec::new();
3894 let mut writer = TerminalWriter::with_diff_config(
3895 &mut output,
3896 ScreenMode::AltScreen,
3897 UiAnchor::Bottom,
3898 basic_caps(),
3899 RuntimeDiffConfig::default(),
3900 );
3901 writer.set_size(10, 3);
3902
3903 let mut buffer = Buffer::new(10, 3);
3904 buffer.set_raw(0, 0, Cell::from_char('X'));
3905
3906 assert!(writer.last_diff_strategy().is_none());
3907 writer.present_ui(&buffer, None, false).unwrap();
3908 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
3909
3910 buffer.set_raw(1, 1, Cell::from_char('Y'));
3911 writer.present_ui(&buffer, None, false).unwrap();
3912 assert!(writer.last_diff_strategy().is_some());
3913 }
3914
3915 #[test]
3916 fn diff_decision_evidence_schema_includes_span_fields() {
3917 let evidence_path = temp_evidence_path("diff_decision_schema");
3918 let sink = EvidenceSink::from_config(
3919 &crate::evidence_sink::EvidenceSinkConfig::enabled_file(&evidence_path),
3920 )
3921 .expect("evidence sink config")
3922 .expect("evidence sink enabled");
3923
3924 let mut writer = TerminalWriter::with_diff_config(
3925 Vec::<u8>::new(),
3926 ScreenMode::AltScreen,
3927 UiAnchor::Bottom,
3928 basic_caps(),
3929 RuntimeDiffConfig::default(),
3930 )
3931 .with_evidence_sink(sink);
3932 writer.set_size(10, 3);
3933
3934 let mut buffer = Buffer::new(10, 3);
3935 buffer.set_raw(0, 0, Cell::from_char('X'));
3936 writer.present_ui(&buffer, None, false).unwrap();
3937
3938 buffer.set_raw(1, 1, Cell::from_char('Y'));
3939 writer.present_ui(&buffer, None, false).unwrap();
3940
3941 let jsonl = std::fs::read_to_string(&evidence_path).expect("read evidence jsonl");
3942 let line = jsonl
3943 .lines()
3944 .find(|line| line.contains("\"event\":\"diff_decision\""))
3945 .expect("diff_decision line");
3946 let value: serde_json::Value = serde_json::from_str(line).expect("valid json");
3947
3948 assert_eq!(
3949 value["schema_version"],
3950 crate::evidence_sink::EVIDENCE_SCHEMA_VERSION
3951 );
3952 assert_eq!(value["event"], "diff_decision");
3953 assert!(
3954 value["run_id"]
3955 .as_str()
3956 .map(|s| !s.is_empty())
3957 .unwrap_or(false),
3958 "run_id should be a non-empty string"
3959 );
3960 assert!(
3961 value["event_idx"].is_number(),
3962 "event_idx should be numeric"
3963 );
3964 assert_eq!(value["screen_mode"], "altscreen");
3965 assert!(value["cols"].is_number(), "cols should be numeric");
3966 assert!(value["rows"].is_number(), "rows should be numeric");
3967 assert!(
3968 value["span_count"].is_number(),
3969 "span_count should be numeric"
3970 );
3971 assert!(
3972 value["span_coverage_pct"].is_number(),
3973 "span_coverage_pct should be numeric"
3974 );
3975 assert!(
3976 value["tile_size"].is_number(),
3977 "tile_size should be numeric"
3978 );
3979 assert!(
3980 value["dirty_tile_count"].is_number(),
3981 "dirty_tile_count should be numeric"
3982 );
3983 assert!(
3984 value["skipped_tile_count"].is_number(),
3985 "skipped_tile_count should be numeric"
3986 );
3987 assert!(
3988 value["sat_build_cost_est"].is_number(),
3989 "sat_build_cost_est should be numeric"
3990 );
3991 assert!(
3992 value["fallback_reason"].is_string(),
3993 "fallback_reason should be string"
3994 );
3995 assert!(
3996 value["scan_cost_estimate"].is_number(),
3997 "scan_cost_estimate should be numeric"
3998 );
3999 assert!(
4000 value["max_span_len"].is_number(),
4001 "max_span_len should be numeric"
4002 );
4003 assert!(
4004 value["guard_reason"].is_string(),
4005 "guard_reason should be a string"
4006 );
4007 assert!(
4008 value["hysteresis_applied"].is_boolean(),
4009 "hysteresis_applied should be boolean"
4010 );
4011 assert!(
4012 value["hysteresis_ratio"].is_number(),
4013 "hysteresis_ratio should be numeric"
4014 );
4015 assert!(
4016 value["fallback_reason"].is_string(),
4017 "fallback_reason should be a string"
4018 );
4019 assert!(
4020 value["scan_cost_estimate"].is_number(),
4021 "scan_cost_estimate should be numeric"
4022 );
4023 }
4024
4025 #[test]
4026 fn diff_strategy_posterior_updates_with_total_cells() {
4027 let mut output = Vec::new();
4028 let mut writer = TerminalWriter::with_diff_config(
4029 &mut output,
4030 ScreenMode::AltScreen,
4031 UiAnchor::Bottom,
4032 basic_caps(),
4033 RuntimeDiffConfig::default(),
4034 );
4035 writer.set_size(10, 10);
4036
4037 let mut buffer = Buffer::new(10, 10);
4038 buffer.set_raw(0, 0, Cell::from_char('A'));
4039 writer.present_ui(&buffer, None, false).unwrap();
4040
4041 let mut buffer2 = Buffer::new(10, 10);
4042 for x in 0..10u16 {
4043 buffer2.set_raw(x, 0, Cell::from_char('X'));
4044 }
4045 writer.present_ui(&buffer2, None, false).unwrap();
4046
4047 let config = writer.diff_strategy().config().clone();
4048 let total_cells = 10usize * 10usize;
4049 let changed = 10usize;
4050 let alpha = config.prior_alpha * config.decay + changed as f64;
4051 let beta = config.prior_beta * config.decay + (total_cells - changed) as f64;
4052 let expected = alpha / (alpha + beta);
4053 let mean = writer.diff_strategy().posterior_mean();
4054 assert!(
4055 (mean - expected).abs() < 1e-9,
4056 "posterior mean should use total_cells; got {mean:.6}, expected {expected:.6}"
4057 );
4058 }
4059
4060 #[test]
4061 fn log_write_without_scroll_region_resets_diff_strategy() {
4062 let mut output = Vec::new();
4065 {
4066 let config = RuntimeDiffConfig::default();
4067 let mut writer = TerminalWriter::with_diff_config(
4068 &mut output,
4069 ScreenMode::Inline { ui_height: 5 },
4070 UiAnchor::Bottom,
4071 basic_caps(), config,
4073 );
4074 writer.set_size(80, 24);
4075
4076 let mut buffer = Buffer::new(80, 5);
4078 buffer.set_raw(0, 0, Cell::from_char('X'));
4079 writer.present_ui(&buffer, None, false).unwrap();
4080
4081 let (_alpha_before, _) = writer.diff_strategy().posterior_params();
4083
4084 buffer.set_raw(1, 1, Cell::from_char('Y'));
4086 writer.present_ui(&buffer, None, false).unwrap();
4087
4088 assert!(!writer.scroll_region_active());
4090 writer.write_log("log message\n").unwrap();
4091
4092 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4094 assert!(
4095 (alpha_after - 1.0).abs() < 0.01 && (beta_after - 19.0).abs() < 0.01,
4096 "posterior should reset to priors after log write: alpha={}, beta={}",
4097 alpha_after,
4098 beta_after
4099 );
4100 }
4101 }
4102
4103 #[test]
4104 fn log_write_with_scroll_region_preserves_diff_strategy() {
4105 let mut output = Vec::new();
4107 {
4108 let config = RuntimeDiffConfig::default();
4109 let mut writer = TerminalWriter::with_diff_config(
4110 &mut output,
4111 ScreenMode::Inline { ui_height: 5 },
4112 UiAnchor::Bottom,
4113 scroll_region_caps(), config,
4115 );
4116 writer.set_size(80, 24);
4117
4118 let mut buffer = Buffer::new(80, 5);
4120 buffer.set_raw(0, 0, Cell::from_char('X'));
4121 writer.present_ui(&buffer, None, false).unwrap();
4122
4123 buffer.set_raw(1, 1, Cell::from_char('Y'));
4124 writer.present_ui(&buffer, None, false).unwrap();
4125
4126 assert!(writer.scroll_region_active());
4127
4128 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4130
4131 writer.write_log("log message\n").unwrap();
4133
4134 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4135 assert!(
4136 (alpha_after - alpha_before).abs() < 0.01
4137 && (beta_after - beta_before).abs() < 0.01,
4138 "posterior should be preserved with scroll region: before=({}, {}), after=({}, {})",
4139 alpha_before,
4140 beta_before,
4141 alpha_after,
4142 beta_after
4143 );
4144 }
4145 }
4146
4147 #[test]
4148 fn strategy_selection_config_flags_applied() {
4149 let config = RuntimeDiffConfig::default()
4151 .with_dirty_rows_enabled(false)
4152 .with_bayesian_enabled(false);
4153
4154 let writer = TerminalWriter::with_diff_config(
4155 Vec::<u8>::new(),
4156 ScreenMode::AltScreen,
4157 UiAnchor::Bottom,
4158 basic_caps(),
4159 config,
4160 );
4161
4162 assert!(!writer.diff_config().dirty_rows_enabled);
4164 assert!(!writer.diff_config().bayesian_enabled);
4165
4166 let (alpha, beta) = writer.diff_strategy().posterior_params();
4168 assert!((alpha - 1.0).abs() < 0.01);
4170 assert!((beta - 19.0).abs() < 0.01);
4171 }
4172
4173 #[test]
4174 fn resize_respects_reset_toggle() {
4175 let config = RuntimeDiffConfig::default().with_reset_on_resize(false);
4177
4178 let mut writer = TerminalWriter::with_diff_config(
4179 Vec::<u8>::new(),
4180 ScreenMode::AltScreen,
4181 UiAnchor::Bottom,
4182 basic_caps(),
4183 config,
4184 );
4185 writer.set_size(80, 24);
4186
4187 let mut buffer = Buffer::new(80, 24);
4189 buffer.set_raw(0, 0, Cell::from_char('X'));
4190 writer.present_ui(&buffer, None, false).unwrap();
4191
4192 let mut buffer2 = Buffer::new(80, 24);
4193 buffer2.set_raw(1, 1, Cell::from_char('Y'));
4194 writer.present_ui(&buffer2, None, false).unwrap();
4195
4196 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4198
4199 writer.set_size(100, 30);
4201
4202 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4203 assert!(
4204 (alpha_after - alpha_before).abs() < 0.01 && (beta_after - beta_before).abs() < 0.01,
4205 "posterior should be preserved when reset_on_resize=false"
4206 );
4207 }
4208
4209 #[test]
4214 fn screen_mode_default_is_altscreen() {
4215 assert_eq!(ScreenMode::default(), ScreenMode::AltScreen);
4216 }
4217
4218 #[test]
4219 fn screen_mode_debug_format() {
4220 let dbg = format!("{:?}", ScreenMode::Inline { ui_height: 7 });
4221 assert!(dbg.contains("Inline"));
4222 assert!(dbg.contains('7'));
4223 }
4224
4225 #[test]
4226 fn screen_mode_inline_auto_debug_format() {
4227 let dbg = format!(
4228 "{:?}",
4229 ScreenMode::InlineAuto {
4230 min_height: 3,
4231 max_height: 10
4232 }
4233 );
4234 assert!(dbg.contains("InlineAuto"));
4235 }
4236
4237 #[test]
4238 fn screen_mode_eq_inline_auto() {
4239 let a = ScreenMode::InlineAuto {
4240 min_height: 2,
4241 max_height: 8,
4242 };
4243 let b = ScreenMode::InlineAuto {
4244 min_height: 2,
4245 max_height: 8,
4246 };
4247 assert_eq!(a, b);
4248 let c = ScreenMode::InlineAuto {
4249 min_height: 2,
4250 max_height: 9,
4251 };
4252 assert_ne!(a, c);
4253 }
4254
4255 #[test]
4256 fn ui_anchor_default_is_bottom() {
4257 assert_eq!(UiAnchor::default(), UiAnchor::Bottom);
4258 }
4259
4260 #[test]
4261 fn ui_anchor_debug_format() {
4262 assert_eq!(format!("{:?}", UiAnchor::Top), "Top");
4263 assert_eq!(format!("{:?}", UiAnchor::Bottom), "Bottom");
4264 }
4265
4266 #[test]
4271 fn width_height_accessors() {
4272 let output = Vec::new();
4273 let mut writer = TerminalWriter::new(
4274 output,
4275 ScreenMode::AltScreen,
4276 UiAnchor::Bottom,
4277 basic_caps(),
4278 );
4279 assert_eq!(writer.width(), 80);
4281 assert_eq!(writer.height(), 24);
4282
4283 writer.set_size(120, 40);
4284 assert_eq!(writer.width(), 120);
4285 assert_eq!(writer.height(), 40);
4286 }
4287
4288 #[test]
4289 fn screen_mode_accessor() {
4290 let writer = TerminalWriter::new(
4291 Vec::new(),
4292 ScreenMode::Inline { ui_height: 5 },
4293 UiAnchor::Top,
4294 basic_caps(),
4295 );
4296 assert_eq!(writer.screen_mode(), ScreenMode::Inline { ui_height: 5 });
4297 }
4298
4299 #[test]
4300 fn capabilities_accessor() {
4301 let caps = full_caps();
4302 let writer = TerminalWriter::new(Vec::new(), ScreenMode::AltScreen, UiAnchor::Bottom, caps);
4303 assert!(writer.capabilities().true_color);
4304 assert!(writer.capabilities().sync_output);
4305 }
4306
4307 #[test]
4312 fn into_inner_returns_writer() {
4313 let writer = TerminalWriter::new(
4314 Vec::new(),
4315 ScreenMode::AltScreen,
4316 UiAnchor::Bottom,
4317 basic_caps(),
4318 );
4319 let inner = writer.into_inner();
4320 assert!(inner.is_some());
4321 }
4322
4323 #[test]
4324 fn into_inner_performs_cleanup() {
4325 let mut writer = TerminalWriter::new(
4326 Vec::new(),
4327 ScreenMode::Inline { ui_height: 5 },
4328 UiAnchor::Bottom,
4329 basic_caps(),
4330 );
4331 writer.cursor_saved = true;
4332 writer.in_sync_block = false;
4333
4334 let inner = writer.into_inner().unwrap();
4335 assert!(
4337 inner
4338 .windows(CURSOR_RESTORE.len())
4339 .any(|w| w == CURSOR_RESTORE),
4340 "into_inner should perform cleanup before returning"
4341 );
4342 }
4343
4344 #[test]
4349 fn take_render_buffer_creates_new_when_no_spare() {
4350 let mut writer = TerminalWriter::new(
4351 Vec::new(),
4352 ScreenMode::AltScreen,
4353 UiAnchor::Bottom,
4354 basic_caps(),
4355 );
4356 let buf = writer.take_render_buffer(80, 24);
4357 assert_eq!(buf.width(), 80);
4358 assert_eq!(buf.height(), 24);
4359 }
4360
4361 #[test]
4362 fn take_render_buffer_reuses_spare_on_match() {
4363 let mut writer = TerminalWriter::new(
4364 Vec::new(),
4365 ScreenMode::AltScreen,
4366 UiAnchor::Bottom,
4367 basic_caps(),
4368 );
4369 writer.spare_buffer = Some(Buffer::new(80, 24));
4371 assert!(writer.spare_buffer.is_some());
4372
4373 let buf = writer.take_render_buffer(80, 24);
4374 assert_eq!(buf.width(), 80);
4375 assert_eq!(buf.height(), 24);
4376 assert!(writer.spare_buffer.is_none());
4378 }
4379
4380 #[test]
4381 fn take_render_buffer_ignores_spare_on_size_mismatch() {
4382 let mut writer = TerminalWriter::new(
4383 Vec::new(),
4384 ScreenMode::AltScreen,
4385 UiAnchor::Bottom,
4386 basic_caps(),
4387 );
4388 writer.spare_buffer = Some(Buffer::new(80, 24));
4389
4390 let buf = writer.take_render_buffer(100, 30);
4392 assert_eq!(buf.width(), 100);
4393 assert_eq!(buf.height(), 30);
4394 }
4395
4396 #[test]
4401 fn gc_with_no_prev_buffer() {
4402 let mut writer = TerminalWriter::new(
4403 Vec::new(),
4404 ScreenMode::AltScreen,
4405 UiAnchor::Bottom,
4406 basic_caps(),
4407 );
4408 assert!(writer.prev_buffer.is_none());
4409 writer.gc();
4411 }
4412
4413 #[test]
4414 fn gc_with_prev_buffer() {
4415 let mut writer = TerminalWriter::new(
4416 Vec::new(),
4417 ScreenMode::AltScreen,
4418 UiAnchor::Bottom,
4419 basic_caps(),
4420 );
4421 writer.prev_buffer = Some(Buffer::new(10, 5));
4422 writer.gc();
4424 }
4425
4426 #[test]
4431 fn hide_cursor_emits_sequence() {
4432 let mut output = Vec::new();
4433 {
4434 let mut writer = TerminalWriter::new(
4435 &mut output,
4436 ScreenMode::AltScreen,
4437 UiAnchor::Bottom,
4438 basic_caps(),
4439 );
4440 writer.hide_cursor().unwrap();
4441 }
4442 assert!(
4443 output.windows(6).any(|w| w == b"\x1b[?25l"),
4444 "hide_cursor should emit cursor hide sequence"
4445 );
4446 }
4447
4448 #[test]
4449 fn show_cursor_emits_sequence() {
4450 let mut output = Vec::new();
4451 {
4452 let mut writer = TerminalWriter::new(
4453 &mut output,
4454 ScreenMode::AltScreen,
4455 UiAnchor::Bottom,
4456 basic_caps(),
4457 );
4458 writer.hide_cursor().unwrap();
4460 writer.show_cursor().unwrap();
4461 }
4462 assert!(
4463 output.windows(6).any(|w| w == b"\x1b[?25h"),
4464 "show_cursor should emit cursor show sequence"
4465 );
4466 }
4467
4468 #[test]
4469 fn hide_cursor_idempotent() {
4470 use std::io::Cursor;
4472 let mut writer = TerminalWriter::new(
4473 Cursor::new(Vec::new()),
4474 ScreenMode::AltScreen,
4475 UiAnchor::Bottom,
4476 basic_caps(),
4477 );
4478 writer.hide_cursor().unwrap();
4479 let inner = writer.into_inner().unwrap().into_inner();
4480 let hide_count = inner.windows(6).filter(|w| *w == b"\x1b[?25l").count();
4481 assert_eq!(
4483 hide_count, 1,
4484 "hide_cursor called once should emit exactly one hide sequence"
4485 );
4486 }
4487
4488 #[test]
4489 fn show_cursor_idempotent_when_already_visible() {
4490 use std::io::Cursor;
4491 let mut writer = TerminalWriter::new(
4492 Cursor::new(Vec::new()),
4493 ScreenMode::AltScreen,
4494 UiAnchor::Bottom,
4495 basic_caps(),
4496 );
4497 writer.show_cursor().unwrap();
4499 let inner = writer.into_inner().unwrap().into_inner();
4500 let show_count = inner.windows(6).filter(|w| *w == b"\x1b[?25h").count();
4502 assert!(
4503 show_count <= 1,
4504 "show_cursor when already visible should not add extra show sequences"
4505 );
4506 }
4507
4508 #[test]
4513 fn pool_accessor() {
4514 let writer = TerminalWriter::new(
4515 Vec::new(),
4516 ScreenMode::AltScreen,
4517 UiAnchor::Bottom,
4518 basic_caps(),
4519 );
4520 let _pool = writer.pool();
4522 }
4523
4524 #[test]
4525 fn pool_mut_accessor() {
4526 let mut writer = TerminalWriter::new(
4527 Vec::new(),
4528 ScreenMode::AltScreen,
4529 UiAnchor::Bottom,
4530 basic_caps(),
4531 );
4532 let _pool = writer.pool_mut();
4533 }
4534
4535 #[test]
4536 fn links_accessor() {
4537 let writer = TerminalWriter::new(
4538 Vec::new(),
4539 ScreenMode::AltScreen,
4540 UiAnchor::Bottom,
4541 basic_caps(),
4542 );
4543 let _links = writer.links();
4544 }
4545
4546 #[test]
4547 fn links_mut_accessor() {
4548 let mut writer = TerminalWriter::new(
4549 Vec::new(),
4550 ScreenMode::AltScreen,
4551 UiAnchor::Bottom,
4552 basic_caps(),
4553 );
4554 let _links = writer.links_mut();
4555 }
4556
4557 #[test]
4558 fn pool_and_links_mut_accessor() {
4559 let mut writer = TerminalWriter::new(
4560 Vec::new(),
4561 ScreenMode::AltScreen,
4562 UiAnchor::Bottom,
4563 basic_caps(),
4564 );
4565 let (_pool, _links) = writer.pool_and_links_mut();
4566 }
4567
4568 #[test]
4573 fn sanitize_auto_bounds_normal() {
4574 assert_eq!(sanitize_auto_bounds(3, 10), (3, 10));
4575 }
4576
4577 #[test]
4578 fn sanitize_auto_bounds_zero_min() {
4579 assert_eq!(sanitize_auto_bounds(0, 10), (1, 10));
4581 }
4582
4583 #[test]
4584 fn sanitize_auto_bounds_max_less_than_min() {
4585 assert_eq!(sanitize_auto_bounds(5, 3), (5, 5));
4587 }
4588
4589 #[test]
4590 fn sanitize_auto_bounds_both_zero() {
4591 assert_eq!(sanitize_auto_bounds(0, 0), (1, 1));
4592 }
4593
4594 #[test]
4595 fn diff_strategy_str_variants() {
4596 assert_eq!(diff_strategy_str(DiffStrategy::Full), "full");
4597 assert_eq!(diff_strategy_str(DiffStrategy::DirtyRows), "dirty");
4598 assert_eq!(diff_strategy_str(DiffStrategy::FullRedraw), "redraw");
4599 }
4600
4601 #[test]
4602 fn ui_anchor_str_variants() {
4603 assert_eq!(ui_anchor_str(UiAnchor::Bottom), "bottom");
4604 assert_eq!(ui_anchor_str(UiAnchor::Top), "top");
4605 }
4606
4607 #[test]
4608 fn json_escape_plain_text() {
4609 assert_eq!(json_escape("hello"), "hello");
4610 }
4611
4612 #[test]
4613 fn json_escape_special_chars() {
4614 assert_eq!(json_escape(r#"a"b"#), r#"a\"b"#);
4615 assert_eq!(json_escape("a\\b"), r#"a\\b"#);
4616 assert_eq!(json_escape("a\nb"), r#"a\nb"#);
4617 assert_eq!(json_escape("a\rb"), r#"a\rb"#);
4618 assert_eq!(json_escape("a\tb"), r#"a\tb"#);
4619 }
4620
4621 #[test]
4622 fn json_escape_control_chars() {
4623 let s = String::from("\x00\x01\x1f");
4624 let escaped = json_escape(&s);
4625 assert!(escaped.contains("\\u0000"));
4626 assert!(escaped.contains("\\u0001"));
4627 assert!(escaped.contains("\\u001F"));
4628 }
4629
4630 #[test]
4631 fn json_escape_unicode_passthrough() {
4632 assert_eq!(json_escape("caf\u{00e9}"), "caf\u{00e9}");
4633 assert_eq!(json_escape("\u{1f600}"), "\u{1f600}");
4634 }
4635
4636 #[test]
4641 fn counting_writer_no_counting_by_default() {
4642 let mut cw = CountingWriter::new(Vec::new());
4643 cw.write_all(b"hello").unwrap();
4644 assert_eq!(cw.take_count(), 0);
4645 }
4646
4647 #[test]
4648 fn counting_writer_counts_when_enabled() {
4649 let mut cw = CountingWriter::new(Vec::new());
4650 cw.enable_counting();
4651 cw.write_all(b"hello").unwrap();
4652 assert_eq!(cw.take_count(), 5);
4653 }
4654
4655 #[test]
4656 fn counting_writer_take_count_resets() {
4657 let mut cw = CountingWriter::new(Vec::new());
4658 cw.enable_counting();
4659 cw.write_all(b"abc").unwrap();
4660 assert_eq!(cw.take_count(), 3);
4661 assert_eq!(cw.take_count(), 0);
4663 }
4664
4665 #[test]
4666 fn counting_writer_disable_stops_counting() {
4667 let mut cw = CountingWriter::new(Vec::new());
4668 cw.enable_counting();
4669 cw.write_all(b"abc").unwrap();
4670 cw.disable_counting();
4671 cw.write_all(b"def").unwrap();
4672 assert_eq!(cw.take_count(), 3);
4674 }
4675
4676 #[test]
4677 fn counting_writer_write_counts_partial() {
4678 let mut cw = CountingWriter::new(Vec::new());
4679 cw.enable_counting();
4680 let written = cw.write(b"hello world").unwrap();
4681 assert_eq!(written, 11);
4682 assert_eq!(cw.take_count(), 11);
4683 }
4684
4685 #[test]
4686 fn counting_writer_flush() {
4687 let mut cw = CountingWriter::new(Vec::new());
4688 cw.flush().unwrap();
4689 }
4690
4691 #[test]
4692 fn counting_writer_into_inner() {
4693 let mut cw = CountingWriter::new(Vec::new());
4694 cw.write_all(b"data").unwrap();
4695 let inner = cw.into_inner();
4696 assert_eq!(inner, b"data");
4697 }
4698
4699 fn zero_span_stats() -> DirtySpanStats {
4704 DirtySpanStats {
4705 rows_full_dirty: 0,
4706 rows_with_spans: 0,
4707 total_spans: 0,
4708 overflows: 0,
4709 span_coverage_cells: 0,
4710 max_span_len: 0,
4711 max_spans_per_row: 4,
4712 }
4713 }
4714
4715 #[test]
4716 fn estimate_diff_scan_cost_full_strategy() {
4717 let stats = zero_span_stats();
4718 let (cost, label) = estimate_diff_scan_cost(DiffStrategy::Full, 0, 80, 24, &stats, None);
4719 assert_eq!(cost, 80 * 24);
4720 assert_eq!(label, "full_strategy");
4721 }
4722
4723 #[test]
4724 fn estimate_diff_scan_cost_full_redraw() {
4725 let stats = zero_span_stats();
4726 let (cost, label) =
4727 estimate_diff_scan_cost(DiffStrategy::FullRedraw, 5, 80, 24, &stats, None);
4728 assert_eq!(cost, 0);
4729 assert_eq!(label, "full_redraw");
4730 }
4731
4732 #[test]
4733 fn estimate_diff_scan_cost_dirty_rows_no_dirty() {
4734 let stats = zero_span_stats();
4735 let (cost, label) =
4736 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 0, 80, 24, &stats, None);
4737 assert_eq!(cost, 0);
4738 assert_eq!(label, "no_dirty_rows");
4739 }
4740
4741 #[test]
4742 fn estimate_diff_scan_cost_dirty_rows_with_span_coverage() {
4743 let mut stats = zero_span_stats();
4744 stats.span_coverage_cells = 100;
4745 let (cost, label) =
4746 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
4747 assert_eq!(cost, 100);
4748 assert_eq!(label, "none");
4749 }
4750
4751 #[test]
4752 fn estimate_diff_scan_cost_dirty_rows_no_spans() {
4753 let stats = zero_span_stats();
4754 let (cost, label) =
4755 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
4756 assert_eq!(cost, 5 * 80);
4757 assert_eq!(label, "no_spans");
4758 }
4759
4760 #[test]
4761 fn estimate_diff_scan_cost_dirty_rows_overflow_with_span() {
4762 let mut stats = zero_span_stats();
4763 stats.span_coverage_cells = 150;
4764 stats.overflows = 1;
4765 let (cost, label) =
4766 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
4767 assert_eq!(cost, 150);
4768 assert_eq!(label, "span_overflow");
4769 }
4770
4771 #[test]
4772 fn estimate_diff_scan_cost_dirty_rows_overflow_no_span() {
4773 let mut stats = zero_span_stats();
4774 stats.overflows = 1;
4775 let (cost, label) =
4776 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
4777 assert_eq!(cost, 5 * 80);
4778 assert_eq!(label, "span_overflow");
4779 }
4780
4781 #[test]
4782 fn estimate_diff_scan_cost_tile_skip() {
4783 let stats = zero_span_stats();
4784 let tile = TileDiffStats {
4785 width: 80,
4786 height: 24,
4787 tile_w: 16,
4788 tile_h: 8,
4789 tiles_x: 5,
4790 tiles_y: 3,
4791 total_tiles: 15,
4792 dirty_cells: 10,
4793 dirty_tiles: 2,
4794 dirty_cell_ratio: 0.005,
4795 dirty_tile_ratio: 0.13,
4796 scanned_tiles: 2,
4797 skipped_tiles: 13,
4798 sat_build_cells: 1920,
4799 scan_cells_estimate: 42,
4800 fallback: None,
4801 };
4802 let (cost, label) =
4803 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
4804 assert_eq!(cost, 42);
4805 assert_eq!(label, "tile_skip");
4806 }
4807
4808 #[test]
4809 fn estimate_diff_scan_cost_tile_with_fallback_uses_spans() {
4810 let mut stats = zero_span_stats();
4811 stats.span_coverage_cells = 200;
4812 let tile = TileDiffStats {
4813 width: 80,
4814 height: 24,
4815 tile_w: 16,
4816 tile_h: 8,
4817 tiles_x: 5,
4818 tiles_y: 3,
4819 total_tiles: 15,
4820 dirty_cells: 10,
4821 dirty_tiles: 2,
4822 dirty_cell_ratio: 0.005,
4823 dirty_tile_ratio: 0.13,
4824 scanned_tiles: 2,
4825 skipped_tiles: 13,
4826 sat_build_cells: 1920,
4827 scan_cells_estimate: 42,
4828 fallback: Some(TileDiffFallback::SmallScreen),
4829 };
4830 let (cost, label) =
4831 estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
4832 assert_eq!(cost, 200);
4834 assert_eq!(label, "none");
4835 }
4836
4837 #[test]
4842 fn inline_auto_bounds_accessor() {
4843 let mut writer = TerminalWriter::new(
4844 Vec::new(),
4845 ScreenMode::InlineAuto {
4846 min_height: 3,
4847 max_height: 10,
4848 },
4849 UiAnchor::Bottom,
4850 basic_caps(),
4851 );
4852 writer.set_size(80, 24);
4853 let bounds = writer.inline_auto_bounds();
4854 assert_eq!(bounds, Some((3, 10)));
4855 }
4856
4857 #[test]
4858 fn inline_auto_bounds_clamped_to_terminal() {
4859 let mut writer = TerminalWriter::new(
4860 Vec::new(),
4861 ScreenMode::InlineAuto {
4862 min_height: 3,
4863 max_height: 50,
4864 },
4865 UiAnchor::Bottom,
4866 basic_caps(),
4867 );
4868 writer.set_size(80, 20);
4869 let bounds = writer.inline_auto_bounds();
4870 assert_eq!(bounds, Some((3, 20)));
4871 }
4872
4873 #[test]
4874 fn inline_auto_bounds_returns_none_for_non_auto() {
4875 let writer = TerminalWriter::new(
4876 Vec::new(),
4877 ScreenMode::Inline { ui_height: 5 },
4878 UiAnchor::Bottom,
4879 basic_caps(),
4880 );
4881 assert_eq!(writer.inline_auto_bounds(), None);
4882
4883 let writer2 = TerminalWriter::new(
4884 Vec::new(),
4885 ScreenMode::AltScreen,
4886 UiAnchor::Bottom,
4887 basic_caps(),
4888 );
4889 assert_eq!(writer2.inline_auto_bounds(), None);
4890 }
4891
4892 #[test]
4893 fn auto_ui_height_returns_none_for_non_auto() {
4894 let writer = TerminalWriter::new(
4895 Vec::new(),
4896 ScreenMode::Inline { ui_height: 5 },
4897 UiAnchor::Bottom,
4898 basic_caps(),
4899 );
4900 assert_eq!(writer.auto_ui_height(), None);
4901 }
4902
4903 #[test]
4904 fn render_height_hint_altscreen() {
4905 let mut writer = TerminalWriter::new(
4906 Vec::new(),
4907 ScreenMode::AltScreen,
4908 UiAnchor::Bottom,
4909 basic_caps(),
4910 );
4911 writer.set_size(80, 24);
4912 assert_eq!(writer.render_height_hint(), 24);
4913 }
4914
4915 #[test]
4916 fn render_height_hint_inline_fixed() {
4917 let writer = TerminalWriter::new(
4918 Vec::new(),
4919 ScreenMode::Inline { ui_height: 7 },
4920 UiAnchor::Bottom,
4921 basic_caps(),
4922 );
4923 assert_eq!(writer.render_height_hint(), 7);
4924 }
4925
4926 #[test]
4931 fn runtime_diff_config_tile_skip_toggle() {
4932 let config = RuntimeDiffConfig::new().with_tile_skip_enabled(false);
4933 assert!(!config.tile_diff_config.enabled);
4934 }
4935
4936 #[test]
4937 fn runtime_diff_config_dirty_spans_toggle() {
4938 let config = RuntimeDiffConfig::new().with_dirty_spans_enabled(false);
4939 assert!(!config.dirty_span_config.enabled);
4940 }
4941
4942 #[test]
4947 fn present_ui_altscreen_no_cursor_save_restore() {
4948 let mut output = Vec::new();
4949 {
4950 let mut writer = TerminalWriter::new(
4951 &mut output,
4952 ScreenMode::AltScreen,
4953 UiAnchor::Bottom,
4954 basic_caps(),
4955 );
4956 writer.set_size(10, 5);
4957 let buffer = Buffer::new(10, 5);
4958 writer.present_ui(&buffer, None, true).unwrap();
4959 }
4960
4961 let save_count = output
4963 .windows(CURSOR_SAVE.len())
4964 .filter(|w| *w == CURSOR_SAVE)
4965 .count();
4966 assert_eq!(save_count, 0, "AltScreen should not save cursor");
4967 }
4968
4969 #[test]
4970 fn clear_screen_emits_ed2() {
4971 let mut output = Vec::new();
4972 {
4973 let mut writer = TerminalWriter::new(
4974 &mut output,
4975 ScreenMode::AltScreen,
4976 UiAnchor::Bottom,
4977 basic_caps(),
4978 );
4979 writer.clear_screen().unwrap();
4980 }
4981 assert!(
4982 output.windows(4).any(|w| w == b"\x1b[2J"),
4983 "clear_screen should emit ED2 sequence"
4984 );
4985 }
4986
4987 #[test]
4988 fn set_size_resets_scroll_region_and_spare_buffer() {
4989 let output = Vec::new();
4990 let mut writer = TerminalWriter::new(
4991 output,
4992 ScreenMode::Inline { ui_height: 5 },
4993 UiAnchor::Bottom,
4994 basic_caps(),
4995 );
4996 writer.spare_buffer = Some(Buffer::new(80, 24));
4997 writer.set_size(100, 30);
4998 assert!(writer.spare_buffer.is_none());
4999 }
5000
5001 static GAUGE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
5008
5009 #[test]
5010 fn inline_active_widgets_gauge_increments_for_inline_mode() {
5011 let _lock = GAUGE_TEST_LOCK.lock().unwrap();
5012 let before = inline_active_widgets();
5013 let writer = TerminalWriter::new(
5014 Vec::new(),
5015 ScreenMode::Inline { ui_height: 5 },
5016 UiAnchor::Bottom,
5017 basic_caps(),
5018 );
5019 assert_eq!(
5020 inline_active_widgets(),
5021 before + 1,
5022 "creating an inline writer should increment the gauge"
5023 );
5024 drop(writer);
5025 assert_eq!(
5026 inline_active_widgets(),
5027 before,
5028 "dropping an inline writer should decrement the gauge"
5029 );
5030 }
5031
5032 #[test]
5033 fn inline_active_widgets_gauge_increments_for_inline_auto_mode() {
5034 let _lock = GAUGE_TEST_LOCK.lock().unwrap();
5035 let before = inline_active_widgets();
5036 let writer = TerminalWriter::new(
5037 Vec::new(),
5038 ScreenMode::InlineAuto {
5039 min_height: 2,
5040 max_height: 10,
5041 },
5042 UiAnchor::Bottom,
5043 basic_caps(),
5044 );
5045 assert_eq!(inline_active_widgets(), before + 1);
5046 drop(writer);
5047 assert_eq!(inline_active_widgets(), before);
5048 }
5049
5050 #[test]
5051 fn inline_active_widgets_gauge_unchanged_for_altscreen() {
5052 let _lock = GAUGE_TEST_LOCK.lock().unwrap();
5053 let before = inline_active_widgets();
5054 let writer = TerminalWriter::new(
5055 Vec::new(),
5056 ScreenMode::AltScreen,
5057 UiAnchor::Bottom,
5058 basic_caps(),
5059 );
5060 assert_eq!(
5061 inline_active_widgets(),
5062 before,
5063 "altscreen writer should not affect the inline gauge"
5064 );
5065 drop(writer);
5066 }
5067
5068 const ALTSCREEN_ENTER: &[u8] = b"\x1b[?1049h";
5075
5076 const ALTSCREEN_EXIT: &[u8] = b"\x1b[?1049l";
5078
5079 fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
5081 haystack.windows(needle.len()).any(|w| w == needle)
5082 }
5083
5084 #[test]
5085 fn inline_render_never_emits_altscreen_enter() {
5086 let mut output = Vec::new();
5088 {
5089 let mut writer = TerminalWriter::new(
5090 &mut output,
5091 ScreenMode::Inline { ui_height: 5 },
5092 UiAnchor::Bottom,
5093 basic_caps(),
5094 );
5095 writer.set_size(80, 24);
5096
5097 let buffer = Buffer::new(80, 5);
5098 writer.present_ui(&buffer, None, true).unwrap();
5099 writer.write_log("hello\n").unwrap();
5100 writer.present_ui(&buffer, None, true).unwrap();
5102 }
5103
5104 assert!(
5105 !contains_bytes(&output, ALTSCREEN_ENTER),
5106 "inline mode must never emit CSI ?1049h (alternate screen enter)"
5107 );
5108 assert!(
5109 !contains_bytes(&output, ALTSCREEN_EXIT),
5110 "inline mode must never emit CSI ?1049l (alternate screen exit)"
5111 );
5112 }
5113
5114 #[test]
5115 fn inline_auto_render_never_emits_altscreen_enter() {
5116 let mut output = Vec::new();
5117 {
5118 let mut writer = TerminalWriter::new(
5119 &mut output,
5120 ScreenMode::InlineAuto {
5121 min_height: 3,
5122 max_height: 10,
5123 },
5124 UiAnchor::Bottom,
5125 basic_caps(),
5126 );
5127 writer.set_size(80, 24);
5128
5129 let buffer = Buffer::new(80, 5);
5130 writer.present_ui(&buffer, None, true).unwrap();
5131 }
5132
5133 assert!(
5134 !contains_bytes(&output, ALTSCREEN_ENTER),
5135 "InlineAuto mode must never emit CSI ?1049h"
5136 );
5137 }
5138
5139 #[test]
5140 fn inline_scrollback_preserved_after_present() {
5141 let mut output = Vec::new();
5146 {
5147 let mut writer = TerminalWriter::new(
5148 &mut output,
5149 ScreenMode::Inline { ui_height: 5 },
5150 UiAnchor::Bottom,
5151 basic_caps(),
5152 );
5153 writer.set_size(80, 24);
5154
5155 writer.write_log("scrollback line A\n").unwrap();
5156 writer.write_log("scrollback line B\n").unwrap();
5157
5158 let buffer = Buffer::new(80, 5);
5159 writer.present_ui(&buffer, None, true).unwrap();
5160
5161 writer.write_log("scrollback line C\n").unwrap();
5163 }
5164
5165 let text = String::from_utf8_lossy(&output);
5166 assert!(text.contains("scrollback line A"), "first log must survive");
5167 assert!(
5168 text.contains("scrollback line B"),
5169 "second log must survive"
5170 );
5171 assert!(
5172 text.contains("scrollback line C"),
5173 "post-render log must survive"
5174 );
5175
5176 assert!(
5179 contains_bytes(&output, CURSOR_SAVE),
5180 "present_ui must save cursor to protect scrollback"
5181 );
5182 assert!(
5183 contains_bytes(&output, CURSOR_RESTORE),
5184 "present_ui must restore cursor to protect scrollback"
5185 );
5186 }
5187
5188 #[test]
5189 fn multiple_inline_writers_coexist() {
5190 let mut writer_a = TerminalWriter::new(
5194 Vec::new(),
5195 ScreenMode::Inline { ui_height: 3 },
5196 UiAnchor::Bottom,
5197 basic_caps(),
5198 );
5199 writer_a.set_size(40, 12);
5200
5201 let mut writer_b = TerminalWriter::new(
5202 Vec::new(),
5203 ScreenMode::Inline { ui_height: 5 },
5204 UiAnchor::Bottom,
5205 basic_caps(),
5206 );
5207 writer_b.set_size(80, 24);
5208
5209 let buf_a = Buffer::new(40, 3);
5211 let buf_b = Buffer::new(80, 5);
5212 writer_a.present_ui(&buf_a, None, true).unwrap();
5213 writer_b.present_ui(&buf_b, None, true).unwrap();
5214
5215 writer_a.present_ui(&buf_a, None, true).unwrap();
5217 writer_b.present_ui(&buf_b, None, true).unwrap();
5218
5219 drop(writer_a);
5221 drop(writer_b);
5222 }
5223
5224 #[test]
5225 fn multiple_inline_writers_gauge_tracks_both() {
5226 let _lock = GAUGE_TEST_LOCK.lock().unwrap();
5228 let before = inline_active_widgets();
5229
5230 let writer_a = TerminalWriter::new(
5231 Vec::new(),
5232 ScreenMode::Inline { ui_height: 3 },
5233 UiAnchor::Bottom,
5234 basic_caps(),
5235 );
5236 assert_eq!(inline_active_widgets(), before + 1);
5237
5238 let writer_b = TerminalWriter::new(
5239 Vec::new(),
5240 ScreenMode::Inline { ui_height: 5 },
5241 UiAnchor::Bottom,
5242 basic_caps(),
5243 );
5244 assert_eq!(inline_active_widgets(), before + 2);
5245
5246 drop(writer_a);
5247 assert_eq!(inline_active_widgets(), before + 1);
5248
5249 drop(writer_b);
5250 assert_eq!(inline_active_widgets(), before);
5251 }
5252
5253 #[test]
5254 fn resize_during_inline_mode_preserves_scrollback() {
5255 let mut output = Vec::new();
5258 {
5259 let mut writer = TerminalWriter::new(
5260 &mut output,
5261 ScreenMode::Inline { ui_height: 5 },
5262 UiAnchor::Bottom,
5263 basic_caps(),
5264 );
5265 writer.set_size(80, 24);
5266
5267 let buffer = Buffer::new(80, 5);
5268 writer.present_ui(&buffer, None, true).unwrap();
5269
5270 writer.set_size(100, 30);
5272 assert_eq!(writer.ui_start_row(), 25); let buffer2 = Buffer::new(100, 5);
5276 writer.present_ui(&buffer2, None, true).unwrap();
5277
5278 writer.write_log("post-resize log\n").unwrap();
5280 }
5281
5282 let text = String::from_utf8_lossy(&output);
5283 assert!(text.contains("post-resize log"));
5284 assert!(
5285 !contains_bytes(&output, ALTSCREEN_ENTER),
5286 "resize must not trigger alternate screen"
5287 );
5288 }
5289
5290 #[test]
5291 fn resize_shrink_during_inline_mode_clamps_correctly() {
5292 let mut output = Vec::new();
5295 {
5296 let mut writer = TerminalWriter::new(
5297 &mut output,
5298 ScreenMode::Inline { ui_height: 10 },
5299 UiAnchor::Bottom,
5300 basic_caps(),
5301 );
5302 writer.set_size(80, 24);
5303 assert_eq!(writer.ui_start_row(), 14);
5304
5305 writer.set_size(80, 8);
5307 assert_eq!(writer.ui_start_row(), 0); let buffer = Buffer::new(80, 8);
5311 writer.present_ui(&buffer, None, true).unwrap();
5312 }
5313
5314 assert!(
5315 !contains_bytes(&output, ALTSCREEN_ENTER),
5316 "shrunken terminal must not switch to altscreen"
5317 );
5318 }
5319
5320 #[test]
5321 fn inline_render_emits_tracing_span_fields() {
5322 use std::sync::Arc;
5326 use std::sync::atomic::AtomicBool;
5327
5328 struct SpanChecker {
5329 saw_inline_render: Arc<AtomicBool>,
5330 }
5331
5332 impl tracing::Subscriber for SpanChecker {
5333 fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
5334 true
5335 }
5336 fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
5337 if span.metadata().name() == "inline.render" {
5338 self.saw_inline_render
5339 .store(true, std::sync::atomic::Ordering::SeqCst);
5340 }
5341 tracing::span::Id::from_u64(1)
5342 }
5343 fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
5344 fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
5345 }
5346 fn event(&self, _event: &tracing::Event<'_>) {}
5347 fn enter(&self, _span: &tracing::span::Id) {}
5348 fn exit(&self, _span: &tracing::span::Id) {}
5349 }
5350
5351 let saw_it = Arc::new(AtomicBool::new(false));
5352 let subscriber = SpanChecker {
5353 saw_inline_render: Arc::clone(&saw_it),
5354 };
5355
5356 let _guard = tracing::subscriber::set_default(subscriber);
5357
5358 let mut output = Vec::new();
5359 {
5360 let mut writer = TerminalWriter::new(
5361 &mut output,
5362 ScreenMode::Inline { ui_height: 5 },
5363 UiAnchor::Bottom,
5364 basic_caps(),
5365 );
5366 writer.set_size(80, 24);
5367
5368 let buffer = Buffer::new(80, 5);
5369 writer.present_ui(&buffer, None, true).unwrap();
5370 }
5371
5372 assert!(
5373 saw_it.load(std::sync::atomic::Ordering::SeqCst),
5374 "present_ui in inline mode must emit an inline.render tracing span"
5375 );
5376 }
5377
5378 #[test]
5379 fn inline_render_no_altscreen_with_scroll_region_strategy() {
5380 let mut output = Vec::new();
5382 {
5383 let mut writer = TerminalWriter::new(
5384 &mut output,
5385 ScreenMode::Inline { ui_height: 5 },
5386 UiAnchor::Bottom,
5387 scroll_region_caps(),
5388 );
5389 writer.set_size(80, 24);
5390
5391 let buffer = Buffer::new(80, 5);
5392 writer.present_ui(&buffer, None, true).unwrap();
5393 writer.present_ui(&buffer, None, true).unwrap();
5394 }
5395
5396 assert!(
5397 !contains_bytes(&output, ALTSCREEN_ENTER),
5398 "scroll region strategy must never emit altscreen enter"
5399 );
5400 }
5401
5402 #[test]
5403 fn inline_render_no_altscreen_with_hybrid_strategy() {
5404 let mut output = Vec::new();
5405 {
5406 let mut writer = TerminalWriter::new(
5407 &mut output,
5408 ScreenMode::Inline { ui_height: 5 },
5409 UiAnchor::Bottom,
5410 hybrid_caps(),
5411 );
5412 writer.set_size(80, 24);
5413
5414 let buffer = Buffer::new(80, 5);
5415 writer.present_ui(&buffer, None, true).unwrap();
5416 }
5417
5418 assert!(
5419 !contains_bytes(&output, ALTSCREEN_ENTER),
5420 "hybrid strategy must never emit altscreen enter"
5421 );
5422 }
5423
5424 #[test]
5425 fn inline_render_no_altscreen_with_mux_strategy() {
5426 let mut output = Vec::new();
5427 {
5428 let mut writer = TerminalWriter::new(
5429 &mut output,
5430 ScreenMode::Inline { ui_height: 5 },
5431 UiAnchor::Bottom,
5432 mux_caps(),
5433 );
5434 writer.set_size(80, 24);
5435
5436 let buffer = Buffer::new(80, 5);
5437 writer.present_ui(&buffer, None, true).unwrap();
5438 }
5439
5440 assert!(
5441 !contains_bytes(&output, ALTSCREEN_ENTER),
5442 "mux (overlay) strategy must never emit altscreen enter"
5443 );
5444 }
5445}