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]
82 Hybrid,
83}
84
85impl InlineStrategy {
86 #[must_use]
93 pub fn select(caps: &TerminalCapabilities) -> Self {
94 if caps.in_any_mux() {
95 InlineStrategy::OverlayRedraw
97 } else if caps.use_scroll_region() && caps.use_sync_output() {
98 InlineStrategy::ScrollRegion
100 } else if caps.use_scroll_region() {
101 InlineStrategy::Hybrid
103 } else {
104 InlineStrategy::OverlayRedraw
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy)]
116pub struct InlineConfig {
117 pub ui_height: u16,
119
120 pub term_height: u16,
122
123 pub term_width: u16,
125
126 pub strategy: InlineStrategy,
128
129 pub use_sync_output: bool,
131}
132
133impl InlineConfig {
134 #[must_use]
136 pub fn new(ui_height: u16, term_height: u16, term_width: u16) -> Self {
137 Self {
138 ui_height,
139 term_height,
140 term_width,
141 strategy: InlineStrategy::default(),
142 use_sync_output: false,
143 }
144 }
145
146 #[must_use]
148 pub const fn with_strategy(mut self, strategy: InlineStrategy) -> Self {
149 self.strategy = strategy;
150 self
151 }
152
153 #[must_use]
155 pub const fn with_sync_output(mut self, enabled: bool) -> Self {
156 self.use_sync_output = enabled;
157 self
158 }
159
160 #[must_use]
164 pub const fn ui_top_row(&self) -> u16 {
165 let row = self
166 .term_height
167 .saturating_sub(self.ui_height)
168 .saturating_add(1);
169 if row == 0 { 1 } else { row }
171 }
172
173 #[must_use]
178 pub const fn log_bottom_row(&self) -> u16 {
179 self.ui_top_row().saturating_sub(1)
180 }
181
182 #[must_use]
186 pub const fn is_valid(&self) -> bool {
187 self.ui_height > 0 && self.ui_height < self.term_height && self.term_height > 1
188 }
189}
190
191pub struct InlineRenderer<W: Write> {
200 writer: W,
201 config: InlineConfig,
202 scroll_region_set: bool,
203 in_sync_block: bool,
204 cursor_saved: bool,
205}
206
207impl<W: Write> InlineRenderer<W> {
208 pub fn new(writer: W, config: InlineConfig) -> Self {
214 Self {
215 writer,
216 config,
217 scroll_region_set: false,
218 in_sync_block: false,
219 cursor_saved: false,
220 }
221 }
222
223 #[inline]
224 fn sync_output_enabled(&self) -> bool {
225 self.config.use_sync_output && TerminalCapabilities::with_overrides().use_sync_output()
226 }
227
228 pub fn enter(&mut self) -> io::Result<()> {
233 match self.config.strategy {
234 InlineStrategy::ScrollRegion => {
235 let log_bottom = self.config.log_bottom_row();
237 if log_bottom > 0 {
238 self.writer.write_all(&set_scroll_region(1, log_bottom))?;
239 self.scroll_region_set = true;
240 }
241 }
242 InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
243 }
247 }
248 self.writer.flush()
249 }
250
251 pub fn exit(&mut self) -> io::Result<()> {
253 self.cleanup_internal()
254 }
255
256 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
264 let log_row = self.config.log_bottom_row();
265
266 if log_row == 0 {
268 return Ok(());
269 }
270
271 match self.config.strategy {
272 InlineStrategy::ScrollRegion => {
273 let safe_text = Self::sanitize_scroll_region_log_text(text);
275 if !safe_text.is_empty() {
276 self.writer.write_all(safe_text.as_bytes())?;
277 }
278 }
279 InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
280 self.writer.write_all(CURSOR_SAVE)?;
282 self.cursor_saved = true;
283
284 self.writer.write_all(&cursor_position(log_row, 1))?;
286 self.writer.write_all(ERASE_LINE)?;
287
288 let safe_line =
291 Self::sanitize_overlay_log_line(text, usize::from(self.config.term_width));
292 if !safe_line.is_empty() {
293 self.writer.write_all(safe_line.as_bytes())?;
294 }
295
296 self.writer.write_all(CURSOR_RESTORE)?;
298 self.cursor_saved = false;
299 }
300 }
301 self.writer.flush()
302 }
303
304 pub fn present_ui<F>(&mut self, render_fn: F) -> io::Result<()>
311 where
312 F: FnOnce(&mut W, &InlineConfig) -> io::Result<()>,
313 {
314 if !self.config.is_valid() {
315 return Err(io::Error::new(
316 io::ErrorKind::InvalidInput,
317 "invalid inline mode configuration",
318 ));
319 }
320
321 let sync_output_enabled = self.sync_output_enabled();
322
323 if sync_output_enabled && !self.in_sync_block {
325 self.in_sync_block = true;
328 if let Err(err) = self.writer.write_all(SYNC_BEGIN) {
329 let _ = self.writer.write_all(SYNC_END);
332 self.in_sync_block = false;
333 let _ = self.writer.flush();
334 return Err(err);
335 }
336 }
337
338 self.writer.write_all(CURSOR_SAVE)?;
340 self.cursor_saved = true;
341
342 let operation_result = (|| -> io::Result<()> {
343 let ui_row = self.config.ui_top_row();
345 self.writer.write_all(&cursor_position(ui_row, 1))?;
346
347 for row in 0..self.config.ui_height {
349 self.writer
350 .write_all(&cursor_position(ui_row.saturating_add(row), 1))?;
351 self.writer.write_all(ERASE_LINE)?;
352 }
353
354 self.writer.write_all(&cursor_position(ui_row, 1))?;
356 render_fn(&mut self.writer, &self.config)?;
357 Ok(())
358 })();
359
360 let restore_result = self.writer.write_all(CURSOR_RESTORE);
362 if restore_result.is_ok() {
363 self.cursor_saved = false;
364 }
365
366 let sync_end_result = if sync_output_enabled && self.in_sync_block {
367 let res = self.writer.write_all(SYNC_END);
368 if res.is_ok() {
369 self.in_sync_block = false;
370 }
371 Some(res)
372 } else {
373 if !sync_output_enabled {
374 self.in_sync_block = false;
377 }
378 None
379 };
380
381 let flush_result = self.writer.flush();
382
383 let cleanup_error = restore_result
386 .err()
387 .or_else(|| sync_end_result.and_then(Result::err))
388 .or_else(|| flush_result.err());
389 if let Some(err) = cleanup_error {
390 return Err(err);
391 }
392 operation_result
393 }
394
395 fn sanitize_scroll_region_log_text(text: &str) -> String {
396 let bytes = text.as_bytes();
397 let mut out = String::with_capacity(text.len());
398 let mut i = 0;
399
400 while i < bytes.len() {
401 match bytes[i] {
402 0x1B => {
404 i = Self::skip_escape_sequence(bytes, i);
405 }
406 0x0A => {
408 out.push('\n');
409 i += 1;
410 }
411 0x0D => {
412 out.push('\n');
413 i += 1;
414 }
415 0x00..=0x1F | 0x7F => {
417 i += 1;
418 }
419 0x20..=0x7E => {
421 out.push(bytes[i] as char);
422 i += 1;
423 }
424 _ => {
426 if let Some((ch, len)) = Self::decode_utf8_char(&bytes[i..]) {
427 if !('\u{0080}'..='\u{009F}').contains(&ch) {
428 out.push(ch);
429 }
430 i += len;
431 } else {
432 i += 1;
433 }
434 }
435 }
436 }
437
438 out
439 }
440
441 fn skip_escape_sequence(bytes: &[u8], start: usize) -> usize {
442 let mut i = start + 1; if i >= bytes.len() {
444 return i;
445 }
446
447 match bytes[i] {
448 b'[' => {
450 i += 1;
451 while i < bytes.len() {
452 let b = bytes[i];
453 if (0x40..=0x7E).contains(&b) {
454 return i + 1;
455 }
456 if !(0x20..=0x3F).contains(&b) {
457 return i;
458 }
459 i += 1;
460 }
461 }
462 b']' => {
464 i += 1;
465 while i < bytes.len() {
466 let b = bytes[i];
467 if b == 0x07 {
468 return i + 1;
469 }
470 if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
471 return i + 2;
472 }
473 if b == 0x1B || b < 0x20 {
474 return i;
475 }
476 i += 1;
477 }
478 }
479 b'P' | b'^' | b'_' => {
481 i += 1;
482 while i < bytes.len() {
483 let b = bytes[i];
484 if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
485 return i + 2;
486 }
487 if b == 0x1B || b < 0x20 {
488 return i;
489 }
490 i += 1;
491 }
492 }
493 0x20..=0x7E => return i + 1,
495 _ => {}
496 }
497
498 i
499 }
500
501 fn decode_utf8_char(bytes: &[u8]) -> Option<(char, usize)> {
502 if bytes.is_empty() {
503 return None;
504 }
505
506 let first = bytes[0];
507 let (expected_len, mut codepoint) = match first {
508 0x00..=0x7F => return Some((first as char, 1)),
509 0xC0..=0xDF => (2, (first & 0x1F) as u32),
510 0xE0..=0xEF => (3, (first & 0x0F) as u32),
511 0xF0..=0xF7 => (4, (first & 0x07) as u32),
512 _ => return None,
513 };
514
515 if bytes.len() < expected_len {
516 return None;
517 }
518
519 for &b in bytes.iter().take(expected_len).skip(1) {
520 if (b & 0xC0) != 0x80 {
521 return None;
522 }
523 codepoint = (codepoint << 6) | (b & 0x3F) as u32;
524 }
525
526 let min_codepoint = match expected_len {
527 2 => 0x80,
528 3 => 0x800,
529 4 => 0x1_0000,
530 _ => return None,
531 };
532 if codepoint < min_codepoint {
533 return None;
534 }
535
536 char::from_u32(codepoint).map(|c| (c, expected_len))
537 }
538
539 fn sanitize_overlay_log_line(text: &str, max_cols: usize) -> String {
540 if max_cols == 0 {
541 return String::new();
542 }
543
544 let mut out = String::new();
545 let mut used_cols = 0usize;
546
547 for ch in text.chars() {
548 if ch == '\n' || ch == '\r' {
549 break;
550 }
551
552 if ch.is_control() {
554 continue;
555 }
556
557 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
558 if ch_width == 0 {
559 if !out.is_empty() {
561 out.push(ch);
562 }
563 continue;
564 }
565
566 if used_cols.saturating_add(ch_width) > max_cols {
567 break;
568 }
569
570 out.push(ch);
571 used_cols += ch_width;
572 if used_cols == max_cols {
573 break;
574 }
575 }
576
577 out
578 }
579
580 fn cleanup_internal(&mut self) -> io::Result<()> {
582 let sync_output_enabled = self.sync_output_enabled();
583
584 if self.in_sync_block {
586 if sync_output_enabled {
587 let _ = self.writer.write_all(SYNC_END);
588 }
589 self.in_sync_block = false;
590 }
591
592 if self.scroll_region_set {
594 let _ = self.writer.write_all(RESET_SCROLL_REGION);
595 self.scroll_region_set = false;
596 }
597
598 if self.cursor_saved {
600 let _ = self.writer.write_all(CURSOR_RESTORE);
601 self.cursor_saved = false;
602 }
603
604 self.writer.flush()
605 }
606}
607
608impl<W: Write> Drop for InlineRenderer<W> {
609 fn drop(&mut self) {
610 let _ = self.cleanup_internal();
612 }
613}
614
615#[cfg(test)]
620mod tests {
621 use super::*;
622 use std::io::Cursor;
623
624 type TestWriter = Cursor<Vec<u8>>;
625
626 fn test_writer() -> TestWriter {
627 Cursor::new(Vec::new())
628 }
629
630 fn writer_contains_sequence(writer: &TestWriter, seq: &[u8]) -> bool {
631 writer
632 .get_ref()
633 .windows(seq.len())
634 .any(|window| window == seq)
635 }
636
637 fn writer_clear(writer: &mut TestWriter) {
638 writer.get_mut().clear();
639 }
640
641 fn sync_policy_allows() -> bool {
642 TerminalCapabilities::with_overrides().use_sync_output()
643 }
644
645 #[test]
646 fn config_calculates_regions_correctly() {
647 let config = InlineConfig::new(6, 24, 80);
649 assert_eq!(config.ui_top_row(), 19); assert_eq!(config.log_bottom_row(), 18); }
652
653 #[test]
654 fn strategy_selection_prefers_overlay_in_mux() {
655 let mut caps = TerminalCapabilities::basic();
656 caps.in_tmux = true;
657 caps.scroll_region = true;
658 caps.sync_output = true;
659
660 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
661 }
662
663 #[test]
664 fn strategy_selection_uses_scroll_region_in_modern_terminal() {
665 let mut caps = TerminalCapabilities::basic();
666 caps.scroll_region = true;
667 caps.sync_output = true;
668
669 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::ScrollRegion);
670 }
671
672 #[test]
673 fn strategy_selection_uses_hybrid_without_sync() {
674 let mut caps = TerminalCapabilities::basic();
675 caps.scroll_region = true;
676 caps.sync_output = false;
677
678 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::Hybrid);
679 }
680
681 #[test]
682 fn enter_sets_scroll_region_for_scroll_strategy() {
683 let writer = test_writer();
684 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
685 let mut renderer = InlineRenderer::new(writer, config);
686
687 renderer.enter().unwrap();
688
689 assert!(writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
691 }
692
693 #[test]
694 fn exit_resets_scroll_region() {
695 let writer = test_writer();
696 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
697 let mut renderer = InlineRenderer::new(writer, config);
698
699 renderer.enter().unwrap();
700 renderer.exit().unwrap();
701
702 assert!(writer_contains_sequence(
704 &renderer.writer,
705 RESET_SCROLL_REGION
706 ));
707 }
708
709 #[test]
710 fn present_ui_saves_and_restores_cursor() {
711 let writer = test_writer();
712 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
713 let mut renderer = InlineRenderer::new(writer, config);
714
715 renderer
716 .present_ui(|w, _| {
717 w.write_all(b"UI Content")?;
718 Ok(())
719 })
720 .unwrap();
721
722 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
724 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
726 }
727
728 #[test]
729 fn present_ui_uses_sync_output_when_enabled() {
730 let writer = test_writer();
731 let config = InlineConfig::new(6, 24, 80)
732 .with_strategy(InlineStrategy::OverlayRedraw)
733 .with_sync_output(true);
734 let mut renderer = InlineRenderer::new(writer, config);
735
736 renderer.present_ui(|_, _| Ok(())).unwrap();
737
738 if sync_policy_allows() {
739 assert!(writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
740 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
741 } else {
742 assert!(!writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
743 assert!(!writer_contains_sequence(&renderer.writer, SYNC_END));
744 }
745 }
746
747 #[test]
748 fn drop_cleans_up_scroll_region() {
749 let writer = test_writer();
750 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
751
752 {
753 let mut renderer = InlineRenderer::new(writer, config);
754 renderer.enter().unwrap();
755 }
757
758 }
760
761 #[test]
762 fn write_log_preserves_cursor_in_overlay_mode() {
763 let writer = test_writer();
764 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
765 let mut renderer = InlineRenderer::new(writer, config);
766
767 renderer.write_log("test log\n").unwrap();
768
769 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
771 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
772 }
773
774 #[test]
775 fn write_log_overlay_truncates_to_single_safe_line() {
776 let writer = test_writer();
777 let config = InlineConfig::new(6, 24, 5).with_strategy(InlineStrategy::OverlayRedraw);
778 let mut renderer = InlineRenderer::new(writer, config);
779
780 renderer.write_log("ABCDE\nSECOND").unwrap();
781
782 let output = String::from_utf8_lossy(renderer.writer.get_ref());
783 assert!(output.contains("ABCDE"));
784 assert!(!output.contains("SECOND"));
785 assert!(!output.contains('\n'));
786 }
787
788 #[test]
789 fn write_log_overlay_truncates_wide_chars_by_display_width() {
790 let writer = test_writer();
791 let config = InlineConfig::new(6, 24, 3).with_strategy(InlineStrategy::OverlayRedraw);
792 let mut renderer = InlineRenderer::new(writer, config);
793
794 renderer.write_log("ab界Z").unwrap();
795
796 let output = String::from_utf8_lossy(renderer.writer.get_ref());
797 assert!(output.contains("ab"));
798 assert!(!output.contains('界'));
799 assert!(!output.contains('Z'));
800 }
801
802 #[test]
803 fn write_log_overlay_allows_wide_char_when_it_exactly_fits_width() {
804 let writer = test_writer();
805 let config = InlineConfig::new(6, 24, 4).with_strategy(InlineStrategy::OverlayRedraw);
806 let mut renderer = InlineRenderer::new(writer, config);
807
808 renderer.write_log("ab界Z").unwrap();
809
810 let output = String::from_utf8_lossy(renderer.writer.get_ref());
811 assert!(output.contains("ab界"));
812 assert!(!output.contains('Z'));
813 }
814
815 #[test]
816 fn hybrid_does_not_set_scroll_region_in_enter() {
817 let writer = test_writer();
818 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::Hybrid);
819 let mut renderer = InlineRenderer::new(writer, config);
820
821 renderer.enter().unwrap();
822
823 assert!(!writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
825 assert!(!renderer.scroll_region_set);
826 }
827
828 #[test]
829 fn config_is_valid_checks_boundaries() {
830 let valid = InlineConfig::new(6, 24, 80);
832 assert!(valid.is_valid());
833
834 let full_ui = InlineConfig::new(24, 24, 80);
836 assert!(!full_ui.is_valid());
837
838 let no_ui = InlineConfig::new(0, 24, 80);
840 assert!(!no_ui.is_valid());
841
842 let tiny = InlineConfig::new(1, 1, 80);
844 assert!(!tiny.is_valid());
845 }
846
847 #[test]
848 fn log_bottom_row_zero_when_no_room() {
849 let config = InlineConfig::new(24, 24, 80);
851 assert_eq!(config.log_bottom_row(), 0);
852 }
853
854 #[test]
855 fn write_log_silently_drops_when_no_log_region() {
856 let writer = test_writer();
857 let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
859 let mut renderer = InlineRenderer::new(writer, config);
860
861 renderer.write_log("test log\n").unwrap();
863
864 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
866 }
867
868 #[test]
869 fn cleanup_does_not_restore_unsaved_cursor() {
870 let writer = test_writer();
871 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
872 let mut renderer = InlineRenderer::new(writer, config);
873
874 renderer.enter().unwrap();
876 writer_clear(&mut renderer.writer); renderer.exit().unwrap();
878
879 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
881 }
882
883 #[test]
884 fn inline_strategy_default_is_hybrid() {
885 assert_eq!(InlineStrategy::default(), InlineStrategy::Hybrid);
886 }
887
888 #[test]
889 fn config_ui_top_row_clamps_to_1() {
890 let config = InlineConfig::new(30, 24, 80);
892 assert!(config.ui_top_row() >= 1);
893 }
894
895 #[test]
896 fn strategy_select_fallback_no_scroll_no_sync() {
897 let mut caps = TerminalCapabilities::basic();
898 caps.scroll_region = false;
899 caps.sync_output = false;
900 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
901 }
902
903 #[test]
904 fn write_log_in_scroll_region_mode() {
905 let writer = test_writer();
906 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
907 let mut renderer = InlineRenderer::new(writer, config);
908
909 renderer.enter().unwrap();
910 renderer.write_log("hello\n").unwrap();
911
912 let output = renderer.writer.get_ref();
914 assert!(output.windows(b"hello\n".len()).any(|w| w == b"hello\n"));
915 }
916
917 #[test]
918 fn write_log_in_scroll_region_mode_sanitizes_escape_payloads() {
919 let writer = test_writer();
920 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
921 let mut renderer = InlineRenderer::new(writer, config);
922
923 renderer.enter().unwrap();
924 renderer
925 .write_log("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x\n")
926 .unwrap();
927
928 let output = String::from_utf8_lossy(renderer.writer.get_ref());
929 assert!(output.contains("safetailx\n"));
930 assert!(
931 !output.contains("52;c;SGVsbG8"),
932 "OSC payload should not survive scroll-region log sanitization"
933 );
934 assert!(
935 !output.contains('\u{009d}'),
936 "C1 controls must be stripped in scroll-region logging"
937 );
938 }
939
940 #[test]
941 fn present_ui_clears_ui_lines() {
942 let writer = test_writer();
943 let config = InlineConfig::new(2, 10, 80).with_strategy(InlineStrategy::OverlayRedraw);
944 let mut renderer = InlineRenderer::new(writer, config);
945
946 renderer.present_ui(|_, _| Ok(())).unwrap();
947
948 let count = renderer
950 .writer
951 .get_ref()
952 .windows(ERASE_LINE.len())
953 .filter(|w| *w == ERASE_LINE)
954 .count();
955 assert_eq!(count, 2);
956 }
957
958 #[test]
959 fn present_ui_render_error_still_restores_state() {
960 let writer = test_writer();
961 let config = InlineConfig::new(2, 10, 80)
962 .with_strategy(InlineStrategy::OverlayRedraw)
963 .with_sync_output(true);
964 let mut renderer = InlineRenderer::new(writer, config);
965
966 let err = renderer
967 .present_ui(|_, _| Err(io::Error::other("boom")))
968 .unwrap_err();
969 assert_eq!(err.kind(), io::ErrorKind::Other);
970
971 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
972 if sync_policy_allows() {
973 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
974 } else {
975 assert!(!writer_contains_sequence(&renderer.writer, SYNC_END));
976 }
977 assert!(!renderer.cursor_saved);
978 assert!(!renderer.in_sync_block);
979 }
980
981 #[test]
982 fn cleanup_skips_sync_end_when_sync_output_disabled() {
983 let writer = test_writer();
984 let config = InlineConfig::new(2, 10, 80)
985 .with_strategy(InlineStrategy::OverlayRedraw)
986 .with_sync_output(false);
987 let mut renderer = InlineRenderer::new(writer, config);
988 renderer.in_sync_block = true;
989
990 renderer.cleanup_internal().unwrap();
991
992 assert!(
993 !writer_contains_sequence(&renderer.writer, SYNC_END),
994 "sync_end must not be emitted when synchronized output is disabled"
995 );
996 assert!(!renderer.in_sync_block);
997 }
998
999 #[test]
1000 fn present_ui_rejects_invalid_config() {
1001 let writer = test_writer();
1002 let config = InlineConfig::new(0, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
1003 let mut renderer = InlineRenderer::new(writer, config);
1004
1005 let err = renderer.present_ui(|_, _| Ok(())).unwrap_err();
1006 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1007 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
1008 }
1009
1010 #[test]
1011 fn config_new_defaults() {
1012 let config = InlineConfig::new(5, 20, 100);
1013 assert_eq!(config.ui_height, 5);
1014 assert_eq!(config.term_height, 20);
1015 assert_eq!(config.term_width, 100);
1016 assert_eq!(config.strategy, InlineStrategy::Hybrid);
1017 assert!(!config.use_sync_output);
1018 }
1019}