1#![forbid(unsafe_code)]
2
3use std::io::{self, Write};
23
24use unicode_width::UnicodeWidthChar;
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 ch in text.chars() {
591 if ch == '\n' || ch == '\r' {
592 break;
593 }
594
595 if ch.is_control() {
597 continue;
598 }
599
600 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
601 if ch_width == 0 {
602 if !out.is_empty() {
604 out.push(ch);
605 }
606 continue;
607 }
608
609 if used_cols.saturating_add(ch_width) > max_cols {
610 break;
611 }
612
613 out.push(ch);
614 used_cols += ch_width;
615 if used_cols == max_cols {
616 break;
617 }
618 }
619
620 out
621}
622
623impl<W: Write> Drop for InlineRenderer<W> {
624 fn drop(&mut self) {
625 let _ = self.cleanup_internal();
627 }
628}
629
630#[cfg(test)]
635mod tests {
636 use super::*;
637 use std::io::Cursor;
638
639 type TestWriter = Cursor<Vec<u8>>;
640
641 fn test_writer() -> TestWriter {
642 Cursor::new(Vec::new())
643 }
644
645 fn writer_contains_sequence(writer: &TestWriter, seq: &[u8]) -> bool {
646 writer
647 .get_ref()
648 .windows(seq.len())
649 .any(|window| window == seq)
650 }
651
652 fn writer_clear(writer: &mut TestWriter) {
653 writer.get_mut().clear();
654 }
655
656 fn sync_policy_allows() -> bool {
657 TerminalCapabilities::with_overrides().use_sync_output()
658 }
659
660 #[test]
661 fn config_calculates_regions_correctly() {
662 let config = InlineConfig::new(6, 24, 80);
664 assert_eq!(config.ui_top_row(), 19); assert_eq!(config.log_bottom_row(), 18); }
667
668 #[test]
669 fn strategy_selection_prefers_overlay_in_mux() {
670 let mut caps = TerminalCapabilities::basic();
671 caps.in_tmux = true;
672 caps.scroll_region = true;
673 caps.sync_output = true;
674
675 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
676 }
677
678 #[test]
679 fn strategy_selection_uses_scroll_region_in_modern_terminal() {
680 let mut caps = TerminalCapabilities::basic();
681 caps.scroll_region = true;
682 caps.sync_output = true;
683
684 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::ScrollRegion);
685 }
686
687 #[test]
688 fn strategy_selection_uses_hybrid_without_sync() {
689 let mut caps = TerminalCapabilities::basic();
690 caps.scroll_region = true;
691 caps.sync_output = false;
692
693 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::Hybrid);
694 }
695
696 #[test]
697 fn enter_sets_scroll_region_for_scroll_strategy() {
698 let writer = test_writer();
699 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
700 let mut renderer = InlineRenderer::new(writer, config);
701
702 renderer.enter().unwrap();
703
704 assert!(writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
706 }
707
708 #[test]
709 fn exit_resets_scroll_region() {
710 let writer = test_writer();
711 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
712 let mut renderer = InlineRenderer::new(writer, config);
713
714 renderer.enter().unwrap();
715 renderer.exit().unwrap();
716
717 assert!(writer_contains_sequence(
719 &renderer.writer,
720 RESET_SCROLL_REGION
721 ));
722 }
723
724 #[test]
725 fn present_ui_saves_and_restores_cursor() {
726 let writer = test_writer();
727 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
728 let mut renderer = InlineRenderer::new(writer, config);
729
730 renderer
731 .present_ui(|w, _| {
732 w.write_all(b"UI Content")?;
733 Ok(())
734 })
735 .unwrap();
736
737 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
739 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
741 }
742
743 #[test]
744 fn present_ui_uses_sync_output_when_enabled() {
745 let writer = test_writer();
746 let config = InlineConfig::new(6, 24, 80)
747 .with_strategy(InlineStrategy::OverlayRedraw)
748 .with_sync_output(true);
749 let mut renderer = InlineRenderer::new(writer, config);
750
751 renderer.present_ui(|_, _| Ok(())).unwrap();
752
753 if sync_policy_allows() {
754 assert!(writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
755 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
756 } else {
757 assert!(!writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
758 assert!(!writer_contains_sequence(&renderer.writer, SYNC_END));
759 }
760 }
761
762 #[test]
763 fn drop_cleans_up_scroll_region() {
764 let writer = test_writer();
765 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
766
767 {
768 let mut renderer = InlineRenderer::new(writer, config);
769 renderer.enter().unwrap();
770 }
772
773 }
775
776 #[test]
777 fn write_log_preserves_cursor_in_overlay_mode() {
778 let writer = test_writer();
779 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
780 let mut renderer = InlineRenderer::new(writer, config);
781
782 renderer.write_log("test log\n").unwrap();
783
784 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
786 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
787 }
788
789 #[test]
790 fn write_log_overlay_truncates_to_single_safe_line() {
791 let writer = test_writer();
792 let config = InlineConfig::new(6, 24, 5).with_strategy(InlineStrategy::OverlayRedraw);
793 let mut renderer = InlineRenderer::new(writer, config);
794
795 renderer.write_log("ABCDE\nSECOND").unwrap();
796
797 let output = String::from_utf8_lossy(renderer.writer.get_ref());
798 assert!(output.contains("ABCDE"));
799 assert!(!output.contains("SECOND"));
800 assert!(!output.contains('\n'));
801 }
802
803 #[test]
804 fn write_log_overlay_truncates_wide_chars_by_display_width() {
805 let writer = test_writer();
806 let config = InlineConfig::new(6, 24, 3).with_strategy(InlineStrategy::OverlayRedraw);
807 let mut renderer = InlineRenderer::new(writer, config);
808
809 renderer.write_log("ab界Z").unwrap();
810
811 let output = String::from_utf8_lossy(renderer.writer.get_ref());
812 assert!(output.contains("ab"));
813 assert!(!output.contains('界'));
814 assert!(!output.contains('Z'));
815 }
816
817 #[test]
818 fn write_log_overlay_allows_wide_char_when_it_exactly_fits_width() {
819 let writer = test_writer();
820 let config = InlineConfig::new(6, 24, 4).with_strategy(InlineStrategy::OverlayRedraw);
821 let mut renderer = InlineRenderer::new(writer, config);
822
823 renderer.write_log("ab界Z").unwrap();
824
825 let output = String::from_utf8_lossy(renderer.writer.get_ref());
826 assert!(output.contains("ab界"));
827 assert!(!output.contains('Z'));
828 }
829
830 #[test]
831 fn hybrid_does_not_set_scroll_region_in_enter() {
832 let writer = test_writer();
833 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::Hybrid);
834 let mut renderer = InlineRenderer::new(writer, config);
835
836 renderer.enter().unwrap();
837
838 assert!(!writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
840 assert!(!renderer.scroll_region_set);
841 }
842
843 #[test]
844 fn config_is_valid_checks_boundaries() {
845 let valid = InlineConfig::new(6, 24, 80);
847 assert!(valid.is_valid());
848
849 let full_ui = InlineConfig::new(24, 24, 80);
851 assert!(!full_ui.is_valid());
852
853 let no_ui = InlineConfig::new(0, 24, 80);
855 assert!(!no_ui.is_valid());
856
857 let tiny = InlineConfig::new(1, 1, 80);
859 assert!(!tiny.is_valid());
860 }
861
862 #[test]
863 fn log_bottom_row_zero_when_no_room() {
864 let config = InlineConfig::new(24, 24, 80);
866 assert_eq!(config.log_bottom_row(), 0);
867 }
868
869 #[test]
870 fn write_log_silently_drops_when_no_log_region() {
871 let writer = test_writer();
872 let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
874 let mut renderer = InlineRenderer::new(writer, config);
875
876 renderer.write_log("test log\n").unwrap();
878
879 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
881 }
882
883 #[test]
884 fn cleanup_does_not_restore_unsaved_cursor() {
885 let writer = test_writer();
886 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
887 let mut renderer = InlineRenderer::new(writer, config);
888
889 renderer.enter().unwrap();
891 writer_clear(&mut renderer.writer); renderer.exit().unwrap();
893
894 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
896 }
897
898 #[test]
899 fn inline_strategy_default_is_hybrid() {
900 assert_eq!(InlineStrategy::default(), InlineStrategy::Hybrid);
901 }
902
903 #[test]
904 fn config_ui_top_row_clamps_to_1() {
905 let config = InlineConfig::new(30, 24, 80);
907 assert!(config.ui_top_row() >= 1);
908 }
909
910 #[test]
911 fn strategy_select_fallback_no_scroll_no_sync() {
912 let mut caps = TerminalCapabilities::basic();
913 caps.scroll_region = false;
914 caps.sync_output = false;
915 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
916 }
917
918 #[test]
919 fn write_log_in_scroll_region_mode() {
920 let writer = test_writer();
921 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
922 let mut renderer = InlineRenderer::new(writer, config);
923
924 renderer.enter().unwrap();
925 renderer.write_log("hello\n").unwrap();
926
927 let output = renderer.writer.get_ref();
929 assert!(output.windows(b"hello\n".len()).any(|w| w == b"hello\n"));
930 }
931
932 #[test]
933 fn write_log_in_scroll_region_mode_sanitizes_escape_payloads() {
934 let writer = test_writer();
935 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
936 let mut renderer = InlineRenderer::new(writer, config);
937
938 renderer.enter().unwrap();
939 renderer
940 .write_log("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x\n")
941 .unwrap();
942
943 let output = String::from_utf8_lossy(renderer.writer.get_ref());
944 assert!(output.contains("safetailx\n"));
945 assert!(
946 !output.contains("52;c;SGVsbG8"),
947 "OSC payload should not survive scroll-region log sanitization"
948 );
949 assert!(
950 !output.contains('\u{009d}'),
951 "C1 controls must be stripped in scroll-region logging"
952 );
953 }
954
955 #[test]
956 fn present_ui_clears_ui_lines() {
957 let writer = test_writer();
958 let config = InlineConfig::new(2, 10, 80).with_strategy(InlineStrategy::OverlayRedraw);
959 let mut renderer = InlineRenderer::new(writer, config);
960
961 renderer.present_ui(|_, _| Ok(())).unwrap();
962
963 let count = renderer
965 .writer
966 .get_ref()
967 .windows(ERASE_LINE.len())
968 .filter(|w| *w == ERASE_LINE)
969 .count();
970 assert_eq!(count, 2);
971 }
972
973 #[test]
974 fn present_ui_render_error_still_restores_state() {
975 let writer = test_writer();
976 let config = InlineConfig::new(2, 10, 80)
977 .with_strategy(InlineStrategy::OverlayRedraw)
978 .with_sync_output(true);
979 let mut renderer = InlineRenderer::new(writer, config);
980
981 let err = renderer
982 .present_ui(|_, _| Err(io::Error::other("boom")))
983 .unwrap_err();
984 assert_eq!(err.kind(), io::ErrorKind::Other);
985
986 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
987 if sync_policy_allows() {
988 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
989 } else {
990 assert!(!writer_contains_sequence(&renderer.writer, SYNC_END));
991 }
992 assert!(!renderer.cursor_saved);
993 assert!(!renderer.in_sync_block);
994 }
995
996 #[test]
997 fn cleanup_skips_sync_end_when_sync_output_disabled() {
998 let writer = test_writer();
999 let config = InlineConfig::new(2, 10, 80)
1000 .with_strategy(InlineStrategy::OverlayRedraw)
1001 .with_sync_output(false);
1002 let mut renderer = InlineRenderer::new(writer, config);
1003 renderer.in_sync_block = true;
1004
1005 renderer.cleanup_internal().unwrap();
1006
1007 assert!(
1008 !writer_contains_sequence(&renderer.writer, SYNC_END),
1009 "sync_end must not be emitted when synchronized output is disabled"
1010 );
1011 assert!(!renderer.in_sync_block);
1012 }
1013
1014 #[test]
1015 fn present_ui_rejects_invalid_config() {
1016 let writer = test_writer();
1017 let config = InlineConfig::new(0, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
1018 let mut renderer = InlineRenderer::new(writer, config);
1019
1020 let err = renderer.present_ui(|_, _| Ok(())).unwrap_err();
1021 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1022 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
1023 }
1024
1025 #[test]
1026 fn config_new_defaults() {
1027 let config = InlineConfig::new(5, 20, 100);
1028 assert_eq!(config.ui_height, 5);
1029 assert_eq!(config.term_height, 20);
1030 assert_eq!(config.term_width, 100);
1031 assert_eq!(config.strategy, InlineStrategy::Hybrid);
1032 assert!(!config.use_sync_output);
1033 }
1034}