1#![forbid(unsafe_code)]
2
3use std::io::{self, Write};
23
24use unicode_segmentation::UnicodeSegmentation;
25
26use crate::terminal_capabilities::TerminalCapabilities;
27
28const CURSOR_SAVE: &[u8] = b"\x1b7";
34
35const CURSOR_RESTORE: &[u8] = b"\x1b8";
37
38fn cursor_position(row: u16, col: u16) -> Vec<u8> {
40 format!("\x1b[{};{}H", row, col).into_bytes()
41}
42
43fn set_scroll_region(top: u16, bottom: u16) -> Vec<u8> {
45 format!("\x1b[{};{}r", top, bottom).into_bytes()
46}
47
48const RESET_SCROLL_REGION: &[u8] = b"\x1b[r";
50
51#[allow(dead_code)] const ERASE_TO_EOL: &[u8] = b"\x1b[0K";
54
55const ERASE_LINE: &[u8] = b"\x1b[2K";
57
58const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
60
61const SYNC_END: &[u8] = b"\x1b[?2026l";
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum InlineStrategy {
71 ScrollRegion,
74
75 OverlayRedraw,
78
79 #[default]
85 Hybrid,
86}
87
88impl InlineStrategy {
89 #[must_use]
100 pub fn select(caps: &TerminalCapabilities) -> Self {
101 if caps.in_any_mux() {
102 InlineStrategy::OverlayRedraw
104 } else if caps.use_scroll_region() && caps.use_sync_output() {
105 InlineStrategy::ScrollRegion
107 } else if caps.use_scroll_region() {
108 InlineStrategy::Hybrid
110 } else {
111 InlineStrategy::OverlayRedraw
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy)]
123pub struct InlineConfig {
124 pub ui_height: u16,
126
127 pub term_height: u16,
129
130 pub term_width: u16,
132
133 pub strategy: InlineStrategy,
135
136 pub use_sync_output: bool,
138}
139
140impl InlineConfig {
141 #[must_use]
143 pub fn new(ui_height: u16, term_height: u16, term_width: u16) -> Self {
144 Self {
145 ui_height,
146 term_height,
147 term_width,
148 strategy: InlineStrategy::default(),
149 use_sync_output: false,
150 }
151 }
152
153 #[must_use]
155 pub const fn with_strategy(mut self, strategy: InlineStrategy) -> Self {
156 self.strategy = strategy;
157 self
158 }
159
160 #[must_use]
162 pub const fn with_sync_output(mut self, enabled: bool) -> Self {
163 self.use_sync_output = enabled;
164 self
165 }
166
167 #[must_use]
171 pub const fn ui_top_row(&self) -> u16 {
172 let row = self
173 .term_height
174 .saturating_sub(self.ui_height)
175 .saturating_add(1);
176 if row == 0 { 1 } else { row }
178 }
179
180 #[must_use]
185 pub const fn log_bottom_row(&self) -> u16 {
186 self.ui_top_row().saturating_sub(1)
187 }
188
189 #[must_use]
193 pub const fn is_valid(&self) -> bool {
194 self.ui_height > 0 && self.ui_height < self.term_height && self.term_height > 1
195 }
196}
197
198pub struct InlineRenderer<W: Write> {
207 writer: W,
208 config: InlineConfig,
209 scroll_region_set: bool,
210 in_sync_block: bool,
211 cursor_saved: bool,
212}
213
214impl<W: Write> InlineRenderer<W> {
215 pub fn new(writer: W, config: InlineConfig) -> Self {
221 Self {
222 writer,
223 config,
224 scroll_region_set: false,
225 in_sync_block: false,
226 cursor_saved: false,
227 }
228 }
229
230 #[inline]
231 fn sync_output_enabled(&self) -> bool {
232 self.config.use_sync_output && TerminalCapabilities::with_overrides().use_sync_output()
233 }
234
235 pub fn enter(&mut self) -> io::Result<()> {
240 match self.config.strategy {
241 InlineStrategy::ScrollRegion => {
242 let log_bottom = self.config.log_bottom_row();
244 if log_bottom > 0 {
245 self.writer.write_all(&set_scroll_region(1, log_bottom))?;
246 self.scroll_region_set = true;
247 }
248 }
249 InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
250 }
254 }
255 self.writer.flush()
256 }
257
258 pub fn exit(&mut self) -> io::Result<()> {
260 self.cleanup_internal()
261 }
262
263 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
271 let log_row = self.config.log_bottom_row();
272
273 if log_row == 0 {
275 return Ok(());
276 }
277
278 match self.config.strategy {
279 InlineStrategy::ScrollRegion => {
280 let safe_text = Self::sanitize_scroll_region_log_text(text);
282 if !safe_text.is_empty() {
283 self.writer.write_all(safe_text.as_bytes())?;
284 }
285 }
286 InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
287 self.writer.write_all(CURSOR_SAVE)?;
289 self.cursor_saved = true;
290
291 self.writer.write_all(&cursor_position(log_row, 1))?;
293 self.writer.write_all(ERASE_LINE)?;
294
295 let safe_line =
298 sanitize_overlay_log_line(text, usize::from(self.config.term_width));
299 if !safe_line.is_empty() {
300 self.writer.write_all(safe_line.as_bytes())?;
301 }
302
303 self.writer.write_all(CURSOR_RESTORE)?;
305 self.cursor_saved = false;
306 }
307 }
308 self.writer.flush()
309 }
310
311 pub fn present_ui<F>(&mut self, render_fn: F) -> io::Result<()>
318 where
319 F: FnOnce(&mut W, &InlineConfig) -> io::Result<()>,
320 {
321 if !self.config.is_valid() {
322 return Err(io::Error::new(
323 io::ErrorKind::InvalidInput,
324 "invalid inline mode configuration",
325 ));
326 }
327
328 let sync_output_enabled = self.sync_output_enabled();
329
330 if sync_output_enabled && !self.in_sync_block {
332 self.in_sync_block = true;
335 if let Err(err) = self.writer.write_all(SYNC_BEGIN) {
336 let _ = self.writer.write_all(SYNC_END);
339 self.in_sync_block = false;
340 let _ = self.writer.flush();
341 return Err(err);
342 }
343 }
344
345 self.writer.write_all(CURSOR_SAVE)?;
347 self.cursor_saved = true;
348
349 let operation_result = (|| -> io::Result<()> {
350 let ui_row = self.config.ui_top_row();
352 self.writer.write_all(&cursor_position(ui_row, 1))?;
353
354 for row in 0..self.config.ui_height {
356 self.writer
357 .write_all(&cursor_position(ui_row.saturating_add(row), 1))?;
358 self.writer.write_all(ERASE_LINE)?;
359 }
360
361 self.writer.write_all(&cursor_position(ui_row, 1))?;
363 render_fn(&mut self.writer, &self.config)?;
364 Ok(())
365 })();
366
367 let restore_result = self.writer.write_all(CURSOR_RESTORE);
369 if restore_result.is_ok() {
370 self.cursor_saved = false;
371 }
372
373 let sync_end_result = if sync_output_enabled && self.in_sync_block {
374 let res = self.writer.write_all(SYNC_END);
375 if res.is_ok() {
376 self.in_sync_block = false;
377 }
378 Some(res)
379 } else {
380 if !sync_output_enabled {
381 self.in_sync_block = false;
384 }
385 None
386 };
387
388 let flush_result = self.writer.flush();
389
390 let cleanup_error = restore_result
393 .err()
394 .or_else(|| sync_end_result.and_then(Result::err))
395 .or_else(|| flush_result.err());
396 if let Some(err) = cleanup_error {
397 return Err(err);
398 }
399 operation_result
400 }
401
402 fn sanitize_scroll_region_log_text(text: &str) -> String {
403 let bytes = text.as_bytes();
404 let mut out = String::with_capacity(text.len());
405 let mut i = 0;
406
407 while i < bytes.len() {
408 match bytes[i] {
409 0x1B => {
411 i = Self::skip_escape_sequence(bytes, i);
412 }
413 0x0A => {
415 out.push('\n');
416 i += 1;
417 }
418 0x0D => {
419 out.push('\n');
420 i += 1;
421 }
422 0x00..=0x1F | 0x7F => {
424 i += 1;
425 }
426 0x20..=0x7E => {
428 out.push(bytes[i] as char);
429 i += 1;
430 }
431 _ => {
433 if let Some((ch, len)) = Self::decode_utf8_char(&bytes[i..]) {
434 if !('\u{0080}'..='\u{009F}').contains(&ch) {
435 out.push(ch);
436 }
437 i += len;
438 } else {
439 i += 1;
440 }
441 }
442 }
443 }
444
445 out
446 }
447
448 fn skip_escape_sequence(bytes: &[u8], start: usize) -> usize {
449 let mut i = start + 1; if i >= bytes.len() {
451 return i;
452 }
453
454 match bytes[i] {
455 b'[' => {
457 i += 1;
458 while i < bytes.len() {
459 let b = bytes[i];
460 if (0x40..=0x7E).contains(&b) {
461 return i + 1;
462 }
463 if !(0x20..=0x3F).contains(&b) {
464 return i;
465 }
466 i += 1;
467 }
468 }
469 b']' => {
471 i += 1;
472 while i < bytes.len() {
473 let b = bytes[i];
474 if b == 0x07 {
475 return i + 1;
476 }
477 if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
478 return i + 2;
479 }
480 if b == 0x1B || b < 0x20 {
481 return i;
482 }
483 i += 1;
484 }
485 }
486 b'P' | b'^' | b'_' => {
488 i += 1;
489 while i < bytes.len() {
490 let b = bytes[i];
491 if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
492 return i + 2;
493 }
494 if b == 0x1B || b < 0x20 {
495 return i;
496 }
497 i += 1;
498 }
499 }
500 0x20..=0x7E => return i + 1,
502 _ => {}
503 }
504
505 i
506 }
507
508 fn decode_utf8_char(bytes: &[u8]) -> Option<(char, usize)> {
509 if bytes.is_empty() {
510 return None;
511 }
512
513 let first = bytes[0];
514 let (expected_len, mut codepoint) = match first {
515 0x00..=0x7F => return Some((first as char, 1)),
516 0xC0..=0xDF => (2, (first & 0x1F) as u32),
517 0xE0..=0xEF => (3, (first & 0x0F) as u32),
518 0xF0..=0xF7 => (4, (first & 0x07) as u32),
519 _ => return None,
520 };
521
522 if bytes.len() < expected_len {
523 return None;
524 }
525
526 for &b in bytes.iter().take(expected_len).skip(1) {
527 if (b & 0xC0) != 0x80 {
528 return None;
529 }
530 codepoint = (codepoint << 6) | (b & 0x3F) as u32;
531 }
532
533 let min_codepoint = match expected_len {
534 2 => 0x80,
535 3 => 0x800,
536 4 => 0x1_0000,
537 _ => return None,
538 };
539 if codepoint < min_codepoint {
540 return None;
541 }
542
543 char::from_u32(codepoint).map(|c| (c, expected_len))
544 }
545
546 fn cleanup_internal(&mut self) -> io::Result<()> {
548 let sync_output_enabled = self.sync_output_enabled();
549
550 if self.in_sync_block {
552 if sync_output_enabled {
553 let _ = self.writer.write_all(SYNC_END);
554 }
555 self.in_sync_block = false;
556 }
557
558 if self.scroll_region_set {
560 let _ = self.writer.write_all(RESET_SCROLL_REGION);
561 self.scroll_region_set = false;
562 }
563
564 if self.cursor_saved {
566 let _ = self.writer.write_all(CURSOR_RESTORE);
567 self.cursor_saved = false;
568 }
569
570 self.writer.flush()
571 }
572}
573
574pub fn sanitize_overlay_log_line(text: &str, max_cols: usize) -> String {
583 if max_cols == 0 {
584 return String::new();
585 }
586
587 let mut out = String::new();
588 let mut used_cols = 0usize;
589
590 for grapheme in text.graphemes(true) {
597 if grapheme.starts_with('\n') || grapheme.starts_with('\r') {
599 break;
600 }
601
602 if grapheme.chars().all(char::is_control) {
605 continue;
606 }
607
608 let width = crate::text_width::grapheme_width(grapheme);
609 if width == 0 {
610 if !out.is_empty() {
612 out.push_str(grapheme);
613 }
614 continue;
615 }
616
617 if used_cols.saturating_add(width) > max_cols {
618 break;
619 }
620
621 out.push_str(grapheme);
622 used_cols += width;
623 if used_cols == max_cols {
624 break;
625 }
626 }
627
628 out
629}
630
631impl<W: Write> Drop for InlineRenderer<W> {
632 fn drop(&mut self) {
633 let _ = self.cleanup_internal();
635 }
636}
637
638#[cfg(test)]
643mod tests {
644 use super::*;
645 use std::io::Cursor;
646
647 type TestWriter = Cursor<Vec<u8>>;
648
649 fn test_writer() -> TestWriter {
650 Cursor::new(Vec::new())
651 }
652
653 fn writer_contains_sequence(writer: &TestWriter, seq: &[u8]) -> bool {
654 writer
655 .get_ref()
656 .windows(seq.len())
657 .any(|window| window == seq)
658 }
659
660 fn writer_clear(writer: &mut TestWriter) {
661 writer.get_mut().clear();
662 }
663
664 fn sync_policy_allows() -> bool {
665 TerminalCapabilities::with_overrides().use_sync_output()
666 }
667
668 #[test]
669 fn config_calculates_regions_correctly() {
670 let config = InlineConfig::new(6, 24, 80);
672 assert_eq!(config.ui_top_row(), 19); assert_eq!(config.log_bottom_row(), 18); }
675
676 #[test]
677 fn strategy_selection_prefers_overlay_in_mux() {
678 let mut caps = TerminalCapabilities::basic();
679 caps.in_tmux = true;
680 caps.scroll_region = true;
681 caps.sync_output = true;
682
683 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
684 }
685
686 #[test]
687 fn strategy_selection_uses_scroll_region_in_modern_terminal() {
688 let mut caps = TerminalCapabilities::basic();
689 caps.scroll_region = true;
690 caps.sync_output = true;
691
692 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::ScrollRegion);
693 }
694
695 #[test]
696 fn strategy_selection_uses_hybrid_without_sync() {
697 let mut caps = TerminalCapabilities::basic();
698 caps.scroll_region = true;
699 caps.sync_output = false;
700
701 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::Hybrid);
702 }
703
704 #[test]
705 fn enter_sets_scroll_region_for_scroll_strategy() {
706 let writer = test_writer();
707 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
708 let mut renderer = InlineRenderer::new(writer, config);
709
710 renderer.enter().unwrap();
711
712 assert!(writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
714 }
715
716 #[test]
717 fn exit_resets_scroll_region() {
718 let writer = test_writer();
719 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
720 let mut renderer = InlineRenderer::new(writer, config);
721
722 renderer.enter().unwrap();
723 renderer.exit().unwrap();
724
725 assert!(writer_contains_sequence(
727 &renderer.writer,
728 RESET_SCROLL_REGION
729 ));
730 }
731
732 #[test]
733 fn present_ui_saves_and_restores_cursor() {
734 let writer = test_writer();
735 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
736 let mut renderer = InlineRenderer::new(writer, config);
737
738 renderer
739 .present_ui(|w, _| {
740 w.write_all(b"UI Content")?;
741 Ok(())
742 })
743 .unwrap();
744
745 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
747 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
749 }
750
751 #[test]
752 fn present_ui_uses_sync_output_when_enabled() {
753 let writer = test_writer();
754 let config = InlineConfig::new(6, 24, 80)
755 .with_strategy(InlineStrategy::OverlayRedraw)
756 .with_sync_output(true);
757 let mut renderer = InlineRenderer::new(writer, config);
758
759 renderer.present_ui(|_, _| Ok(())).unwrap();
760
761 if sync_policy_allows() {
762 assert!(writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
763 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
764 } else {
765 assert!(!writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
766 assert!(!writer_contains_sequence(&renderer.writer, SYNC_END));
767 }
768 }
769
770 #[test]
771 fn drop_cleans_up_scroll_region() {
772 let writer = test_writer();
773 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
774
775 {
776 let mut renderer = InlineRenderer::new(writer, config);
777 renderer.enter().unwrap();
778 }
780
781 }
783
784 #[test]
785 fn write_log_preserves_cursor_in_overlay_mode() {
786 let writer = test_writer();
787 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
788 let mut renderer = InlineRenderer::new(writer, config);
789
790 renderer.write_log("test log\n").unwrap();
791
792 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
794 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
795 }
796
797 #[test]
798 fn write_log_overlay_truncates_to_single_safe_line() {
799 let writer = test_writer();
800 let config = InlineConfig::new(6, 24, 5).with_strategy(InlineStrategy::OverlayRedraw);
801 let mut renderer = InlineRenderer::new(writer, config);
802
803 renderer.write_log("ABCDE\nSECOND").unwrap();
804
805 let output = String::from_utf8_lossy(renderer.writer.get_ref());
806 assert!(output.contains("ABCDE"));
807 assert!(!output.contains("SECOND"));
808 assert!(!output.contains('\n'));
809 }
810
811 #[test]
812 fn write_log_overlay_truncates_wide_chars_by_display_width() {
813 let writer = test_writer();
814 let config = InlineConfig::new(6, 24, 3).with_strategy(InlineStrategy::OverlayRedraw);
815 let mut renderer = InlineRenderer::new(writer, config);
816
817 renderer.write_log("ab界Z").unwrap();
818
819 let output = String::from_utf8_lossy(renderer.writer.get_ref());
820 assert!(output.contains("ab"));
821 assert!(!output.contains('界'));
822 assert!(!output.contains('Z'));
823 }
824
825 #[test]
826 fn write_log_overlay_allows_wide_char_when_it_exactly_fits_width() {
827 let writer = test_writer();
828 let config = InlineConfig::new(6, 24, 4).with_strategy(InlineStrategy::OverlayRedraw);
829 let mut renderer = InlineRenderer::new(writer, config);
830
831 renderer.write_log("ab界Z").unwrap();
832
833 let output = String::from_utf8_lossy(renderer.writer.get_ref());
834 assert!(output.contains("ab界"));
835 assert!(!output.contains('Z'));
836 }
837
838 #[test]
839 fn hybrid_does_not_set_scroll_region_in_enter() {
840 let writer = test_writer();
841 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::Hybrid);
842 let mut renderer = InlineRenderer::new(writer, config);
843
844 renderer.enter().unwrap();
845
846 assert!(!writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
848 assert!(!renderer.scroll_region_set);
849 }
850
851 #[test]
852 fn config_is_valid_checks_boundaries() {
853 let valid = InlineConfig::new(6, 24, 80);
855 assert!(valid.is_valid());
856
857 let full_ui = InlineConfig::new(24, 24, 80);
859 assert!(!full_ui.is_valid());
860
861 let no_ui = InlineConfig::new(0, 24, 80);
863 assert!(!no_ui.is_valid());
864
865 let tiny = InlineConfig::new(1, 1, 80);
867 assert!(!tiny.is_valid());
868 }
869
870 #[test]
871 fn log_bottom_row_zero_when_no_room() {
872 let config = InlineConfig::new(24, 24, 80);
874 assert_eq!(config.log_bottom_row(), 0);
875 }
876
877 #[test]
878 fn write_log_silently_drops_when_no_log_region() {
879 let writer = test_writer();
880 let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
882 let mut renderer = InlineRenderer::new(writer, config);
883
884 renderer.write_log("test log\n").unwrap();
886
887 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
889 }
890
891 #[test]
892 fn cleanup_does_not_restore_unsaved_cursor() {
893 let writer = test_writer();
894 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
895 let mut renderer = InlineRenderer::new(writer, config);
896
897 renderer.enter().unwrap();
899 writer_clear(&mut renderer.writer); renderer.exit().unwrap();
901
902 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
904 }
905
906 #[test]
907 fn inline_strategy_default_is_hybrid() {
908 assert_eq!(InlineStrategy::default(), InlineStrategy::Hybrid);
909 }
910
911 #[test]
912 fn config_ui_top_row_clamps_to_1() {
913 let config = InlineConfig::new(30, 24, 80);
915 assert!(config.ui_top_row() >= 1);
916 }
917
918 #[test]
919 fn strategy_select_fallback_no_scroll_no_sync() {
920 let mut caps = TerminalCapabilities::basic();
921 caps.scroll_region = false;
922 caps.sync_output = false;
923 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
924 }
925
926 #[test]
927 fn write_log_in_scroll_region_mode() {
928 let writer = test_writer();
929 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
930 let mut renderer = InlineRenderer::new(writer, config);
931
932 renderer.enter().unwrap();
933 renderer.write_log("hello\n").unwrap();
934
935 let output = renderer.writer.get_ref();
937 assert!(output.windows(b"hello\n".len()).any(|w| w == b"hello\n"));
938 }
939
940 #[test]
941 fn write_log_in_scroll_region_mode_sanitizes_escape_payloads() {
942 let writer = test_writer();
943 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
944 let mut renderer = InlineRenderer::new(writer, config);
945
946 renderer.enter().unwrap();
947 renderer
948 .write_log("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x\n")
949 .unwrap();
950
951 let output = String::from_utf8_lossy(renderer.writer.get_ref());
952 assert!(output.contains("safetailx\n"));
953 assert!(
954 !output.contains("52;c;SGVsbG8"),
955 "OSC payload should not survive scroll-region log sanitization"
956 );
957 assert!(
958 !output.contains('\u{009d}'),
959 "C1 controls must be stripped in scroll-region logging"
960 );
961 }
962
963 #[test]
964 fn present_ui_clears_ui_lines() {
965 let writer = test_writer();
966 let config = InlineConfig::new(2, 10, 80).with_strategy(InlineStrategy::OverlayRedraw);
967 let mut renderer = InlineRenderer::new(writer, config);
968
969 renderer.present_ui(|_, _| Ok(())).unwrap();
970
971 let count = renderer
973 .writer
974 .get_ref()
975 .windows(ERASE_LINE.len())
976 .filter(|w| *w == ERASE_LINE)
977 .count();
978 assert_eq!(count, 2);
979 }
980
981 #[test]
982 fn present_ui_render_error_still_restores_state() {
983 let writer = test_writer();
984 let config = InlineConfig::new(2, 10, 80)
985 .with_strategy(InlineStrategy::OverlayRedraw)
986 .with_sync_output(true);
987 let mut renderer = InlineRenderer::new(writer, config);
988
989 let err = renderer
990 .present_ui(|_, _| Err(io::Error::other("boom")))
991 .unwrap_err();
992 assert_eq!(err.kind(), io::ErrorKind::Other);
993
994 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
995 if sync_policy_allows() {
996 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
997 } else {
998 assert!(!writer_contains_sequence(&renderer.writer, SYNC_END));
999 }
1000 assert!(!renderer.cursor_saved);
1001 assert!(!renderer.in_sync_block);
1002 }
1003
1004 #[test]
1005 fn cleanup_skips_sync_end_when_sync_output_disabled() {
1006 let writer = test_writer();
1007 let config = InlineConfig::new(2, 10, 80)
1008 .with_strategy(InlineStrategy::OverlayRedraw)
1009 .with_sync_output(false);
1010 let mut renderer = InlineRenderer::new(writer, config);
1011 renderer.in_sync_block = true;
1012
1013 renderer.cleanup_internal().unwrap();
1014
1015 assert!(
1016 !writer_contains_sequence(&renderer.writer, SYNC_END),
1017 "sync_end must not be emitted when synchronized output is disabled"
1018 );
1019 assert!(!renderer.in_sync_block);
1020 }
1021
1022 #[test]
1023 fn present_ui_rejects_invalid_config() {
1024 let writer = test_writer();
1025 let config = InlineConfig::new(0, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
1026 let mut renderer = InlineRenderer::new(writer, config);
1027
1028 let err = renderer.present_ui(|_, _| Ok(())).unwrap_err();
1029 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1030 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
1031 }
1032
1033 #[test]
1034 fn config_new_defaults() {
1035 let config = InlineConfig::new(5, 20, 100);
1036 assert_eq!(config.ui_height, 5);
1037 assert_eq!(config.term_height, 20);
1038 assert_eq!(config.term_width, 100);
1039 assert_eq!(config.strategy, InlineStrategy::Hybrid);
1040 assert!(!config.use_sync_output);
1041 }
1042
1043 #[test]
1049 fn the_overlay_clamp_measures_width_the_way_the_rest_of_the_crate_does() {
1050 let half_flag = "\u{1F1EB}";
1051 assert_eq!(crate::text_width::grapheme_width(half_flag), 2);
1052 assert_eq!(sanitize_overlay_log_line(half_flag, 1), "");
1053 assert_eq!(sanitize_overlay_log_line(half_flag, 2), half_flag);
1054
1055 for text in [
1057 "\u{1F1EB}\u{1F1F7}",
1058 "\u{1F1EB}a",
1059 "中中中",
1060 "a\u{301}bc",
1061 "🦀🦀",
1062 "ab",
1063 ] {
1064 for cols in 0..8usize {
1065 let out = sanitize_overlay_log_line(text, cols);
1066 assert!(
1067 crate::text_width::display_width(&out) <= cols,
1068 "{text:?} at {cols} columns produced {out:?}"
1069 );
1070 }
1071 }
1072 }
1073
1074 #[test]
1077 fn the_overlay_clamp_keeps_one_line_and_drops_cursor_motion() {
1078 assert_eq!(sanitize_overlay_log_line("a\nb", 40), "a");
1079 assert_eq!(sanitize_overlay_log_line("a\r\nb", 40), "a");
1080 assert_eq!(sanitize_overlay_log_line("a\u{1b}[2Jb\u{7}c", 40), "a[2Jbc");
1081 let out = sanitize_overlay_log_line("x\u{1b}[31my\nz", 40);
1082 assert!(
1083 !out.contains('\n') && !out.chars().any(char::is_control),
1084 "{out:?}"
1085 );
1086 }
1087}