1#![forbid(unsafe_code)]
2
3use std::io::{self, BufWriter, Write};
51use std::time::Instant;
52
53use crate::evidence_sink::EvidenceSink;
54use crate::evidence_telemetry::{DiffDecisionSnapshot, set_diff_snapshot};
55use crate::render_trace::{
56 RenderTraceFrame, RenderTraceRecorder, build_diff_runs_payload, build_full_buffer_payload,
57};
58use ftui_core::inline_mode::InlineStrategy;
59use ftui_core::terminal_capabilities::TerminalCapabilities;
60use ftui_render::buffer::{Buffer, DirtySpanConfig, DirtySpanStats};
61use ftui_render::diff::{BufferDiff, TileDiffConfig, TileDiffFallback, TileDiffStats};
62use ftui_render::diff_strategy::{DiffStrategy, DiffStrategyConfig, DiffStrategySelector};
63use ftui_render::grapheme_pool::GraphemePool;
64use ftui_render::link_registry::LinkRegistry;
65use tracing::{debug_span, info_span, trace};
66
67const BUFFER_CAPACITY: usize = 64 * 1024;
69
70const CURSOR_SAVE: &[u8] = b"\x1b7";
72
73const CURSOR_RESTORE: &[u8] = b"\x1b8";
75
76const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
78
79const SYNC_END: &[u8] = b"\x1b[?2026l";
81
82const ERASE_LINE: &[u8] = b"\x1b[2K";
84
85#[allow(dead_code)] const FULL_REDRAW_PROBE_INTERVAL: u64 = 60;
88
89struct CountingWriter<W: Write> {
91 inner: W,
92 count_enabled: bool,
93 bytes_written: u64,
94}
95
96impl<W: Write> CountingWriter<W> {
97 fn new(inner: W) -> Self {
98 Self {
99 inner,
100 count_enabled: false,
101 bytes_written: 0,
102 }
103 }
104
105 #[allow(dead_code)]
106 fn enable_counting(&mut self) {
107 self.count_enabled = true;
108 self.bytes_written = 0;
109 }
110
111 #[allow(dead_code)]
112 fn disable_counting(&mut self) {
113 self.count_enabled = false;
114 }
115
116 #[allow(dead_code)]
117 fn take_count(&mut self) -> u64 {
118 let count = self.bytes_written;
119 self.bytes_written = 0;
120 count
121 }
122
123 fn into_inner(self) -> W {
124 self.inner
125 }
126}
127
128impl<W: Write> Write for CountingWriter<W> {
129 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
130 let written = self.inner.write(buf)?;
131 if self.count_enabled {
132 self.bytes_written = self.bytes_written.saturating_add(written as u64);
133 }
134 Ok(written)
135 }
136
137 fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
138 self.inner.write_all(buf)?;
139 if self.count_enabled {
140 self.bytes_written = self.bytes_written.saturating_add(buf.len() as u64);
141 }
142 Ok(())
143 }
144
145 fn flush(&mut self) -> io::Result<()> {
146 self.inner.flush()
147 }
148}
149
150fn default_diff_run_id() -> String {
151 format!("diff-{}", std::process::id())
152}
153
154fn diff_strategy_str(strategy: DiffStrategy) -> &'static str {
155 match strategy {
156 DiffStrategy::Full => "full",
157 DiffStrategy::DirtyRows => "dirty",
158 DiffStrategy::FullRedraw => "redraw",
159 }
160}
161
162fn ui_anchor_str(anchor: UiAnchor) -> &'static str {
163 match anchor {
164 UiAnchor::Bottom => "bottom",
165 UiAnchor::Top => "top",
166 }
167}
168
169#[allow(dead_code)]
170#[inline]
171fn json_escape(value: &str) -> String {
172 let mut out = String::with_capacity(value.len());
173 for ch in value.chars() {
174 match ch {
175 '"' => out.push_str("\\\""),
176 '\\' => out.push_str("\\\\"),
177 '\n' => out.push_str("\\n"),
178 '\r' => out.push_str("\\r"),
179 '\t' => out.push_str("\\t"),
180 c if c.is_control() => {
181 use std::fmt::Write as _;
182 let _ = write!(out, "\\u{:04X}", c as u32);
183 }
184 _ => out.push(ch),
185 }
186 }
187 out
188}
189
190#[allow(dead_code)]
191fn estimate_diff_scan_cost(
192 strategy: DiffStrategy,
193 dirty_rows: usize,
194 width: usize,
195 height: usize,
196 span_stats: &DirtySpanStats,
197 tile_stats: Option<TileDiffStats>,
198) -> (usize, &'static str) {
199 match strategy {
200 DiffStrategy::Full => (width.saturating_mul(height), "full_strategy"),
201 DiffStrategy::FullRedraw => (0, "full_redraw"),
202 DiffStrategy::DirtyRows => {
203 if dirty_rows == 0 {
204 return (0, "no_dirty_rows");
205 }
206 if let Some(tile_stats) = tile_stats
207 && tile_stats.fallback.is_none()
208 {
209 return (tile_stats.scan_cells_estimate, "tile_skip");
210 }
211 let span_cells = span_stats.span_coverage_cells;
212 if span_stats.overflows > 0 {
213 let estimate = if span_cells > 0 {
214 span_cells
215 } else {
216 dirty_rows.saturating_mul(width)
217 };
218 return (estimate, "span_overflow");
219 }
220 if span_cells > 0 {
221 (span_cells, "none")
222 } else {
223 (dirty_rows.saturating_mul(width), "no_spans")
224 }
225 }
226 }
227}
228
229fn sanitize_auto_bounds(min_height: u16, max_height: u16) -> (u16, u16) {
230 let min = min_height.max(1);
231 let max = max_height.max(min);
232 (min, max)
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237pub enum ScreenMode {
238 Inline {
240 ui_height: u16,
242 },
243 InlineAuto {
247 min_height: u16,
249 max_height: u16,
251 },
252 #[default]
254 AltScreen,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
259pub enum UiAnchor {
260 #[default]
262 Bottom,
263 Top,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268struct InlineRegion {
269 start: u16,
270 height: u16,
271}
272
273struct DiffDecision {
274 #[allow(dead_code)] strategy: DiffStrategy,
276 has_diff: bool,
277}
278
279#[derive(Debug, Clone, Copy)]
280#[allow(dead_code)]
281struct EmitStats {
282 diff_cells: usize,
283 diff_runs: usize,
284}
285
286#[derive(Debug, Clone, Copy)]
287#[allow(dead_code)]
288struct FrameEmitStats {
289 diff_strategy: DiffStrategy,
290 diff_cells: usize,
291 diff_runs: usize,
292 ui_height: u16,
293}
294
295#[derive(Debug, Clone, Copy)]
296#[allow(dead_code)]
297pub struct PresentTimings {
298 pub diff_us: u64,
299}
300
301#[derive(Debug, Clone)]
330pub struct RuntimeDiffConfig {
331 pub bayesian_enabled: bool,
341
342 pub dirty_rows_enabled: bool,
349
350 pub dirty_span_config: DirtySpanConfig,
354
355 pub tile_diff_config: TileDiffConfig,
359
360 pub reset_on_resize: bool,
367
368 pub reset_on_invalidation: bool,
375
376 pub strategy_config: DiffStrategyConfig,
380}
381
382impl Default for RuntimeDiffConfig {
383 fn default() -> Self {
384 Self {
385 bayesian_enabled: true,
386 dirty_rows_enabled: true,
387 dirty_span_config: DirtySpanConfig::default(),
388 tile_diff_config: TileDiffConfig::default(),
389 reset_on_resize: true,
390 reset_on_invalidation: true,
391 strategy_config: DiffStrategyConfig::default(),
392 }
393 }
394}
395
396impl RuntimeDiffConfig {
397 pub fn new() -> Self {
399 Self::default()
400 }
401
402 pub fn with_bayesian_enabled(mut self, enabled: bool) -> Self {
404 self.bayesian_enabled = enabled;
405 self
406 }
407
408 pub fn with_dirty_rows_enabled(mut self, enabled: bool) -> Self {
410 self.dirty_rows_enabled = enabled;
411 self
412 }
413
414 pub fn with_dirty_spans_enabled(mut self, enabled: bool) -> Self {
416 self.dirty_span_config = self.dirty_span_config.with_enabled(enabled);
417 self
418 }
419
420 pub fn with_dirty_span_config(mut self, config: DirtySpanConfig) -> Self {
422 self.dirty_span_config = config;
423 self
424 }
425
426 pub fn with_tile_skip_enabled(mut self, enabled: bool) -> Self {
428 self.tile_diff_config = self.tile_diff_config.with_enabled(enabled);
429 self
430 }
431
432 pub fn with_tile_diff_config(mut self, config: TileDiffConfig) -> Self {
434 self.tile_diff_config = config;
435 self
436 }
437
438 pub fn with_reset_on_resize(mut self, enabled: bool) -> Self {
440 self.reset_on_resize = enabled;
441 self
442 }
443
444 pub fn with_reset_on_invalidation(mut self, enabled: bool) -> Self {
446 self.reset_on_invalidation = enabled;
447 self
448 }
449
450 pub fn with_strategy_config(mut self, config: DiffStrategyConfig) -> Self {
452 self.strategy_config = config;
453 self
454 }
455}
456
457pub struct TerminalWriter<W: Write> {
462 writer: Option<CountingWriter<BufWriter<W>>>,
464 screen_mode: ScreenMode,
466 auto_ui_height: Option<u16>,
468 ui_anchor: UiAnchor,
470 prev_buffer: Option<Buffer>,
472 spare_buffer: Option<Buffer>,
474 pool: GraphemePool,
476 links: LinkRegistry,
478 capabilities: TerminalCapabilities,
480 term_width: u16,
482 term_height: u16,
484 in_sync_block: bool,
486 cursor_saved: bool,
488 cursor_visible: bool,
490 inline_strategy: InlineStrategy,
492 scroll_region_active: bool,
494 last_inline_region: Option<InlineRegion>,
496 diff_strategy: DiffStrategySelector,
498 diff_scratch: BufferDiff,
500 full_redraw_probe: u64,
502 #[allow(dead_code)] diff_config: RuntimeDiffConfig,
505 evidence_sink: Option<EvidenceSink>,
507 #[allow(dead_code)]
509 diff_evidence_run_id: String,
510 #[allow(dead_code)]
512 diff_evidence_idx: u64,
513 last_diff_strategy: Option<DiffStrategy>,
515 render_trace: Option<RenderTraceRecorder>,
517 timing_enabled: bool,
519 last_present_timings: Option<PresentTimings>,
521}
522
523impl<W: Write> TerminalWriter<W> {
524 pub fn new(
533 writer: W,
534 screen_mode: ScreenMode,
535 ui_anchor: UiAnchor,
536 capabilities: TerminalCapabilities,
537 ) -> Self {
538 Self::with_diff_config(
539 writer,
540 screen_mode,
541 ui_anchor,
542 capabilities,
543 RuntimeDiffConfig::default(),
544 )
545 }
546
547 pub fn with_diff_config(
576 writer: W,
577 screen_mode: ScreenMode,
578 ui_anchor: UiAnchor,
579 capabilities: TerminalCapabilities,
580 diff_config: RuntimeDiffConfig,
581 ) -> Self {
582 let inline_strategy = InlineStrategy::select(&capabilities);
583 let auto_ui_height = None;
584 let diff_strategy = DiffStrategySelector::new(diff_config.strategy_config.clone());
585 let mut diff_scratch = BufferDiff::new();
586 diff_scratch
587 .tile_config_mut()
588 .clone_from(&diff_config.tile_diff_config);
589 Self {
590 writer: Some(CountingWriter::new(BufWriter::with_capacity(
591 BUFFER_CAPACITY,
592 writer,
593 ))),
594 screen_mode,
595 auto_ui_height,
596 ui_anchor,
597 prev_buffer: None,
598 spare_buffer: None,
599 pool: GraphemePool::new(),
600 links: LinkRegistry::new(),
601 capabilities,
602 term_width: 80,
603 term_height: 24,
604 in_sync_block: false,
605 cursor_saved: false,
606 cursor_visible: true,
607 inline_strategy,
608 scroll_region_active: false,
609 last_inline_region: None,
610 diff_strategy,
611 diff_scratch,
612 full_redraw_probe: 0,
613 diff_config,
614 evidence_sink: None,
615 diff_evidence_run_id: default_diff_run_id(),
616 diff_evidence_idx: 0,
617 last_diff_strategy: None,
618 render_trace: None,
619 timing_enabled: false,
620 last_present_timings: None,
621 }
622 }
623
624 #[inline]
630 fn writer(&mut self) -> &mut CountingWriter<BufWriter<W>> {
631 self.writer.as_mut().expect("writer has been consumed")
632 }
633
634 fn reset_diff_strategy(&mut self) {
636 if self.diff_config.reset_on_invalidation {
637 self.diff_strategy.reset();
638 }
639 self.full_redraw_probe = 0;
640 self.last_diff_strategy = None;
641 }
642
643 #[allow(dead_code)] fn reset_diff_on_resize(&mut self) {
646 if self.diff_config.reset_on_resize {
647 self.diff_strategy.reset();
648 }
649 self.full_redraw_probe = 0;
650 self.last_diff_strategy = None;
651 }
652
653 pub fn diff_config(&self) -> &RuntimeDiffConfig {
655 &self.diff_config
656 }
657
658 pub(crate) fn set_timing_enabled(&mut self, enabled: bool) {
660 self.timing_enabled = enabled;
661 if !enabled {
662 self.last_present_timings = None;
663 }
664 }
665
666 pub(crate) fn take_last_present_timings(&mut self) -> Option<PresentTimings> {
668 self.last_present_timings.take()
669 }
670
671 #[must_use]
673 pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
674 self.evidence_sink = Some(sink);
675 self
676 }
677
678 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
680 self.evidence_sink = sink;
681 }
682
683 #[must_use]
685 pub fn with_render_trace(mut self, recorder: RenderTraceRecorder) -> Self {
686 self.render_trace = Some(recorder);
687 self
688 }
689
690 pub fn set_render_trace(&mut self, recorder: Option<RenderTraceRecorder>) {
692 self.render_trace = recorder;
693 }
694
695 pub fn diff_strategy_mut(&mut self) -> &mut DiffStrategySelector {
699 &mut self.diff_strategy
700 }
701
702 pub fn diff_strategy(&self) -> &DiffStrategySelector {
704 &self.diff_strategy
705 }
706
707 pub fn last_diff_strategy(&self) -> Option<DiffStrategy> {
709 self.last_diff_strategy
710 }
711
712 pub fn set_size(&mut self, width: u16, height: u16) {
716 self.term_width = width;
717 self.term_height = height;
718 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. }) {
719 self.auto_ui_height = None;
720 }
721 self.prev_buffer = None;
723 self.spare_buffer = None;
724 self.reset_diff_on_resize();
725 if self.scroll_region_active {
727 let _ = self.deactivate_scroll_region();
728 }
729 }
730
731 pub fn take_render_buffer(&mut self, width: u16, height: u16) -> Buffer {
735 if let Some(mut buffer) = self.spare_buffer.take()
736 && buffer.width() == width
737 && buffer.height() == height
738 {
739 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
740 buffer.reset_for_frame();
741 return buffer;
742 }
743
744 let mut buffer = Buffer::new(width, height);
745 buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
746 buffer
747 }
748
749 pub fn width(&self) -> u16 {
751 self.term_width
752 }
753
754 pub fn height(&self) -> u16 {
756 self.term_height
757 }
758
759 pub fn screen_mode(&self) -> ScreenMode {
761 self.screen_mode
762 }
763
764 pub fn render_height_hint(&self) -> u16 {
769 match self.screen_mode {
770 ScreenMode::Inline { ui_height } => ui_height,
771 ScreenMode::InlineAuto {
772 min_height,
773 max_height,
774 } => {
775 let (min, max) = sanitize_auto_bounds(min_height, max_height);
776 let max = max.min(self.term_height);
777 let min = min.min(max);
778 if let Some(current) = self.auto_ui_height {
779 current.clamp(min, max).min(self.term_height).max(min)
780 } else {
781 max.max(min)
782 }
783 }
784 ScreenMode::AltScreen => self.term_height,
785 }
786 }
787
788 pub fn inline_auto_bounds(&self) -> Option<(u16, u16)> {
790 match self.screen_mode {
791 ScreenMode::InlineAuto {
792 min_height,
793 max_height,
794 } => {
795 let (min, max) = sanitize_auto_bounds(min_height, max_height);
796 Some((min.min(self.term_height), max.min(self.term_height)))
797 }
798 _ => None,
799 }
800 }
801
802 pub fn auto_ui_height(&self) -> Option<u16> {
804 match self.screen_mode {
805 ScreenMode::InlineAuto { .. } => self.auto_ui_height,
806 _ => None,
807 }
808 }
809
810 pub fn set_auto_ui_height(&mut self, height: u16) {
812 if let ScreenMode::InlineAuto {
813 min_height,
814 max_height,
815 } = self.screen_mode
816 {
817 let (min, max) = sanitize_auto_bounds(min_height, max_height);
818 let max = max.min(self.term_height);
819 let min = min.min(max);
820 let clamped = height.clamp(min, max);
821 let previous_effective = self.auto_ui_height.unwrap_or(min);
822 if self.auto_ui_height != Some(clamped) {
823 self.auto_ui_height = Some(clamped);
824 if clamped != previous_effective {
825 self.prev_buffer = None;
826 self.reset_diff_strategy();
827 if self.scroll_region_active {
828 let _ = self.deactivate_scroll_region();
829 }
830 }
831 }
832 }
833 }
834
835 pub fn clear_auto_ui_height(&mut self) {
837 if matches!(self.screen_mode, ScreenMode::InlineAuto { .. })
838 && self.auto_ui_height.is_some()
839 {
840 self.auto_ui_height = None;
841 self.prev_buffer = None;
842 self.reset_diff_strategy();
843 if self.scroll_region_active {
844 let _ = self.deactivate_scroll_region();
845 }
846 }
847 }
848
849 fn effective_ui_height(&self) -> u16 {
850 match self.screen_mode {
851 ScreenMode::Inline { ui_height } => ui_height,
852 ScreenMode::InlineAuto {
853 min_height,
854 max_height,
855 } => {
856 let (min, max) = sanitize_auto_bounds(min_height, max_height);
857 let current = self.auto_ui_height.unwrap_or(min);
858 current.clamp(min, max).min(self.term_height)
859 }
860 ScreenMode::AltScreen => self.term_height,
861 }
862 }
863
864 pub fn ui_height(&self) -> u16 {
866 self.effective_ui_height()
867 }
868
869 fn ui_start_row(&self) -> u16 {
871 let ui_height = self.effective_ui_height().min(self.term_height);
872 match (self.screen_mode, self.ui_anchor) {
873 (ScreenMode::Inline { .. }, UiAnchor::Bottom)
874 | (ScreenMode::InlineAuto { .. }, UiAnchor::Bottom) => {
875 self.term_height.saturating_sub(ui_height)
876 }
877 (ScreenMode::Inline { .. }, UiAnchor::Top)
878 | (ScreenMode::InlineAuto { .. }, UiAnchor::Top) => 0,
879 (ScreenMode::AltScreen, _) => 0,
880 }
881 }
882
883 pub fn inline_strategy(&self) -> InlineStrategy {
885 self.inline_strategy
886 }
887
888 pub fn scroll_region_active(&self) -> bool {
890 self.scroll_region_active
891 }
892
893 fn activate_scroll_region(&mut self, ui_height: u16) -> io::Result<()> {
901 if self.scroll_region_active {
902 return Ok(());
903 }
904
905 let ui_height = ui_height.min(self.term_height);
906 if ui_height >= self.term_height {
907 return Ok(());
908 }
909
910 match self.ui_anchor {
911 UiAnchor::Bottom => {
912 let term_height = self.term_height;
913 let log_bottom = term_height.saturating_sub(ui_height);
914 if log_bottom > 0 {
915 write!(self.writer(), "\x1b[1;{}r", log_bottom)?;
917 self.scroll_region_active = true;
918 }
919 }
920 UiAnchor::Top => {
921 let term_height = self.term_height;
922 let log_top = ui_height.saturating_add(1);
923 if log_top <= term_height {
924 write!(self.writer(), "\x1b[{};{}r", log_top, term_height)?;
926 self.scroll_region_active = true;
927 write!(self.writer(), "\x1b[{};1H", log_top)?;
930 }
931 }
932 }
933 Ok(())
934 }
935
936 fn deactivate_scroll_region(&mut self) -> io::Result<()> {
938 if self.scroll_region_active {
939 self.writer().write_all(b"\x1b[r")?;
940 self.scroll_region_active = false;
941 }
942 Ok(())
943 }
944
945 fn clear_rows(&mut self, start_row: u16, height: u16) -> io::Result<()> {
946 let start_row = start_row.min(self.term_height);
947 let end_row = start_row.saturating_add(height).min(self.term_height);
948 for row in start_row..end_row {
949 write!(self.writer(), "\x1b[{};1H", row.saturating_add(1))?;
950 self.writer().write_all(ERASE_LINE)?;
951 }
952 Ok(())
953 }
954
955 fn clear_inline_region_diff(&mut self, current: InlineRegion) -> io::Result<()> {
956 let Some(previous) = self.last_inline_region else {
957 return Ok(());
958 };
959
960 let prev_start = previous.start.min(self.term_height);
961 let prev_end = previous
962 .start
963 .saturating_add(previous.height)
964 .min(self.term_height);
965 if prev_start >= prev_end {
966 return Ok(());
967 }
968
969 let curr_start = current.start.min(self.term_height);
970 let curr_end = current
971 .start
972 .saturating_add(current.height)
973 .min(self.term_height);
974
975 if curr_start > prev_start {
976 let clear_end = curr_start.min(prev_end);
977 if clear_end > prev_start {
978 self.clear_rows(prev_start, clear_end - prev_start)?;
979 }
980 }
981
982 if curr_end < prev_end {
983 let clear_start = curr_end.max(prev_start);
984 if prev_end > clear_start {
985 self.clear_rows(clear_start, prev_end - clear_start)?;
986 }
987 }
988
989 Ok(())
990 }
991
992 pub fn present_ui(
1006 &mut self,
1007 buffer: &Buffer,
1008 cursor: Option<(u16, u16)>,
1009 cursor_visible: bool,
1010 ) -> io::Result<()> {
1011 let mode_str = match self.screen_mode {
1012 ScreenMode::Inline { .. } => "inline",
1013 ScreenMode::InlineAuto { .. } => "inline_auto",
1014 ScreenMode::AltScreen => "altscreen",
1015 };
1016 let trace_enabled = self.render_trace.is_some();
1017 if trace_enabled {
1018 self.writer().enable_counting();
1019 }
1020 let present_start = if trace_enabled {
1021 Some(std::time::Instant::now())
1022 } else {
1023 None
1024 };
1025 let _span = info_span!(
1026 "ftui.render.present",
1027 mode = mode_str,
1028 width = buffer.width(),
1029 height = buffer.height(),
1030 )
1031 .entered();
1032
1033 let result = match self.screen_mode {
1034 ScreenMode::Inline { ui_height } => {
1035 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1036 }
1037 ScreenMode::InlineAuto { .. } => {
1038 let ui_height = self.effective_ui_height();
1039 self.present_inline(buffer, ui_height, cursor, cursor_visible)
1040 }
1041 ScreenMode::AltScreen => self.present_altscreen(buffer, cursor, cursor_visible),
1042 };
1043
1044 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1045 let present_bytes = if trace_enabled {
1046 Some(self.writer().take_count())
1047 } else {
1048 None
1049 };
1050 if trace_enabled {
1051 self.writer().disable_counting();
1052 }
1053
1054 if let Ok(stats) = result {
1055 self.spare_buffer = self.prev_buffer.take();
1056 self.prev_buffer = Some(buffer.clone());
1057
1058 if let Some(ref mut trace) = self.render_trace {
1059 let payload_info = match stats.diff_strategy {
1060 DiffStrategy::FullRedraw => {
1061 let payload = build_full_buffer_payload(buffer, &self.pool);
1062 trace.write_payload(&payload).ok()
1063 }
1064 _ => {
1065 let payload =
1066 build_diff_runs_payload(buffer, &self.diff_scratch, &self.pool);
1067 trace.write_payload(&payload).ok()
1068 }
1069 };
1070 let (payload_kind, payload_path) = match payload_info {
1071 Some(info) => (info.kind, Some(info.path)),
1072 None => ("none", None),
1073 };
1074 let payload_path_ref = payload_path.as_deref();
1075 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1076 let ui_anchor = ui_anchor_str(self.ui_anchor);
1077 let frame = RenderTraceFrame {
1078 cols: buffer.width(),
1079 rows: buffer.height(),
1080 mode: mode_str,
1081 ui_height: stats.ui_height,
1082 ui_anchor,
1083 diff_strategy,
1084 diff_cells: stats.diff_cells,
1085 diff_runs: stats.diff_runs,
1086 present_bytes: present_bytes.unwrap_or(0),
1087 render_us: None,
1088 present_us,
1089 payload_kind,
1090 payload_path: payload_path_ref,
1091 trace_us: None,
1092 };
1093 let _ = trace.record_frame(frame, buffer, &self.pool);
1094 }
1095 return Ok(());
1096 }
1097
1098 result.map(|_| ())
1099 }
1100
1101 pub fn present_ui_owned(
1106 &mut self,
1107 buffer: Buffer,
1108 cursor: Option<(u16, u16)>,
1109 cursor_visible: bool,
1110 ) -> io::Result<()> {
1111 let mode_str = match self.screen_mode {
1112 ScreenMode::Inline { .. } => "inline",
1113 ScreenMode::InlineAuto { .. } => "inline_auto",
1114 ScreenMode::AltScreen => "altscreen",
1115 };
1116 let trace_enabled = self.render_trace.is_some();
1117 if trace_enabled {
1118 self.writer().enable_counting();
1119 }
1120 let present_start = if trace_enabled {
1121 Some(std::time::Instant::now())
1122 } else {
1123 None
1124 };
1125 let _span = info_span!(
1126 "ftui.render.present",
1127 mode = mode_str,
1128 width = buffer.width(),
1129 height = buffer.height(),
1130 )
1131 .entered();
1132
1133 let result = match self.screen_mode {
1134 ScreenMode::Inline { ui_height } => {
1135 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1136 }
1137 ScreenMode::InlineAuto { .. } => {
1138 let ui_height = self.effective_ui_height();
1139 self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1140 }
1141 ScreenMode::AltScreen => self.present_altscreen(&buffer, cursor, cursor_visible),
1142 };
1143
1144 let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1145 let present_bytes = if trace_enabled {
1146 Some(self.writer().take_count())
1147 } else {
1148 None
1149 };
1150 if trace_enabled {
1151 self.writer().disable_counting();
1152 }
1153
1154 if let Ok(stats) = result {
1155 if let Some(ref mut trace) = self.render_trace {
1156 let payload_info = match stats.diff_strategy {
1157 DiffStrategy::FullRedraw => {
1158 let payload = build_full_buffer_payload(&buffer, &self.pool);
1159 trace.write_payload(&payload).ok()
1160 }
1161 _ => {
1162 let payload =
1163 build_diff_runs_payload(&buffer, &self.diff_scratch, &self.pool);
1164 trace.write_payload(&payload).ok()
1165 }
1166 };
1167 let (payload_kind, payload_path) = match payload_info {
1168 Some(info) => (info.kind, Some(info.path)),
1169 None => ("none", None),
1170 };
1171 let payload_path_ref = payload_path.as_deref();
1172 let diff_strategy = diff_strategy_str(stats.diff_strategy);
1173 let ui_anchor = ui_anchor_str(self.ui_anchor);
1174 let frame = RenderTraceFrame {
1175 cols: buffer.width(),
1176 rows: buffer.height(),
1177 mode: mode_str,
1178 ui_height: stats.ui_height,
1179 ui_anchor,
1180 diff_strategy,
1181 diff_cells: stats.diff_cells,
1182 diff_runs: stats.diff_runs,
1183 present_bytes: present_bytes.unwrap_or(0),
1184 render_us: None,
1185 present_us,
1186 payload_kind,
1187 payload_path: payload_path_ref,
1188 trace_us: None,
1189 };
1190 let _ = trace.record_frame(frame, &buffer, &self.pool);
1191 }
1192
1193 self.spare_buffer = self.prev_buffer.take();
1194 self.prev_buffer = Some(buffer);
1195 return Ok(());
1196 }
1197
1198 result.map(|_| ())
1199 }
1200
1201 fn decide_diff(&mut self, buffer: &Buffer) -> DiffDecision {
1202 let prev_dims = self
1203 .prev_buffer
1204 .as_ref()
1205 .map(|prev| (prev.width(), prev.height()));
1206 if prev_dims.is_none() || prev_dims != Some((buffer.width(), buffer.height())) {
1207 self.full_redraw_probe = 0;
1208 self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1209 return DiffDecision {
1210 strategy: DiffStrategy::FullRedraw,
1211 has_diff: false,
1212 };
1213 }
1214
1215 let dirty_rows = buffer.dirty_row_count();
1216 let width = buffer.width() as usize;
1217 let height = buffer.height() as usize;
1218 let mut span_stats_snapshot: Option<DirtySpanStats> = None;
1219 let mut dirty_scan_cells_estimate = dirty_rows.saturating_mul(width);
1220
1221 if self.diff_config.bayesian_enabled {
1222 let span_stats = buffer.dirty_span_stats();
1223 if span_stats.span_coverage_cells > 0 {
1224 dirty_scan_cells_estimate = span_stats.span_coverage_cells;
1225 }
1226 span_stats_snapshot = Some(span_stats);
1227 }
1228
1229 let mut strategy = if self.diff_config.bayesian_enabled {
1231 self.diff_strategy.select_with_scan_estimate(
1233 buffer.width(),
1234 buffer.height(),
1235 dirty_rows,
1236 dirty_scan_cells_estimate,
1237 )
1238 } else {
1239 if self.diff_config.dirty_rows_enabled && dirty_rows < buffer.height() as usize {
1241 DiffStrategy::DirtyRows
1242 } else {
1243 DiffStrategy::Full
1244 }
1245 };
1246
1247 if !self.diff_config.dirty_rows_enabled && strategy == DiffStrategy::DirtyRows {
1249 strategy = DiffStrategy::Full;
1250 if self.diff_config.bayesian_enabled {
1251 self.diff_strategy
1252 .override_last_strategy(strategy, "dirty_rows_disabled");
1253 }
1254 }
1255
1256 if strategy == DiffStrategy::FullRedraw {
1258 if self.full_redraw_probe >= FULL_REDRAW_PROBE_INTERVAL {
1259 self.full_redraw_probe = 0;
1260 let probed = if self.diff_config.dirty_rows_enabled
1261 && dirty_rows < buffer.height() as usize
1262 {
1263 DiffStrategy::DirtyRows
1264 } else {
1265 DiffStrategy::Full
1266 };
1267 if probed != strategy {
1268 strategy = probed;
1269 if self.diff_config.bayesian_enabled {
1270 self.diff_strategy
1271 .override_last_strategy(strategy, "full_redraw_probe");
1272 }
1273 }
1274 } else {
1275 self.full_redraw_probe = self.full_redraw_probe.saturating_add(1);
1276 }
1277 } else {
1278 self.full_redraw_probe = 0;
1279 }
1280
1281 let mut has_diff = false;
1282 match strategy {
1283 DiffStrategy::Full => {
1284 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1285 self.diff_scratch.compute_into(prev, buffer);
1286 has_diff = true;
1287 }
1288 DiffStrategy::DirtyRows => {
1289 let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1290 self.diff_scratch.compute_dirty_into(prev, buffer);
1291 has_diff = true;
1292 }
1293 DiffStrategy::FullRedraw => {}
1294 }
1295
1296 let mut scan_cost_estimate = 0usize;
1297 let mut fallback_reason: &'static str = "none";
1298 let tile_stats = if strategy == DiffStrategy::DirtyRows {
1299 self.diff_scratch.last_tile_stats()
1300 } else {
1301 None
1302 };
1303
1304 if self.diff_config.bayesian_enabled && has_diff {
1306 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1307 let (scan_cost, reason) = estimate_diff_scan_cost(
1308 strategy,
1309 dirty_rows,
1310 width,
1311 height,
1312 &span_stats,
1313 tile_stats,
1314 );
1315 let scanned_cells = scan_cost.max(self.diff_scratch.len());
1316 self.diff_strategy
1317 .observe(scanned_cells, self.diff_scratch.len());
1318 span_stats_snapshot = Some(span_stats);
1319 scan_cost_estimate = scan_cost;
1320 fallback_reason = reason;
1321 }
1322
1323 if let Some(evidence) = self.diff_strategy.last_evidence() {
1324 let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1325 let (scan_cost, reason) = if span_stats_snapshot.is_some() {
1326 (scan_cost_estimate, fallback_reason)
1327 } else {
1328 estimate_diff_scan_cost(
1329 strategy,
1330 dirty_rows,
1331 width,
1332 height,
1333 &span_stats,
1334 tile_stats,
1335 )
1336 };
1337 let span_coverage_pct = if evidence.total_cells == 0 {
1338 0.0
1339 } else {
1340 (span_stats.span_coverage_cells as f64 / evidence.total_cells as f64) * 100.0
1341 };
1342 let span_count = span_stats.total_spans;
1343 let max_span_len = span_stats.max_span_len;
1344 let event_idx = self.diff_evidence_idx;
1345 self.diff_evidence_idx = self.diff_evidence_idx.saturating_add(1);
1346 let tile_used = tile_stats.is_some_and(|stats| stats.fallback.is_none());
1347 let tile_fallback = tile_stats
1348 .and_then(|stats| stats.fallback)
1349 .map(TileDiffFallback::as_str)
1350 .unwrap_or("none");
1351 let run_id = json_escape(&self.diff_evidence_run_id);
1352 let strategy_json = json_escape(&strategy.to_string());
1353 let guard_reason_json = json_escape(evidence.guard_reason);
1354 let fallback_reason_json = json_escape(reason);
1355 let tile_fallback_json = json_escape(tile_fallback);
1356 let schema_version = crate::evidence_sink::EVIDENCE_SCHEMA_VERSION;
1357 let screen_mode = match self.screen_mode {
1358 ScreenMode::Inline { .. } => "inline",
1359 ScreenMode::InlineAuto { .. } => "inline_auto",
1360 ScreenMode::AltScreen => "altscreen",
1361 };
1362 let (
1363 tile_w,
1364 tile_h,
1365 tiles_x,
1366 tiles_y,
1367 dirty_tiles,
1368 dirty_cells,
1369 dirty_tile_ratio,
1370 dirty_cell_ratio,
1371 scanned_tiles,
1372 skipped_tiles,
1373 scan_cells_estimate,
1374 sat_build_cells,
1375 ) = if let Some(stats) = tile_stats {
1376 (
1377 stats.tile_w,
1378 stats.tile_h,
1379 stats.tiles_x,
1380 stats.tiles_y,
1381 stats.dirty_tiles,
1382 stats.dirty_cells,
1383 stats.dirty_tile_ratio,
1384 stats.dirty_cell_ratio,
1385 stats.scanned_tiles,
1386 stats.skipped_tiles,
1387 stats.scan_cells_estimate,
1388 stats.sat_build_cells,
1389 )
1390 } else {
1391 (0, 0, 0, 0, 0, 0, 0.0, 0.0, 0, 0, 0, 0)
1392 };
1393 let tile_size = tile_w as usize * tile_h as usize;
1394 let dirty_tile_count = dirty_tiles;
1395 let skipped_tile_count = skipped_tiles;
1396 let sat_build_cost_est = sat_build_cells;
1397
1398 set_diff_snapshot(Some(DiffDecisionSnapshot {
1399 event_idx,
1400 screen_mode: screen_mode.to_string(),
1401 cols: u16::try_from(width).unwrap_or(u16::MAX),
1402 rows: u16::try_from(height).unwrap_or(u16::MAX),
1403 evidence: evidence.clone(),
1404 span_count,
1405 span_coverage_pct,
1406 max_span_len,
1407 scan_cost_estimate: scan_cost,
1408 fallback_reason: reason.to_string(),
1409 tile_used,
1410 tile_fallback: tile_fallback.to_string(),
1411 strategy_used: strategy,
1412 }));
1413
1414 trace!(
1415 strategy = %strategy,
1416 selected = %evidence.strategy,
1417 cost_full = evidence.cost_full,
1418 cost_dirty = evidence.cost_dirty,
1419 cost_redraw = evidence.cost_redraw,
1420 dirty_rows = evidence.dirty_rows,
1421 total_rows = evidence.total_rows,
1422 total_cells = evidence.total_cells,
1423 bayesian_enabled = self.diff_config.bayesian_enabled,
1424 dirty_rows_enabled = self.diff_config.dirty_rows_enabled,
1425 "diff strategy selected"
1426 );
1427 if let Some(ref sink) = self.evidence_sink {
1428 let line = format!(
1429 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":{}}}"#,
1430 schema_version,
1431 run_id,
1432 event_idx,
1433 screen_mode,
1434 width,
1435 height,
1436 strategy_json,
1437 evidence.cost_full,
1438 evidence.cost_dirty,
1439 evidence.cost_redraw,
1440 evidence.posterior_mean,
1441 evidence.posterior_variance,
1442 evidence.alpha,
1443 evidence.beta,
1444 guard_reason_json,
1445 evidence.hysteresis_applied,
1446 evidence.hysteresis_ratio,
1447 evidence.dirty_rows,
1448 evidence.total_rows,
1449 evidence.total_cells,
1450 span_count,
1451 span_coverage_pct,
1452 max_span_len,
1453 fallback_reason_json,
1454 scan_cost,
1455 tile_used,
1456 tile_fallback_json,
1457 tile_w,
1458 tile_h,
1459 tile_size,
1460 tiles_x,
1461 tiles_y,
1462 dirty_tiles,
1463 dirty_tile_count,
1464 dirty_cells,
1465 dirty_tile_ratio,
1466 dirty_cell_ratio,
1467 scanned_tiles,
1468 skipped_tiles,
1469 skipped_tile_count,
1470 scan_cells_estimate,
1471 sat_build_cost_est,
1472 self.diff_config.bayesian_enabled,
1473 self.diff_config.dirty_rows_enabled,
1474 );
1475 let _ = sink.write_jsonl(&line);
1476 }
1477 }
1478
1479 self.last_diff_strategy = Some(strategy);
1480 DiffDecision { strategy, has_diff }
1481 }
1482
1483 fn present_inline(
1489 &mut self,
1490 buffer: &Buffer,
1491 ui_height: u16,
1492 cursor: Option<(u16, u16)>,
1493 cursor_visible: bool,
1494 ) -> io::Result<FrameEmitStats> {
1495 let visible_height = ui_height.min(self.term_height);
1496 let ui_y_start = self.ui_start_row();
1497 let current_region = InlineRegion {
1498 start: ui_y_start,
1499 height: visible_height,
1500 };
1501
1502 {
1504 let _span = debug_span!("ftui.render.scroll_region").entered();
1505 if visible_height > 0 {
1506 match self.inline_strategy {
1507 InlineStrategy::ScrollRegion => {
1508 self.activate_scroll_region(visible_height)?;
1509 }
1510 InlineStrategy::Hybrid => {
1511 self.activate_scroll_region(visible_height)?;
1512 }
1513 InlineStrategy::OverlayRedraw => {}
1514 }
1515 } else if self.scroll_region_active {
1516 self.deactivate_scroll_region()?;
1517 }
1518 }
1519
1520 if self.capabilities.sync_output && !self.in_sync_block {
1522 self.writer().write_all(SYNC_BEGIN)?;
1523 self.in_sync_block = true;
1524 }
1525
1526 self.writer().write_all(CURSOR_SAVE)?;
1528 self.cursor_saved = true;
1529
1530 self.clear_inline_region_diff(current_region)?;
1531
1532 let mut diff_strategy = DiffStrategy::FullRedraw;
1533 let mut diff_us = 0u64;
1534 let mut emit_stats = EmitStats {
1535 diff_cells: 0,
1536 diff_runs: 0,
1537 };
1538
1539 if visible_height > 0 {
1540 if self.prev_buffer.is_none() {
1543 self.clear_rows(ui_y_start, visible_height)?;
1544 } else {
1545 let buf_height = buffer.height().min(visible_height);
1548 if buf_height < visible_height {
1549 let clear_start = ui_y_start.saturating_add(buf_height);
1550 let clear_height = visible_height.saturating_sub(buf_height);
1551 self.clear_rows(clear_start, clear_height)?;
1552 }
1553 }
1554
1555 let diff_start = if self.timing_enabled {
1557 Some(Instant::now())
1558 } else {
1559 None
1560 };
1561 let decision = {
1562 let _span = debug_span!("ftui.render.diff_compute").entered();
1563 self.decide_diff(buffer)
1564 };
1565 if let Some(start) = diff_start {
1566 diff_us = start.elapsed().as_micros() as u64;
1567 }
1568 diff_strategy = decision.strategy;
1569
1570 {
1572 let _span = debug_span!("ftui.render.emit").entered();
1573 if decision.has_diff {
1574 let diff = std::mem::take(&mut self.diff_scratch);
1575 let result = self.emit_diff(buffer, &diff, Some(visible_height), ui_y_start);
1576 self.diff_scratch = diff;
1577 emit_stats = result?;
1578 } else {
1579 emit_stats = self.emit_full_redraw(buffer, Some(visible_height), ui_y_start)?;
1580 }
1581 }
1582 }
1583
1584 self.writer().write_all(b"\x1b[0m")?;
1586
1587 self.writer().write_all(CURSOR_RESTORE)?;
1589 self.cursor_saved = false;
1590
1591 if cursor_visible {
1592 if let Some((cx, cy)) = cursor
1594 && cy < visible_height
1595 {
1596 let abs_y = ui_y_start.saturating_add(cy);
1598 write!(
1599 self.writer(),
1600 "\x1b[{};{}H",
1601 abs_y.saturating_add(1),
1602 cx.saturating_add(1)
1603 )?;
1604 }
1605 self.set_cursor_visibility(true)?;
1606 } else {
1607 self.set_cursor_visibility(false)?;
1608 }
1609
1610 if self.in_sync_block {
1612 self.writer().write_all(SYNC_END)?;
1613 self.in_sync_block = false;
1614 }
1615
1616 self.writer().flush()?;
1617 self.last_inline_region = if visible_height > 0 {
1618 Some(current_region)
1619 } else {
1620 None
1621 };
1622
1623 if self.timing_enabled {
1624 self.last_present_timings = Some(PresentTimings { diff_us });
1625 }
1626
1627 Ok(FrameEmitStats {
1628 diff_strategy,
1629 diff_cells: emit_stats.diff_cells,
1630 diff_runs: emit_stats.diff_runs,
1631 ui_height: visible_height,
1632 })
1633 }
1634
1635 fn present_altscreen(
1637 &mut self,
1638 buffer: &Buffer,
1639 cursor: Option<(u16, u16)>,
1640 cursor_visible: bool,
1641 ) -> io::Result<FrameEmitStats> {
1642 let diff_start = if self.timing_enabled {
1643 Some(Instant::now())
1644 } else {
1645 None
1646 };
1647 let decision = {
1648 let _span = debug_span!("ftui.render.diff_compute").entered();
1649 self.decide_diff(buffer)
1650 };
1651 let diff_us = diff_start
1652 .map(|start| start.elapsed().as_micros() as u64)
1653 .unwrap_or(0);
1654
1655 if self.capabilities.sync_output {
1657 self.writer().write_all(SYNC_BEGIN)?;
1658 }
1659
1660 let emit_stats = {
1661 let _span = debug_span!("ftui.render.emit").entered();
1662 if decision.has_diff {
1663 let diff = std::mem::take(&mut self.diff_scratch);
1664 let result = self.emit_diff(buffer, &diff, None, 0);
1665 self.diff_scratch = diff;
1666 result?
1667 } else {
1668 self.emit_full_redraw(buffer, None, 0)?
1669 }
1670 };
1671
1672 self.writer().write_all(b"\x1b[0m")?;
1674
1675 if cursor_visible {
1676 if let Some((cx, cy)) = cursor {
1678 write!(
1679 self.writer(),
1680 "\x1b[{};{}H",
1681 cy.saturating_add(1),
1682 cx.saturating_add(1)
1683 )?;
1684 }
1685 self.set_cursor_visibility(true)?;
1686 } else {
1687 self.set_cursor_visibility(false)?;
1688 }
1689
1690 if self.capabilities.sync_output {
1691 self.writer().write_all(SYNC_END)?;
1692 }
1693
1694 self.writer().flush()?;
1695
1696 if self.timing_enabled {
1697 self.last_present_timings = Some(PresentTimings { diff_us });
1698 }
1699
1700 Ok(FrameEmitStats {
1701 diff_strategy: decision.strategy,
1702 diff_cells: emit_stats.diff_cells,
1703 diff_runs: emit_stats.diff_runs,
1704 ui_height: 0,
1705 })
1706 }
1707
1708 fn emit_diff(
1710 &mut self,
1711 buffer: &Buffer,
1712 diff: &BufferDiff,
1713 max_height: Option<u16>,
1714 ui_y_start: u16,
1715 ) -> io::Result<EmitStats> {
1716 use ftui_render::cell::{Cell, CellAttrs, StyleFlags};
1717
1718 let runs = diff.runs();
1719 let diff_runs = runs.len();
1720 let diff_cells = diff.len();
1721 let _span = debug_span!("ftui.render.emit_diff", run_count = runs.len()).entered();
1722
1723 let mut current_style: Option<(
1724 ftui_render::cell::PackedRgba,
1725 ftui_render::cell::PackedRgba,
1726 StyleFlags,
1727 )> = None;
1728 let mut current_link: Option<u32> = None;
1729 let default_cell = Cell::default();
1730
1731 let writer = self.writer.as_mut().expect("writer has been consumed");
1733
1734 for run in runs {
1735 if let Some(limit) = max_height
1736 && run.y >= limit
1737 {
1738 continue;
1739 }
1740 write!(
1742 writer,
1743 "\x1b[{};{}H",
1744 ui_y_start.saturating_add(run.y).saturating_add(1),
1745 run.x0.saturating_add(1)
1746 )?;
1747
1748 let mut cursor_x = run.x0;
1750 for x in run.x0..=run.x1 {
1751 let cell = buffer.get_unchecked(x, run.y);
1752
1753 let is_orphan = cell.is_continuation() && cursor_x <= x;
1755 if cell.is_continuation() && !is_orphan {
1756 continue;
1757 }
1758 let effective_cell = if is_orphan { &default_cell } else { cell };
1759
1760 let cell_style = (
1762 effective_cell.fg,
1763 effective_cell.bg,
1764 effective_cell.attrs.flags(),
1765 );
1766 if current_style != Some(cell_style) {
1767 writer.write_all(b"\x1b[0m")?;
1769
1770 if !cell_style.2.is_empty() {
1772 Self::emit_style_flags(writer, cell_style.2)?;
1773 }
1774
1775 if cell_style.0.a() > 0 {
1777 write!(
1778 writer,
1779 "\x1b[38;2;{};{};{}m",
1780 cell_style.0.r(),
1781 cell_style.0.g(),
1782 cell_style.0.b()
1783 )?;
1784 }
1785 if cell_style.1.a() > 0 {
1786 write!(
1787 writer,
1788 "\x1b[48;2;{};{};{}m",
1789 cell_style.1.r(),
1790 cell_style.1.g(),
1791 cell_style.1.b()
1792 )?;
1793 }
1794
1795 current_style = Some(cell_style);
1796 }
1797
1798 let raw_link_id = effective_cell.attrs.link_id();
1800 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1801 None
1802 } else {
1803 Some(raw_link_id)
1804 };
1805
1806 if current_link != new_link {
1807 if current_link.is_some() {
1809 writer.write_all(b"\x1b]8;;\x1b\\")?;
1810 }
1811 let actually_opened = if let Some(link_id) = new_link
1813 && let Some(url) = self.links.get(link_id)
1814 {
1815 write!(writer, "\x1b]8;;{}\x1b\\", url)?;
1816 true
1817 } else {
1818 false
1819 };
1820 current_link = if actually_opened { new_link } else { None };
1821 }
1822
1823 let raw_width = effective_cell.content.width();
1824 let is_zero_width_content = raw_width == 0
1825 && !effective_cell.is_empty()
1826 && !effective_cell.is_continuation();
1827
1828 if is_zero_width_content {
1830 writer.write_all(b"\xEF\xBF\xBD")?;
1831 } else if let Some(ch) = effective_cell.content.as_char() {
1832 let mut buf = [0u8; 4];
1833 let encoded = ch.encode_utf8(&mut buf);
1834 writer.write_all(encoded.as_bytes())?;
1835 } else if let Some(gid) = effective_cell.content.grapheme_id() {
1836 if let Some(text) = self.pool.get(gid) {
1838 writer.write_all(text.as_bytes())?;
1839 } else {
1840 for _ in 0..raw_width.max(1) {
1842 writer.write_all(b"?")?;
1843 }
1844 }
1845 } else {
1846 writer.write_all(b" ")?;
1847 }
1848
1849 let advance = if effective_cell.is_empty() || is_zero_width_content {
1850 1
1851 } else {
1852 raw_width.max(1)
1853 };
1854 cursor_x = cursor_x.saturating_add(advance as u16);
1855 }
1856 }
1857
1858 writer.write_all(b"\x1b[0m")?;
1860
1861 if current_link.is_some() {
1863 writer.write_all(b"\x1b]8;;\x1b\\")?;
1864 }
1865
1866 trace!("emit_diff complete");
1867 Ok(EmitStats {
1868 diff_cells,
1869 diff_runs,
1870 })
1871 }
1872
1873 fn emit_full_redraw(
1875 &mut self,
1876 buffer: &Buffer,
1877 max_height: Option<u16>,
1878 ui_y_start: u16,
1879 ) -> io::Result<EmitStats> {
1880 use ftui_render::cell::{Cell, CellAttrs, StyleFlags};
1881
1882 let height = max_height.unwrap_or(buffer.height()).min(buffer.height());
1883 let width = buffer.width();
1884 let diff_cells = width as usize * height as usize;
1885 let diff_runs = height as usize;
1886
1887 let _span = debug_span!("ftui.render.emit_full_redraw").entered();
1888
1889 let mut current_style: Option<(
1890 ftui_render::cell::PackedRgba,
1891 ftui_render::cell::PackedRgba,
1892 StyleFlags,
1893 )> = None;
1894 let mut current_link: Option<u32> = None;
1895 let default_cell = Cell::default();
1896
1897 let writer = self.writer.as_mut().expect("writer has been consumed");
1899
1900 for y in 0..height {
1901 write!(
1902 writer,
1903 "\x1b[{};{}H",
1904 ui_y_start.saturating_add(y).saturating_add(1),
1905 1
1906 )?;
1907
1908 let mut cursor_x = 0u16;
1909 for x in 0..width {
1910 let cell = buffer.get_unchecked(x, y);
1911
1912 let is_orphan = cell.is_continuation() && cursor_x <= x;
1914 if cell.is_continuation() && !is_orphan {
1915 continue;
1916 }
1917 let effective_cell = if is_orphan { &default_cell } else { cell };
1918
1919 let cell_style = (
1921 effective_cell.fg,
1922 effective_cell.bg,
1923 effective_cell.attrs.flags(),
1924 );
1925 if current_style != Some(cell_style) {
1926 writer.write_all(b"\x1b[0m")?;
1928
1929 if !cell_style.2.is_empty() {
1931 Self::emit_style_flags(writer, cell_style.2)?;
1932 }
1933
1934 if cell_style.0.a() > 0 {
1936 write!(
1937 writer,
1938 "\x1b[38;2;{};{};{}m",
1939 cell_style.0.r(),
1940 cell_style.0.g(),
1941 cell_style.0.b()
1942 )?;
1943 }
1944 if cell_style.1.a() > 0 {
1945 write!(
1946 writer,
1947 "\x1b[48;2;{};{};{}m",
1948 cell_style.1.r(),
1949 cell_style.1.g(),
1950 cell_style.1.b()
1951 )?;
1952 }
1953
1954 current_style = Some(cell_style);
1955 }
1956
1957 let raw_link_id = effective_cell.attrs.link_id();
1959 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1960 None
1961 } else {
1962 Some(raw_link_id)
1963 };
1964
1965 if current_link != new_link {
1966 if current_link.is_some() {
1968 writer.write_all(b"\x1b]8;;\x1b\\")?;
1969 }
1970 let actually_opened = if let Some(link_id) = new_link
1972 && let Some(url) = self.links.get(link_id)
1973 {
1974 write!(writer, "\x1b]8;;{}\x1b\\", url)?;
1975 true
1976 } else {
1977 false
1978 };
1979 current_link = if actually_opened { new_link } else { None };
1980 }
1981
1982 let raw_width = effective_cell.content.width();
1983 let is_zero_width_content = raw_width == 0
1984 && !effective_cell.is_empty()
1985 && !effective_cell.is_continuation();
1986
1987 if is_zero_width_content {
1989 writer.write_all(b"\xEF\xBF\xBD")?;
1990 } else if let Some(ch) = effective_cell.content.as_char() {
1991 let mut buf = [0u8; 4];
1992 let encoded = ch.encode_utf8(&mut buf);
1993 writer.write_all(encoded.as_bytes())?;
1994 } else if let Some(gid) = effective_cell.content.grapheme_id() {
1995 if let Some(text) = self.pool.get(gid) {
1997 writer.write_all(text.as_bytes())?;
1998 } else {
1999 for _ in 0..raw_width.max(1) {
2001 writer.write_all(b"?")?;
2002 }
2003 }
2004 } else {
2005 writer.write_all(b" ")?;
2006 }
2007
2008 let advance = if effective_cell.is_empty() || is_zero_width_content {
2009 1
2010 } else {
2011 raw_width.max(1)
2012 };
2013 cursor_x = cursor_x.saturating_add(advance as u16);
2014 }
2015 }
2016
2017 writer.write_all(b"\x1b[0m")?;
2019
2020 if current_link.is_some() {
2022 writer.write_all(b"\x1b]8;;\x1b\\")?;
2023 }
2024
2025 trace!("emit_full_redraw complete");
2026 Ok(EmitStats {
2027 diff_cells,
2028 diff_runs,
2029 })
2030 }
2031
2032 fn emit_style_flags(
2034 writer: &mut impl Write,
2035 flags: ftui_render::cell::StyleFlags,
2036 ) -> io::Result<()> {
2037 use ftui_render::cell::StyleFlags;
2038
2039 let mut codes = Vec::with_capacity(8);
2040
2041 if flags.contains(StyleFlags::BOLD) {
2042 codes.push("1");
2043 }
2044 if flags.contains(StyleFlags::DIM) {
2045 codes.push("2");
2046 }
2047 if flags.contains(StyleFlags::ITALIC) {
2048 codes.push("3");
2049 }
2050 if flags.contains(StyleFlags::UNDERLINE) {
2051 codes.push("4");
2052 }
2053 if flags.contains(StyleFlags::BLINK) {
2054 codes.push("5");
2055 }
2056 if flags.contains(StyleFlags::REVERSE) {
2057 codes.push("7");
2058 }
2059 if flags.contains(StyleFlags::HIDDEN) {
2060 codes.push("8");
2061 }
2062 if flags.contains(StyleFlags::STRIKETHROUGH) {
2063 codes.push("9");
2064 }
2065
2066 if !codes.is_empty() {
2067 write!(writer, "\x1b[{}m", codes.join(";"))?;
2068 }
2069
2070 Ok(())
2071 }
2072
2073 #[allow(dead_code)] fn create_full_diff(&self, buffer: &Buffer) -> BufferDiff {
2076 BufferDiff::full(buffer.width(), buffer.height())
2077 }
2078
2079 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
2087 match self.screen_mode {
2088 ScreenMode::Inline { ui_height } => {
2089 if !self.scroll_region_active {
2092 self.prev_buffer = None;
2093 self.last_inline_region = None;
2094 self.reset_diff_strategy();
2095 }
2096
2097 self.position_cursor_for_log(ui_height)?;
2100 self.writer().write_all(text.as_bytes())?;
2101 self.writer().flush()
2102 }
2103 ScreenMode::InlineAuto { .. } => {
2104 if !self.scroll_region_active {
2106 self.prev_buffer = None;
2107 self.last_inline_region = None;
2108 self.reset_diff_strategy();
2109 }
2110
2111 let ui_height = self.effective_ui_height();
2113 self.position_cursor_for_log(ui_height)?;
2114 self.writer().write_all(text.as_bytes())?;
2115 self.writer().flush()
2116 }
2117 ScreenMode::AltScreen => {
2118 Ok(())
2121 }
2122 }
2123 }
2124
2125 fn position_cursor_for_log(&mut self, ui_height: u16) -> io::Result<()> {
2132 let visible_height = ui_height.min(self.term_height);
2133 if visible_height >= self.term_height {
2134 return Ok(());
2136 }
2137
2138 let log_row = match self.ui_anchor {
2139 UiAnchor::Bottom => {
2140 self.term_height.saturating_sub(visible_height)
2143 }
2144 UiAnchor::Top => {
2145 self.term_height
2148 }
2149 };
2150
2151 write!(self.writer(), "\x1b[{};1H", log_row)?;
2153 Ok(())
2154 }
2155
2156 pub fn clear_screen(&mut self) -> io::Result<()> {
2158 self.writer().write_all(b"\x1b[2J\x1b[1;1H")?;
2159 self.writer().flush()?;
2160 self.prev_buffer = None;
2161 self.last_inline_region = None;
2162 self.reset_diff_strategy();
2163 Ok(())
2164 }
2165
2166 fn set_cursor_visibility(&mut self, visible: bool) -> io::Result<()> {
2167 if self.cursor_visible == visible {
2168 return Ok(());
2169 }
2170 self.cursor_visible = visible;
2171 if visible {
2172 self.writer().write_all(b"\x1b[?25h")?;
2173 } else {
2174 self.writer().write_all(b"\x1b[?25l")?;
2175 }
2176 Ok(())
2177 }
2178
2179 pub fn hide_cursor(&mut self) -> io::Result<()> {
2181 self.set_cursor_visibility(false)?;
2182 self.writer().flush()
2183 }
2184
2185 pub fn show_cursor(&mut self) -> io::Result<()> {
2187 self.set_cursor_visibility(true)?;
2188 self.writer().flush()
2189 }
2190
2191 pub fn flush(&mut self) -> io::Result<()> {
2193 self.writer().flush()
2194 }
2195
2196 pub fn pool(&self) -> &GraphemePool {
2198 &self.pool
2199 }
2200
2201 pub fn pool_mut(&mut self) -> &mut GraphemePool {
2203 &mut self.pool
2204 }
2205
2206 pub fn links(&self) -> &LinkRegistry {
2208 &self.links
2209 }
2210
2211 pub fn links_mut(&mut self) -> &mut LinkRegistry {
2213 &mut self.links
2214 }
2215
2216 pub fn pool_and_links_mut(&mut self) -> (&mut GraphemePool, &mut LinkRegistry) {
2220 (&mut self.pool, &mut self.links)
2221 }
2222
2223 pub fn capabilities(&self) -> &TerminalCapabilities {
2225 &self.capabilities
2226 }
2227
2228 pub fn into_inner(mut self) -> Option<W> {
2233 self.cleanup();
2234 self.writer.take()?.into_inner().into_inner().ok()
2236 }
2237
2238 pub fn gc(&mut self) {
2244 let buffers = if let Some(ref buf) = self.prev_buffer {
2245 vec![buf]
2246 } else {
2247 vec![]
2248 };
2249 self.pool.gc(&buffers);
2250 }
2251
2252 fn cleanup(&mut self) {
2254 let Some(ref mut writer) = self.writer else {
2255 return; };
2257
2258 if self.in_sync_block {
2260 let _ = writer.write_all(SYNC_END);
2261 self.in_sync_block = false;
2262 }
2263
2264 if self.cursor_saved {
2266 let _ = writer.write_all(CURSOR_RESTORE);
2267 self.cursor_saved = false;
2268 }
2269
2270 if self.scroll_region_active {
2272 let _ = writer.write_all(b"\x1b[r");
2273 self.scroll_region_active = false;
2274 }
2275
2276 let _ = writer.write_all(b"\x1b[0m");
2278
2279 let _ = writer.write_all(b"\x1b[?25h");
2281 self.cursor_visible = true;
2282
2283 let _ = writer.flush();
2285
2286 if let Some(ref mut trace) = self.render_trace {
2287 let _ = trace.finish(None);
2288 }
2289 }
2290}
2291
2292impl<W: Write> Drop for TerminalWriter<W> {
2293 fn drop(&mut self) {
2294 self.cleanup();
2295 }
2296}
2297
2298#[cfg(test)]
2299mod tests {
2300 use super::*;
2301 use ftui_render::cell::{Cell, PackedRgba};
2302 use std::path::PathBuf;
2303 use std::sync::atomic::{AtomicUsize, Ordering};
2304
2305 fn max_cursor_row(output: &[u8]) -> u16 {
2306 let mut max_row = 0u16;
2307 let mut i = 0;
2308 while i + 2 < output.len() {
2309 if output[i] == 0x1b && output[i + 1] == b'[' {
2310 let mut j = i + 2;
2311 let mut row: u16 = 0;
2312 let mut saw_row = false;
2313 while j < output.len() && output[j].is_ascii_digit() {
2314 saw_row = true;
2315 row = row
2316 .saturating_mul(10)
2317 .saturating_add((output[j] - b'0') as u16);
2318 j += 1;
2319 }
2320 if saw_row && j < output.len() && output[j] == b';' {
2321 j += 1;
2322 let mut saw_col = false;
2323 while j < output.len() && output[j].is_ascii_digit() {
2324 saw_col = true;
2325 j += 1;
2326 }
2327 if saw_col && j < output.len() && output[j] == b'H' {
2328 max_row = max_row.max(row);
2329 }
2330 }
2331 }
2332 i += 1;
2333 }
2334 max_row
2335 }
2336
2337 fn basic_caps() -> TerminalCapabilities {
2338 TerminalCapabilities::basic()
2339 }
2340
2341 fn full_caps() -> TerminalCapabilities {
2342 let mut caps = TerminalCapabilities::basic();
2343 caps.true_color = true;
2344 caps.sync_output = true;
2345 caps
2346 }
2347
2348 fn find_nth(haystack: &[u8], needle: &[u8], nth: usize) -> Option<usize> {
2349 if nth == 0 {
2350 return None;
2351 }
2352 let mut count = 0;
2353 let mut i = 0;
2354 while i + needle.len() <= haystack.len() {
2355 if &haystack[i..i + needle.len()] == needle {
2356 count += 1;
2357 if count == nth {
2358 return Some(i);
2359 }
2360 }
2361 i += 1;
2362 }
2363 None
2364 }
2365
2366 fn temp_evidence_path(label: &str) -> PathBuf {
2367 static COUNTER: AtomicUsize = AtomicUsize::new(0);
2368 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2369 let mut path = std::env::temp_dir();
2370 path.push(format!(
2371 "ftui_{}_{}_{}.jsonl",
2372 label,
2373 std::process::id(),
2374 id
2375 ));
2376 path
2377 }
2378
2379 #[test]
2380 fn new_creates_writer() {
2381 let output = Vec::new();
2382 let writer = TerminalWriter::new(
2383 output,
2384 ScreenMode::Inline { ui_height: 10 },
2385 UiAnchor::Bottom,
2386 basic_caps(),
2387 );
2388 assert_eq!(writer.ui_height(), 10);
2389 }
2390
2391 #[test]
2392 fn ui_start_row_bottom_anchor() {
2393 let output = Vec::new();
2394 let mut writer = TerminalWriter::new(
2395 output,
2396 ScreenMode::Inline { ui_height: 10 },
2397 UiAnchor::Bottom,
2398 basic_caps(),
2399 );
2400 writer.set_size(80, 24);
2401 assert_eq!(writer.ui_start_row(), 14); }
2403
2404 #[test]
2405 fn ui_start_row_top_anchor() {
2406 let output = Vec::new();
2407 let mut writer = TerminalWriter::new(
2408 output,
2409 ScreenMode::Inline { ui_height: 10 },
2410 UiAnchor::Top,
2411 basic_caps(),
2412 );
2413 writer.set_size(80, 24);
2414 assert_eq!(writer.ui_start_row(), 0);
2415 }
2416
2417 #[test]
2418 fn ui_start_row_altscreen() {
2419 let output = Vec::new();
2420 let mut writer = TerminalWriter::new(
2421 output,
2422 ScreenMode::AltScreen,
2423 UiAnchor::Bottom,
2424 basic_caps(),
2425 );
2426 writer.set_size(80, 24);
2427 assert_eq!(writer.ui_start_row(), 0);
2428 }
2429
2430 #[test]
2431 fn present_ui_inline_saves_restores_cursor() {
2432 let mut output = Vec::new();
2433 {
2434 let mut writer = TerminalWriter::new(
2435 &mut output,
2436 ScreenMode::Inline { ui_height: 5 },
2437 UiAnchor::Bottom,
2438 basic_caps(),
2439 );
2440 writer.set_size(10, 10);
2441
2442 let buffer = Buffer::new(10, 5);
2443 writer.present_ui(&buffer, None, true).unwrap();
2444 }
2445
2446 assert!(output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE));
2448 assert!(
2449 output
2450 .windows(CURSOR_RESTORE.len())
2451 .any(|w| w == CURSOR_RESTORE)
2452 );
2453 }
2454
2455 #[test]
2456 fn present_ui_with_sync_output() {
2457 let mut output = Vec::new();
2458 {
2459 let mut writer = TerminalWriter::new(
2460 &mut output,
2461 ScreenMode::Inline { ui_height: 5 },
2462 UiAnchor::Bottom,
2463 full_caps(),
2464 );
2465 writer.set_size(10, 10);
2466
2467 let buffer = Buffer::new(10, 5);
2468 writer.present_ui(&buffer, None, true).unwrap();
2469 }
2470
2471 assert!(output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN));
2473 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2474 }
2475
2476 #[test]
2477 fn present_ui_hides_cursor_when_requested() {
2478 let mut output = Vec::new();
2479 {
2480 let mut writer = TerminalWriter::new(
2481 &mut output,
2482 ScreenMode::AltScreen,
2483 UiAnchor::Bottom,
2484 basic_caps(),
2485 );
2486 writer.set_size(10, 5);
2487
2488 let buffer = Buffer::new(10, 5);
2489 writer.present_ui(&buffer, None, false).unwrap();
2490 }
2491
2492 assert!(
2493 output.windows(6).any(|w| w == b"\x1b[?25l"),
2494 "expected cursor hide sequence"
2495 );
2496 }
2497
2498 #[test]
2499 fn present_ui_visible_does_not_hide_cursor() {
2500 let mut output = Vec::new();
2501 {
2502 let mut writer = TerminalWriter::new(
2503 &mut output,
2504 ScreenMode::AltScreen,
2505 UiAnchor::Bottom,
2506 basic_caps(),
2507 );
2508 writer.set_size(10, 5);
2509
2510 let buffer = Buffer::new(10, 5);
2511 writer.present_ui(&buffer, None, true).unwrap();
2512 }
2513
2514 assert!(
2515 !output.windows(6).any(|w| w == b"\x1b[?25l"),
2516 "did not expect cursor hide sequence"
2517 );
2518 }
2519
2520 #[test]
2521 fn write_log_in_inline_mode() {
2522 let mut output = Vec::new();
2523 {
2524 let mut writer = TerminalWriter::new(
2525 &mut output,
2526 ScreenMode::Inline { ui_height: 5 },
2527 UiAnchor::Bottom,
2528 basic_caps(),
2529 );
2530 writer.write_log("test log\n").unwrap();
2531 }
2532
2533 let output_str = String::from_utf8_lossy(&output);
2534 assert!(output_str.contains("test log"));
2535 }
2536
2537 #[test]
2538 fn write_log_in_altscreen_is_noop() {
2539 let mut output = Vec::new();
2540 {
2541 let mut writer = TerminalWriter::new(
2542 &mut output,
2543 ScreenMode::AltScreen,
2544 UiAnchor::Bottom,
2545 basic_caps(),
2546 );
2547 writer.write_log("test log\n").unwrap();
2548 }
2549
2550 let output_str = String::from_utf8_lossy(&output);
2552 assert!(!output_str.contains("test log"));
2553 }
2554
2555 #[test]
2556 fn clear_screen_resets_prev_buffer() {
2557 let mut output = Vec::new();
2558 let mut writer = TerminalWriter::new(
2559 &mut output,
2560 ScreenMode::AltScreen,
2561 UiAnchor::Bottom,
2562 basic_caps(),
2563 );
2564
2565 let buffer = Buffer::new(10, 5);
2567 writer.present_ui(&buffer, None, true).unwrap();
2568 assert!(writer.prev_buffer.is_some());
2569
2570 writer.clear_screen().unwrap();
2572 assert!(writer.prev_buffer.is_none());
2573 }
2574
2575 #[test]
2576 fn set_size_clears_prev_buffer() {
2577 let output = Vec::new();
2578 let mut writer = TerminalWriter::new(
2579 output,
2580 ScreenMode::AltScreen,
2581 UiAnchor::Bottom,
2582 basic_caps(),
2583 );
2584
2585 writer.prev_buffer = Some(Buffer::new(10, 10));
2586 writer.set_size(20, 20);
2587
2588 assert!(writer.prev_buffer.is_none());
2589 }
2590
2591 #[test]
2592 fn inline_auto_resize_clears_cached_height() {
2593 let output = Vec::new();
2594 let mut writer = TerminalWriter::new(
2595 output,
2596 ScreenMode::InlineAuto {
2597 min_height: 3,
2598 max_height: 8,
2599 },
2600 UiAnchor::Bottom,
2601 basic_caps(),
2602 );
2603
2604 writer.set_size(80, 24);
2605 writer.set_auto_ui_height(6);
2606 assert_eq!(writer.auto_ui_height(), Some(6));
2607 assert_eq!(writer.render_height_hint(), 6);
2608
2609 writer.set_size(100, 30);
2610 assert_eq!(writer.auto_ui_height(), None);
2611 assert_eq!(writer.render_height_hint(), 8);
2612 }
2613
2614 #[test]
2615 fn drop_cleanup_restores_cursor() {
2616 let mut output = Vec::new();
2617 {
2618 let mut writer = TerminalWriter::new(
2619 &mut output,
2620 ScreenMode::Inline { ui_height: 5 },
2621 UiAnchor::Bottom,
2622 basic_caps(),
2623 );
2624 writer.cursor_saved = true;
2625 }
2627
2628 assert!(
2630 output
2631 .windows(CURSOR_RESTORE.len())
2632 .any(|w| w == CURSOR_RESTORE)
2633 );
2634 }
2635
2636 #[test]
2637 fn drop_cleanup_ends_sync_block() {
2638 let mut output = Vec::new();
2639 {
2640 let mut writer = TerminalWriter::new(
2641 &mut output,
2642 ScreenMode::Inline { ui_height: 5 },
2643 UiAnchor::Bottom,
2644 full_caps(),
2645 );
2646 writer.in_sync_block = true;
2647 }
2649
2650 assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2652 }
2653
2654 #[test]
2655 fn present_multiple_frames_uses_diff() {
2656 use std::io::Cursor;
2657
2658 let output = Cursor::new(Vec::new());
2660 let mut writer = TerminalWriter::new(
2661 output,
2662 ScreenMode::AltScreen,
2663 UiAnchor::Bottom,
2664 basic_caps(),
2665 );
2666 writer.set_size(10, 5);
2667
2668 let mut buffer1 = Buffer::new(10, 5);
2670 buffer1.set_raw(0, 0, Cell::from_char('A'));
2671 writer.present_ui(&buffer1, None, true).unwrap();
2672
2673 writer.present_ui(&buffer1, None, true).unwrap();
2675
2676 let mut buffer2 = buffer1.clone();
2678 buffer2.set_raw(1, 0, Cell::from_char('B'));
2679 writer.present_ui(&buffer2, None, true).unwrap();
2680
2681 }
2684
2685 #[test]
2686 fn cell_content_rendered_correctly() {
2687 let mut output = Vec::new();
2688 {
2689 let mut writer = TerminalWriter::new(
2690 &mut output,
2691 ScreenMode::AltScreen,
2692 UiAnchor::Bottom,
2693 basic_caps(),
2694 );
2695 writer.set_size(10, 5);
2696
2697 let mut buffer = Buffer::new(10, 5);
2698 buffer.set_raw(0, 0, Cell::from_char('H'));
2699 buffer.set_raw(1, 0, Cell::from_char('i'));
2700 buffer.set_raw(2, 0, Cell::from_char('!'));
2701 writer.present_ui(&buffer, None, true).unwrap();
2702 }
2703
2704 let output_str = String::from_utf8_lossy(&output);
2705 assert!(output_str.contains('H'));
2706 assert!(output_str.contains('i'));
2707 assert!(output_str.contains('!'));
2708 }
2709
2710 #[test]
2711 fn resize_reanchors_ui_region() {
2712 let output = Vec::new();
2713 let mut writer = TerminalWriter::new(
2714 output,
2715 ScreenMode::Inline { ui_height: 10 },
2716 UiAnchor::Bottom,
2717 basic_caps(),
2718 );
2719
2720 writer.set_size(80, 24);
2722 assert_eq!(writer.ui_start_row(), 14);
2723
2724 writer.set_size(80, 40);
2726 assert_eq!(writer.ui_start_row(), 30);
2727
2728 writer.set_size(80, 15);
2730 assert_eq!(writer.ui_start_row(), 5);
2731 }
2732
2733 #[test]
2734 fn inline_auto_height_clamps_and_uses_max_for_render() {
2735 let output = Vec::new();
2736 let mut writer = TerminalWriter::new(
2737 output,
2738 ScreenMode::InlineAuto {
2739 min_height: 3,
2740 max_height: 8,
2741 },
2742 UiAnchor::Bottom,
2743 basic_caps(),
2744 );
2745 writer.set_size(80, 24);
2746
2747 assert_eq!(writer.ui_height(), 3);
2749 assert_eq!(writer.auto_ui_height(), None);
2750
2751 assert_eq!(writer.render_height_hint(), 8);
2753
2754 writer.set_auto_ui_height(6);
2756 assert_eq!(writer.render_height_hint(), 6);
2757
2758 writer.clear_auto_ui_height();
2760 assert_eq!(writer.render_height_hint(), 8);
2761
2762 writer.set_auto_ui_height(3);
2764 assert_eq!(writer.auto_ui_height(), Some(3));
2765 assert_eq!(writer.ui_height(), 3);
2766
2767 writer.clear_auto_ui_height();
2768 assert_eq!(writer.render_height_hint(), 8);
2769
2770 writer.set_auto_ui_height(10);
2772 assert_eq!(writer.ui_height(), 8);
2773
2774 writer.set_auto_ui_height(1);
2776 assert_eq!(writer.ui_height(), 3);
2777 }
2778
2779 #[test]
2780 fn resize_with_top_anchor_stays_at_zero() {
2781 let output = Vec::new();
2782 let mut writer = TerminalWriter::new(
2783 output,
2784 ScreenMode::Inline { ui_height: 10 },
2785 UiAnchor::Top,
2786 basic_caps(),
2787 );
2788
2789 writer.set_size(80, 24);
2790 assert_eq!(writer.ui_start_row(), 0);
2791
2792 writer.set_size(80, 40);
2793 assert_eq!(writer.ui_start_row(), 0);
2794 }
2795
2796 #[test]
2797 fn inline_mode_never_clears_full_screen() {
2798 let mut output = Vec::new();
2799 {
2800 let mut writer = TerminalWriter::new(
2801 &mut output,
2802 ScreenMode::Inline { ui_height: 5 },
2803 UiAnchor::Bottom,
2804 basic_caps(),
2805 );
2806 writer.set_size(10, 10);
2807
2808 let buffer = Buffer::new(10, 5);
2809 writer.present_ui(&buffer, None, true).unwrap();
2810 }
2811
2812 let has_ed2 = output.windows(4).any(|w| w == b"\x1b[2J");
2814 assert!(!has_ed2, "Inline mode should never use full screen clear");
2815
2816 assert!(output.windows(ERASE_LINE.len()).any(|w| w == ERASE_LINE));
2818 }
2819
2820 #[test]
2821 fn present_after_log_maintains_cursor_position() {
2822 let mut output = Vec::new();
2823 {
2824 let mut writer = TerminalWriter::new(
2825 &mut output,
2826 ScreenMode::Inline { ui_height: 5 },
2827 UiAnchor::Bottom,
2828 basic_caps(),
2829 );
2830 writer.set_size(10, 10);
2831
2832 let buffer = Buffer::new(10, 5);
2834 writer.present_ui(&buffer, None, true).unwrap();
2835
2836 writer.write_log("log line\n").unwrap();
2838
2839 writer.present_ui(&buffer, None, true).unwrap();
2841 }
2842
2843 let save_count = output
2845 .windows(CURSOR_SAVE.len())
2846 .filter(|w| *w == CURSOR_SAVE)
2847 .count();
2848 assert_eq!(save_count, 2, "Should have saved cursor twice");
2849
2850 let restore_count = output
2852 .windows(CURSOR_RESTORE.len())
2853 .filter(|w| *w == CURSOR_RESTORE)
2854 .count();
2855 assert!(
2857 restore_count >= 2,
2858 "Should have restored cursor at least twice"
2859 );
2860 }
2861
2862 #[test]
2863 fn ui_height_bounds_check() {
2864 let output = Vec::new();
2865 let mut writer = TerminalWriter::new(
2866 output,
2867 ScreenMode::Inline { ui_height: 100 },
2868 UiAnchor::Bottom,
2869 basic_caps(),
2870 );
2871
2872 writer.set_size(80, 10);
2874
2875 assert_eq!(writer.ui_start_row(), 0);
2877 }
2878
2879 #[test]
2880 fn inline_ui_height_clamped_to_terminal_height() {
2881 let mut output = Vec::new();
2882 {
2883 let mut writer = TerminalWriter::new(
2884 &mut output,
2885 ScreenMode::Inline { ui_height: 10 },
2886 UiAnchor::Bottom,
2887 basic_caps(),
2888 );
2889 writer.set_size(8, 3);
2890 let buffer = Buffer::new(8, 10);
2891 writer.present_ui(&buffer, None, true).unwrap();
2892 }
2893
2894 let max_row = max_cursor_row(&output);
2895 assert!(
2896 max_row <= 3,
2897 "cursor row {} exceeds terminal height",
2898 max_row
2899 );
2900 }
2901
2902 #[test]
2903 fn inline_shrink_clears_stale_rows() {
2904 let mut output = Vec::new();
2905 {
2906 let mut writer = TerminalWriter::new(
2907 &mut output,
2908 ScreenMode::InlineAuto {
2909 min_height: 1,
2910 max_height: 6,
2911 },
2912 UiAnchor::Bottom,
2913 basic_caps(),
2914 );
2915 writer.set_size(10, 10);
2916
2917 let buffer = Buffer::new(10, 6);
2918 writer.set_auto_ui_height(6);
2919 writer.present_ui(&buffer, None, true).unwrap();
2920
2921 writer.set_auto_ui_height(3);
2922 writer.present_ui(&buffer, None, true).unwrap();
2923 }
2924
2925 let second_save = find_nth(&output, CURSOR_SAVE, 2).expect("expected second cursor save");
2926 let after_save = &output[second_save..];
2927 let restore_idx = after_save
2928 .windows(CURSOR_RESTORE.len())
2929 .position(|w| w == CURSOR_RESTORE)
2930 .expect("expected cursor restore after second save");
2931 let segment = &after_save[..restore_idx];
2932 let erase_count = segment
2933 .windows(ERASE_LINE.len())
2934 .filter(|w| *w == ERASE_LINE)
2935 .count();
2936
2937 assert_eq!(erase_count, 6, "expected clears for stale + new rows");
2938 }
2939
2940 fn scroll_region_caps() -> TerminalCapabilities {
2944 let mut caps = TerminalCapabilities::basic();
2945 caps.scroll_region = true;
2946 caps.sync_output = true;
2947 caps
2948 }
2949
2950 fn hybrid_caps() -> TerminalCapabilities {
2952 let mut caps = TerminalCapabilities::basic();
2953 caps.scroll_region = true;
2954 caps
2955 }
2956
2957 fn mux_caps() -> TerminalCapabilities {
2959 let mut caps = TerminalCapabilities::basic();
2960 caps.scroll_region = true;
2961 caps.sync_output = true;
2962 caps.in_tmux = true;
2963 caps
2964 }
2965
2966 #[test]
2967 fn scroll_region_bounds_bottom_anchor() {
2968 let mut output = Vec::new();
2969 {
2970 let mut writer = TerminalWriter::new(
2971 &mut output,
2972 ScreenMode::Inline { ui_height: 5 },
2973 UiAnchor::Bottom,
2974 scroll_region_caps(),
2975 );
2976 writer.set_size(10, 10);
2977 let buffer = Buffer::new(10, 5);
2978 writer.present_ui(&buffer, None, true).unwrap();
2979 }
2980
2981 let seq = b"\x1b[1;5r";
2982 assert!(
2983 output.windows(seq.len()).any(|w| w == seq),
2984 "expected scroll region for bottom anchor"
2985 );
2986 }
2987
2988 #[test]
2989 fn scroll_region_bounds_top_anchor() {
2990 let mut output = Vec::new();
2991 {
2992 let mut writer = TerminalWriter::new(
2993 &mut output,
2994 ScreenMode::Inline { ui_height: 5 },
2995 UiAnchor::Top,
2996 scroll_region_caps(),
2997 );
2998 writer.set_size(10, 10);
2999 let buffer = Buffer::new(10, 5);
3000 writer.present_ui(&buffer, None, true).unwrap();
3001 }
3002
3003 let seq = b"\x1b[6;10r";
3004 assert!(
3005 output.windows(seq.len()).any(|w| w == seq),
3006 "expected scroll region for top anchor"
3007 );
3008 let cursor_seq = b"\x1b[6;1H";
3009 assert!(
3010 output.windows(cursor_seq.len()).any(|w| w == cursor_seq),
3011 "expected cursor move into log region for top anchor"
3012 );
3013 }
3014
3015 #[test]
3016 fn present_ui_inline_resets_style_before_cursor_restore() {
3017 let mut output = Vec::new();
3018 {
3019 let mut writer = TerminalWriter::new(
3020 &mut output,
3021 ScreenMode::Inline { ui_height: 2 },
3022 UiAnchor::Bottom,
3023 basic_caps(),
3024 );
3025 writer.set_size(5, 5);
3026 let mut buffer = Buffer::new(5, 2);
3027 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(PackedRgba::RED));
3028 writer.present_ui(&buffer, None, true).unwrap();
3029 }
3030
3031 let seq = b"\x1b[0m\x1b8";
3032 assert!(
3033 output.windows(seq.len()).any(|w| w == seq),
3034 "expected SGR reset before cursor restore in inline mode"
3035 );
3036 }
3037
3038 #[test]
3039 fn strategy_selected_from_capabilities() {
3040 let w = TerminalWriter::new(
3042 Vec::new(),
3043 ScreenMode::Inline { ui_height: 5 },
3044 UiAnchor::Bottom,
3045 basic_caps(),
3046 );
3047 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3048
3049 let w = TerminalWriter::new(
3051 Vec::new(),
3052 ScreenMode::Inline { ui_height: 5 },
3053 UiAnchor::Bottom,
3054 scroll_region_caps(),
3055 );
3056 assert_eq!(w.inline_strategy(), InlineStrategy::ScrollRegion);
3057
3058 let w = TerminalWriter::new(
3060 Vec::new(),
3061 ScreenMode::Inline { ui_height: 5 },
3062 UiAnchor::Bottom,
3063 hybrid_caps(),
3064 );
3065 assert_eq!(w.inline_strategy(), InlineStrategy::Hybrid);
3066
3067 let w = TerminalWriter::new(
3069 Vec::new(),
3070 ScreenMode::Inline { ui_height: 5 },
3071 UiAnchor::Bottom,
3072 mux_caps(),
3073 );
3074 assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3075 }
3076
3077 #[test]
3078 fn scroll_region_activated_on_present() {
3079 let mut output = Vec::new();
3080 {
3081 let mut writer = TerminalWriter::new(
3082 &mut output,
3083 ScreenMode::Inline { ui_height: 5 },
3084 UiAnchor::Bottom,
3085 scroll_region_caps(),
3086 );
3087 writer.set_size(80, 24);
3088 assert!(!writer.scroll_region_active());
3089
3090 let buffer = Buffer::new(80, 5);
3091 writer.present_ui(&buffer, None, true).unwrap();
3092 assert!(writer.scroll_region_active());
3093 }
3094
3095 let expected = b"\x1b[1;19r";
3097 assert!(
3098 output.windows(expected.len()).any(|w| w == expected),
3099 "Should set scroll region to rows 1-19"
3100 );
3101 }
3102
3103 #[test]
3104 fn scroll_region_not_activated_for_overlay() {
3105 let mut output = Vec::new();
3106 {
3107 let mut writer = TerminalWriter::new(
3108 &mut output,
3109 ScreenMode::Inline { ui_height: 5 },
3110 UiAnchor::Bottom,
3111 basic_caps(),
3112 );
3113 writer.set_size(80, 24);
3114
3115 let buffer = Buffer::new(80, 5);
3116 writer.present_ui(&buffer, None, true).unwrap();
3117 assert!(!writer.scroll_region_active());
3118 }
3119
3120 let decstbm = b"\x1b[1;19r";
3122 assert!(
3123 !output.windows(decstbm.len()).any(|w| w == decstbm),
3124 "OverlayRedraw should not set scroll region"
3125 );
3126 }
3127
3128 #[test]
3129 fn scroll_region_not_activated_in_mux() {
3130 let mut output = Vec::new();
3131 {
3132 let mut writer = TerminalWriter::new(
3133 &mut output,
3134 ScreenMode::Inline { ui_height: 5 },
3135 UiAnchor::Bottom,
3136 mux_caps(),
3137 );
3138 writer.set_size(80, 24);
3139
3140 let buffer = Buffer::new(80, 5);
3141 writer.present_ui(&buffer, None, true).unwrap();
3142 assert!(!writer.scroll_region_active());
3143 }
3144
3145 let decstbm = b"\x1b[1;19r";
3147 assert!(
3148 !output.windows(decstbm.len()).any(|w| w == decstbm),
3149 "Mux environment should not use scroll region"
3150 );
3151 }
3152
3153 #[test]
3154 fn scroll_region_reset_on_cleanup() {
3155 let mut output = Vec::new();
3156 {
3157 let mut writer = TerminalWriter::new(
3158 &mut output,
3159 ScreenMode::Inline { ui_height: 5 },
3160 UiAnchor::Bottom,
3161 scroll_region_caps(),
3162 );
3163 writer.set_size(80, 24);
3164
3165 let buffer = Buffer::new(80, 5);
3166 writer.present_ui(&buffer, None, true).unwrap();
3167 }
3169
3170 let reset = b"\x1b[r";
3172 assert!(
3173 output.windows(reset.len()).any(|w| w == reset),
3174 "Cleanup should reset scroll region"
3175 );
3176 }
3177
3178 #[test]
3179 fn scroll_region_reset_on_resize() {
3180 let output = Vec::new();
3181 let mut writer = TerminalWriter::new(
3182 output,
3183 ScreenMode::Inline { ui_height: 5 },
3184 UiAnchor::Bottom,
3185 scroll_region_caps(),
3186 );
3187 writer.set_size(80, 24);
3188
3189 writer.activate_scroll_region(5).unwrap();
3191 assert!(writer.scroll_region_active());
3192
3193 writer.set_size(80, 40);
3195 assert!(!writer.scroll_region_active());
3196 }
3197
3198 #[test]
3199 fn scroll_region_reactivated_after_resize() {
3200 let mut output = Vec::new();
3201 {
3202 let mut writer = TerminalWriter::new(
3203 &mut output,
3204 ScreenMode::Inline { ui_height: 5 },
3205 UiAnchor::Bottom,
3206 scroll_region_caps(),
3207 );
3208 writer.set_size(80, 24);
3209
3210 let buffer = Buffer::new(80, 5);
3212 writer.present_ui(&buffer, None, true).unwrap();
3213 assert!(writer.scroll_region_active());
3214
3215 writer.set_size(80, 40);
3217 assert!(!writer.scroll_region_active());
3218
3219 let buffer2 = Buffer::new(80, 5);
3221 writer.present_ui(&buffer2, None, true).unwrap();
3222 assert!(writer.scroll_region_active());
3223 }
3224
3225 let new_region = b"\x1b[1;35r";
3227 assert!(
3228 output.windows(new_region.len()).any(|w| w == new_region),
3229 "Should set scroll region to new dimensions after resize"
3230 );
3231 }
3232
3233 #[test]
3234 fn hybrid_strategy_activates_scroll_region() {
3235 let mut output = Vec::new();
3236 {
3237 let mut writer = TerminalWriter::new(
3238 &mut output,
3239 ScreenMode::Inline { ui_height: 5 },
3240 UiAnchor::Bottom,
3241 hybrid_caps(),
3242 );
3243 writer.set_size(80, 24);
3244
3245 let buffer = Buffer::new(80, 5);
3246 writer.present_ui(&buffer, None, true).unwrap();
3247 assert!(writer.scroll_region_active());
3248 }
3249
3250 let expected = b"\x1b[1;19r";
3252 assert!(
3253 output.windows(expected.len()).any(|w| w == expected),
3254 "Hybrid should activate scroll region as optimization"
3255 );
3256 }
3257
3258 #[test]
3259 fn altscreen_does_not_activate_scroll_region() {
3260 let output = Vec::new();
3261 let mut writer = TerminalWriter::new(
3262 output,
3263 ScreenMode::AltScreen,
3264 UiAnchor::Bottom,
3265 scroll_region_caps(),
3266 );
3267 writer.set_size(80, 24);
3268
3269 let buffer = Buffer::new(80, 24);
3270 writer.present_ui(&buffer, None, true).unwrap();
3271 assert!(!writer.scroll_region_active());
3272 }
3273
3274 #[test]
3275 fn scroll_region_still_saves_restores_cursor() {
3276 let mut output = Vec::new();
3277 {
3278 let mut writer = TerminalWriter::new(
3279 &mut output,
3280 ScreenMode::Inline { ui_height: 5 },
3281 UiAnchor::Bottom,
3282 scroll_region_caps(),
3283 );
3284 writer.set_size(80, 24);
3285
3286 let buffer = Buffer::new(80, 5);
3287 writer.present_ui(&buffer, None, true).unwrap();
3288 }
3289
3290 assert!(
3292 output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE),
3293 "Scroll region mode should still save cursor"
3294 );
3295 assert!(
3296 output
3297 .windows(CURSOR_RESTORE.len())
3298 .any(|w| w == CURSOR_RESTORE),
3299 "Scroll region mode should still restore cursor"
3300 );
3301 }
3302
3303 #[test]
3306 fn write_log_positions_cursor_bottom_anchor() {
3307 let mut output = Vec::new();
3310 {
3311 let mut writer = TerminalWriter::new(
3312 &mut output,
3313 ScreenMode::Inline { ui_height: 5 },
3314 UiAnchor::Bottom,
3315 basic_caps(),
3316 );
3317 writer.set_size(80, 24);
3318 writer.write_log("test log\n").unwrap();
3319 }
3320
3321 let expected_pos = b"\x1b[19;1H";
3325 assert!(
3326 output
3327 .windows(expected_pos.len())
3328 .any(|w| w == expected_pos),
3329 "Log write should position cursor at row 19 for bottom anchor"
3330 );
3331 }
3332
3333 #[test]
3334 fn write_log_positions_cursor_top_anchor() {
3335 let mut output = Vec::new();
3338 {
3339 let mut writer = TerminalWriter::new(
3340 &mut output,
3341 ScreenMode::Inline { ui_height: 5 },
3342 UiAnchor::Top,
3343 basic_caps(),
3344 );
3345 writer.set_size(80, 24);
3346 writer.write_log("test log\n").unwrap();
3347 }
3348
3349 let expected_pos = b"\x1b[24;1H";
3353 assert!(
3354 output
3355 .windows(expected_pos.len())
3356 .any(|w| w == expected_pos),
3357 "Log write should position cursor at row 24 for top anchor"
3358 );
3359 }
3360
3361 #[test]
3362 fn write_log_contains_text() {
3363 let mut output = Vec::new();
3365 {
3366 let mut writer = TerminalWriter::new(
3367 &mut output,
3368 ScreenMode::Inline { ui_height: 5 },
3369 UiAnchor::Bottom,
3370 basic_caps(),
3371 );
3372 writer.set_size(80, 24);
3373 writer.write_log("hello world\n").unwrap();
3374 }
3375
3376 let output_str = String::from_utf8_lossy(&output);
3377 assert!(output_str.contains("hello world"));
3378 }
3379
3380 #[test]
3381 fn write_log_multiple_writes_position_each_time() {
3382 let mut output = Vec::new();
3384 {
3385 let mut writer = TerminalWriter::new(
3386 &mut output,
3387 ScreenMode::Inline { ui_height: 5 },
3388 UiAnchor::Bottom,
3389 basic_caps(),
3390 );
3391 writer.set_size(80, 24);
3392 writer.write_log("first\n").unwrap();
3393 writer.write_log("second\n").unwrap();
3394 }
3395
3396 let expected_pos = b"\x1b[19;1H";
3398 let count = output
3399 .windows(expected_pos.len())
3400 .filter(|w| *w == expected_pos)
3401 .count();
3402 assert_eq!(count, 2, "Should position cursor for each log write");
3403 }
3404
3405 #[test]
3406 fn write_log_after_present_ui_works_correctly() {
3407 let mut output = Vec::new();
3409 {
3410 let mut writer = TerminalWriter::new(
3411 &mut output,
3412 ScreenMode::Inline { ui_height: 5 },
3413 UiAnchor::Bottom,
3414 basic_caps(),
3415 );
3416 writer.set_size(80, 24);
3417
3418 let buffer = Buffer::new(80, 5);
3420 writer.present_ui(&buffer, None, true).unwrap();
3421
3422 writer.write_log("after UI\n").unwrap();
3424 }
3425
3426 let output_str = String::from_utf8_lossy(&output);
3427 assert!(output_str.contains("after UI"));
3428
3429 let expected_pos = b"\x1b[19;1H";
3431 assert!(
3433 output
3434 .windows(expected_pos.len())
3435 .any(|w| w == expected_pos),
3436 "Log write after present_ui should position cursor"
3437 );
3438 }
3439
3440 #[test]
3441 fn write_log_ui_fills_terminal_is_noop() {
3442 let mut output = Vec::new();
3444 {
3445 let mut writer = TerminalWriter::new(
3446 &mut output,
3447 ScreenMode::Inline { ui_height: 24 },
3448 UiAnchor::Bottom,
3449 basic_caps(),
3450 );
3451 writer.set_size(80, 24);
3452 writer.write_log("should still write\n").unwrap();
3453 }
3454
3455 let output_str = String::from_utf8_lossy(&output);
3457 assert!(output_str.contains("should still write"));
3458 }
3459
3460 #[test]
3461 fn write_log_with_scroll_region_active() {
3462 let mut output = Vec::new();
3464 {
3465 let mut writer = TerminalWriter::new(
3466 &mut output,
3467 ScreenMode::Inline { ui_height: 5 },
3468 UiAnchor::Bottom,
3469 scroll_region_caps(),
3470 );
3471 writer.set_size(80, 24);
3472
3473 let buffer = Buffer::new(80, 5);
3475 writer.present_ui(&buffer, None, true).unwrap();
3476 assert!(writer.scroll_region_active());
3477
3478 writer.write_log("with scroll region\n").unwrap();
3480 }
3481
3482 let output_str = String::from_utf8_lossy(&output);
3483 assert!(output_str.contains("with scroll region"));
3484 }
3485
3486 #[test]
3487 fn log_write_cursor_position_not_in_ui_region_bottom_anchor() {
3488 let mut output = Vec::new();
3494 {
3495 let mut writer = TerminalWriter::new(
3496 &mut output,
3497 ScreenMode::Inline { ui_height: 5 },
3498 UiAnchor::Bottom,
3499 basic_caps(),
3500 );
3501 writer.set_size(80, 24);
3502 writer.write_log("test\n").unwrap();
3503 }
3504
3505 let mut found_row = None;
3508 let mut i = 0;
3509 while i + 2 < output.len() {
3510 if output[i] == 0x1b && output[i + 1] == b'[' {
3511 let mut j = i + 2;
3512 let mut row: u16 = 0;
3513 while j < output.len() && output[j].is_ascii_digit() {
3514 row = row * 10 + (output[j] - b'0') as u16;
3515 j += 1;
3516 }
3517 if j < output.len() && output[j] == b';' {
3518 j += 1;
3519 while j < output.len() && output[j].is_ascii_digit() {
3520 j += 1;
3521 }
3522 if j < output.len() && output[j] == b'H' {
3523 found_row = Some(row);
3524 }
3525 }
3526 }
3527 i += 1;
3528 }
3529
3530 if let Some(row) = found_row {
3531 assert!(
3533 row < 20,
3534 "Log cursor row {} should be below UI start row 20",
3535 row
3536 );
3537 }
3538 }
3539
3540 #[test]
3541 fn log_write_cursor_position_not_in_ui_region_top_anchor() {
3542 let mut output = Vec::new();
3548 {
3549 let mut writer = TerminalWriter::new(
3550 &mut output,
3551 ScreenMode::Inline { ui_height: 5 },
3552 UiAnchor::Top,
3553 basic_caps(),
3554 );
3555 writer.set_size(80, 24);
3556 writer.write_log("test\n").unwrap();
3557 }
3558
3559 let mut found_row = None;
3561 let mut i = 0;
3562 while i + 2 < output.len() {
3563 if output[i] == 0x1b && output[i + 1] == b'[' {
3564 let mut j = i + 2;
3565 let mut row: u16 = 0;
3566 while j < output.len() && output[j].is_ascii_digit() {
3567 row = row * 10 + (output[j] - b'0') as u16;
3568 j += 1;
3569 }
3570 if j < output.len() && output[j] == b';' {
3571 j += 1;
3572 while j < output.len() && output[j].is_ascii_digit() {
3573 j += 1;
3574 }
3575 if j < output.len() && output[j] == b'H' {
3576 found_row = Some(row);
3577 }
3578 }
3579 }
3580 i += 1;
3581 }
3582
3583 if let Some(row) = found_row {
3584 assert!(
3586 row > 5,
3587 "Log cursor row {} should be above UI end row 5",
3588 row
3589 );
3590 }
3591 }
3592
3593 #[test]
3594 fn present_ui_positions_cursor_after_restore() {
3595 let mut output = Vec::new();
3596 {
3597 let mut writer = TerminalWriter::new(
3598 &mut output,
3599 ScreenMode::Inline { ui_height: 5 },
3600 UiAnchor::Bottom,
3601 basic_caps(),
3602 );
3603 writer.set_size(80, 24);
3604
3605 let buffer = Buffer::new(80, 5);
3606 writer.present_ui(&buffer, Some((2, 1)), true).unwrap();
3608 }
3609
3610 let expected_pos = b"\x1b[21;3H";
3614
3615 let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
3617 let after_restore = &output[restore_idx..];
3618
3619 assert!(
3621 after_restore
3622 .windows(expected_pos.len())
3623 .any(|w| w == expected_pos),
3624 "Cursor positioning should happen after restore"
3625 );
3626 }
3627
3628 #[test]
3633 fn runtime_diff_config_default() {
3634 let config = RuntimeDiffConfig::default();
3635 assert!(config.bayesian_enabled);
3636 assert!(config.dirty_rows_enabled);
3637 assert!(config.dirty_span_config.enabled);
3638 assert!(config.tile_diff_config.enabled);
3639 assert!(config.reset_on_resize);
3640 assert!(config.reset_on_invalidation);
3641 }
3642
3643 #[test]
3644 fn runtime_diff_config_builder() {
3645 let custom_span = DirtySpanConfig::default().with_max_spans_per_row(8);
3646 let tile_config = TileDiffConfig::default()
3647 .with_enabled(false)
3648 .with_tile_size(24, 12)
3649 .with_dense_tile_ratio(0.75)
3650 .with_max_tiles(2048);
3651 let config = RuntimeDiffConfig::new()
3652 .with_bayesian_enabled(false)
3653 .with_dirty_rows_enabled(false)
3654 .with_dirty_span_config(custom_span)
3655 .with_dirty_spans_enabled(false)
3656 .with_tile_diff_config(tile_config)
3657 .with_reset_on_resize(false)
3658 .with_reset_on_invalidation(false);
3659
3660 assert!(!config.bayesian_enabled);
3661 assert!(!config.dirty_rows_enabled);
3662 assert!(!config.dirty_span_config.enabled);
3663 assert_eq!(config.dirty_span_config.max_spans_per_row, 8);
3664 assert!(!config.tile_diff_config.enabled);
3665 assert_eq!(config.tile_diff_config.tile_w, 24);
3666 assert_eq!(config.tile_diff_config.tile_h, 12);
3667 assert_eq!(config.tile_diff_config.max_tiles, 2048);
3668 assert!(!config.reset_on_resize);
3669 assert!(!config.reset_on_invalidation);
3670 }
3671
3672 #[test]
3673 fn with_diff_config_applies_strategy_config() {
3674 use ftui_render::diff_strategy::DiffStrategyConfig;
3675
3676 let strategy_config = DiffStrategyConfig {
3677 prior_alpha: 5.0,
3678 prior_beta: 5.0,
3679 ..Default::default()
3680 };
3681
3682 let runtime_config =
3683 RuntimeDiffConfig::default().with_strategy_config(strategy_config.clone());
3684
3685 let writer = TerminalWriter::with_diff_config(
3686 Vec::<u8>::new(),
3687 ScreenMode::AltScreen,
3688 UiAnchor::Bottom,
3689 basic_caps(),
3690 runtime_config,
3691 );
3692
3693 let (alpha, beta) = writer.diff_strategy().posterior_params();
3695 assert!((alpha - 5.0).abs() < 0.001);
3696 assert!((beta - 5.0).abs() < 0.001);
3697 }
3698
3699 #[test]
3700 fn with_diff_config_applies_tile_config() {
3701 let tile_config = TileDiffConfig::default()
3702 .with_enabled(false)
3703 .with_tile_size(32, 16)
3704 .with_max_tiles(1024);
3705 let runtime_config = RuntimeDiffConfig::default().with_tile_diff_config(tile_config);
3706
3707 let mut writer = TerminalWriter::with_diff_config(
3708 Vec::<u8>::new(),
3709 ScreenMode::AltScreen,
3710 UiAnchor::Bottom,
3711 basic_caps(),
3712 runtime_config,
3713 );
3714
3715 let applied = writer.diff_scratch.tile_config_mut();
3716 assert!(!applied.enabled);
3717 assert_eq!(applied.tile_w, 32);
3718 assert_eq!(applied.tile_h, 16);
3719 assert_eq!(applied.max_tiles, 1024);
3720 }
3721
3722 #[test]
3723 fn diff_config_accessor() {
3724 let config = RuntimeDiffConfig::default().with_bayesian_enabled(false);
3725
3726 let writer = TerminalWriter::with_diff_config(
3727 Vec::<u8>::new(),
3728 ScreenMode::AltScreen,
3729 UiAnchor::Bottom,
3730 basic_caps(),
3731 config,
3732 );
3733
3734 assert!(!writer.diff_config().bayesian_enabled);
3735 }
3736
3737 #[test]
3738 fn last_diff_strategy_updates_after_present() {
3739 let mut output = Vec::new();
3740 let mut writer = TerminalWriter::with_diff_config(
3741 &mut output,
3742 ScreenMode::AltScreen,
3743 UiAnchor::Bottom,
3744 basic_caps(),
3745 RuntimeDiffConfig::default(),
3746 );
3747 writer.set_size(10, 3);
3748
3749 let mut buffer = Buffer::new(10, 3);
3750 buffer.set_raw(0, 0, Cell::from_char('X'));
3751
3752 assert!(writer.last_diff_strategy().is_none());
3753 writer.present_ui(&buffer, None, false).unwrap();
3754 assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
3755
3756 buffer.set_raw(1, 1, Cell::from_char('Y'));
3757 writer.present_ui(&buffer, None, false).unwrap();
3758 assert!(writer.last_diff_strategy().is_some());
3759 }
3760
3761 #[test]
3762 fn diff_decision_evidence_schema_includes_span_fields() {
3763 let evidence_path = temp_evidence_path("diff_decision_schema");
3764 let sink = EvidenceSink::from_config(
3765 &crate::evidence_sink::EvidenceSinkConfig::enabled_file(&evidence_path),
3766 )
3767 .expect("evidence sink config")
3768 .expect("evidence sink enabled");
3769
3770 let mut writer = TerminalWriter::with_diff_config(
3771 Vec::<u8>::new(),
3772 ScreenMode::AltScreen,
3773 UiAnchor::Bottom,
3774 basic_caps(),
3775 RuntimeDiffConfig::default(),
3776 )
3777 .with_evidence_sink(sink);
3778 writer.set_size(10, 3);
3779
3780 let mut buffer = Buffer::new(10, 3);
3781 buffer.set_raw(0, 0, Cell::from_char('X'));
3782 writer.present_ui(&buffer, None, false).unwrap();
3783
3784 buffer.set_raw(1, 1, Cell::from_char('Y'));
3785 writer.present_ui(&buffer, None, false).unwrap();
3786
3787 let jsonl = std::fs::read_to_string(&evidence_path).expect("read evidence jsonl");
3788 let line = jsonl
3789 .lines()
3790 .find(|line| line.contains("\"event\":\"diff_decision\""))
3791 .expect("diff_decision line");
3792 let value: serde_json::Value = serde_json::from_str(line).expect("valid json");
3793
3794 assert_eq!(
3795 value["schema_version"],
3796 crate::evidence_sink::EVIDENCE_SCHEMA_VERSION
3797 );
3798 assert_eq!(value["event"], "diff_decision");
3799 assert!(
3800 value["run_id"]
3801 .as_str()
3802 .map(|s| !s.is_empty())
3803 .unwrap_or(false),
3804 "run_id should be a non-empty string"
3805 );
3806 assert!(
3807 value["event_idx"].is_number(),
3808 "event_idx should be numeric"
3809 );
3810 assert_eq!(value["screen_mode"], "altscreen");
3811 assert!(value["cols"].is_number(), "cols should be numeric");
3812 assert!(value["rows"].is_number(), "rows should be numeric");
3813 assert!(
3814 value["span_count"].is_number(),
3815 "span_count should be numeric"
3816 );
3817 assert!(
3818 value["span_coverage_pct"].is_number(),
3819 "span_coverage_pct should be numeric"
3820 );
3821 assert!(
3822 value["tile_size"].is_number(),
3823 "tile_size should be numeric"
3824 );
3825 assert!(
3826 value["dirty_tile_count"].is_number(),
3827 "dirty_tile_count should be numeric"
3828 );
3829 assert!(
3830 value["skipped_tile_count"].is_number(),
3831 "skipped_tile_count should be numeric"
3832 );
3833 assert!(
3834 value["sat_build_cost_est"].is_number(),
3835 "sat_build_cost_est should be numeric"
3836 );
3837 assert!(
3838 value["fallback_reason"].is_string(),
3839 "fallback_reason should be string"
3840 );
3841 assert!(
3842 value["scan_cost_estimate"].is_number(),
3843 "scan_cost_estimate should be numeric"
3844 );
3845 assert!(
3846 value["max_span_len"].is_number(),
3847 "max_span_len should be numeric"
3848 );
3849 assert!(
3850 value["guard_reason"].is_string(),
3851 "guard_reason should be a string"
3852 );
3853 assert!(
3854 value["hysteresis_applied"].is_boolean(),
3855 "hysteresis_applied should be boolean"
3856 );
3857 assert!(
3858 value["hysteresis_ratio"].is_number(),
3859 "hysteresis_ratio should be numeric"
3860 );
3861 assert!(
3862 value["fallback_reason"].is_string(),
3863 "fallback_reason should be a string"
3864 );
3865 assert!(
3866 value["scan_cost_estimate"].is_number(),
3867 "scan_cost_estimate should be numeric"
3868 );
3869 }
3870
3871 #[test]
3872 fn diff_strategy_posterior_updates_with_total_cells() {
3873 let mut output = Vec::new();
3874 let mut writer = TerminalWriter::with_diff_config(
3875 &mut output,
3876 ScreenMode::AltScreen,
3877 UiAnchor::Bottom,
3878 basic_caps(),
3879 RuntimeDiffConfig::default(),
3880 );
3881 writer.set_size(10, 10);
3882
3883 let mut buffer = Buffer::new(10, 10);
3884 buffer.set_raw(0, 0, Cell::from_char('A'));
3885 writer.present_ui(&buffer, None, false).unwrap();
3886
3887 let mut buffer2 = Buffer::new(10, 10);
3888 for x in 0..10u16 {
3889 buffer2.set_raw(x, 0, Cell::from_char('X'));
3890 }
3891 writer.present_ui(&buffer2, None, false).unwrap();
3892
3893 let config = writer.diff_strategy().config().clone();
3894 let total_cells = 10usize * 10usize;
3895 let changed = 10usize;
3896 let alpha = config.prior_alpha * config.decay + changed as f64;
3897 let beta = config.prior_beta * config.decay + (total_cells - changed) as f64;
3898 let expected = alpha / (alpha + beta);
3899 let mean = writer.diff_strategy().posterior_mean();
3900 assert!(
3901 (mean - expected).abs() < 1e-9,
3902 "posterior mean should use total_cells; got {mean:.6}, expected {expected:.6}"
3903 );
3904 }
3905
3906 #[test]
3907 fn log_write_without_scroll_region_resets_diff_strategy() {
3908 let mut output = Vec::new();
3911 {
3912 let config = RuntimeDiffConfig::default();
3913 let mut writer = TerminalWriter::with_diff_config(
3914 &mut output,
3915 ScreenMode::Inline { ui_height: 5 },
3916 UiAnchor::Bottom,
3917 basic_caps(), config,
3919 );
3920 writer.set_size(80, 24);
3921
3922 let mut buffer = Buffer::new(80, 5);
3924 buffer.set_raw(0, 0, Cell::from_char('X'));
3925 writer.present_ui(&buffer, None, false).unwrap();
3926
3927 let (_alpha_before, _) = writer.diff_strategy().posterior_params();
3929
3930 buffer.set_raw(1, 1, Cell::from_char('Y'));
3932 writer.present_ui(&buffer, None, false).unwrap();
3933
3934 assert!(!writer.scroll_region_active());
3936 writer.write_log("log message\n").unwrap();
3937
3938 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
3940 assert!(
3941 (alpha_after - 1.0).abs() < 0.01 && (beta_after - 19.0).abs() < 0.01,
3942 "posterior should reset to priors after log write: alpha={}, beta={}",
3943 alpha_after,
3944 beta_after
3945 );
3946 }
3947 }
3948
3949 #[test]
3950 fn log_write_with_scroll_region_preserves_diff_strategy() {
3951 let mut output = Vec::new();
3953 {
3954 let config = RuntimeDiffConfig::default();
3955 let mut writer = TerminalWriter::with_diff_config(
3956 &mut output,
3957 ScreenMode::Inline { ui_height: 5 },
3958 UiAnchor::Bottom,
3959 scroll_region_caps(), config,
3961 );
3962 writer.set_size(80, 24);
3963
3964 let mut buffer = Buffer::new(80, 5);
3966 buffer.set_raw(0, 0, Cell::from_char('X'));
3967 writer.present_ui(&buffer, None, false).unwrap();
3968
3969 buffer.set_raw(1, 1, Cell::from_char('Y'));
3970 writer.present_ui(&buffer, None, false).unwrap();
3971
3972 assert!(writer.scroll_region_active());
3973
3974 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
3976
3977 writer.write_log("log message\n").unwrap();
3979
3980 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
3981 assert!(
3982 (alpha_after - alpha_before).abs() < 0.01
3983 && (beta_after - beta_before).abs() < 0.01,
3984 "posterior should be preserved with scroll region: before=({}, {}), after=({}, {})",
3985 alpha_before,
3986 beta_before,
3987 alpha_after,
3988 beta_after
3989 );
3990 }
3991 }
3992
3993 #[test]
3994 fn strategy_selection_config_flags_applied() {
3995 let config = RuntimeDiffConfig::default()
3997 .with_dirty_rows_enabled(false)
3998 .with_bayesian_enabled(false);
3999
4000 let writer = TerminalWriter::with_diff_config(
4001 Vec::<u8>::new(),
4002 ScreenMode::AltScreen,
4003 UiAnchor::Bottom,
4004 basic_caps(),
4005 config,
4006 );
4007
4008 assert!(!writer.diff_config().dirty_rows_enabled);
4010 assert!(!writer.diff_config().bayesian_enabled);
4011
4012 let (alpha, beta) = writer.diff_strategy().posterior_params();
4014 assert!((alpha - 1.0).abs() < 0.01);
4016 assert!((beta - 19.0).abs() < 0.01);
4017 }
4018
4019 #[test]
4020 fn resize_respects_reset_toggle() {
4021 let config = RuntimeDiffConfig::default().with_reset_on_resize(false);
4023
4024 let mut writer = TerminalWriter::with_diff_config(
4025 Vec::<u8>::new(),
4026 ScreenMode::AltScreen,
4027 UiAnchor::Bottom,
4028 basic_caps(),
4029 config,
4030 );
4031 writer.set_size(80, 24);
4032
4033 let mut buffer = Buffer::new(80, 24);
4035 buffer.set_raw(0, 0, Cell::from_char('X'));
4036 writer.present_ui(&buffer, None, false).unwrap();
4037
4038 let mut buffer2 = Buffer::new(80, 24);
4039 buffer2.set_raw(1, 1, Cell::from_char('Y'));
4040 writer.present_ui(&buffer2, None, false).unwrap();
4041
4042 let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4044
4045 writer.set_size(100, 30);
4047
4048 let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4049 assert!(
4050 (alpha_after - alpha_before).abs() < 0.01 && (beta_after - beta_before).abs() < 0.01,
4051 "posterior should be preserved when reset_on_resize=false"
4052 );
4053 }
4054}