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.scroll_region && caps.sync_output {
98 InlineStrategy::ScrollRegion
100 } else if caps.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 pub fn enter(&mut self) -> io::Result<()> {
228 match self.config.strategy {
229 InlineStrategy::ScrollRegion => {
230 let log_bottom = self.config.log_bottom_row();
232 if log_bottom > 0 {
233 self.writer.write_all(&set_scroll_region(1, log_bottom))?;
234 self.scroll_region_set = true;
235 }
236 }
237 InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
238 }
242 }
243 self.writer.flush()
244 }
245
246 pub fn exit(&mut self) -> io::Result<()> {
248 self.cleanup_internal()
249 }
250
251 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
259 let log_row = self.config.log_bottom_row();
260
261 if log_row == 0 {
263 return Ok(());
264 }
265
266 match self.config.strategy {
267 InlineStrategy::ScrollRegion => {
268 self.writer.write_all(text.as_bytes())?;
270 }
271 InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
272 self.writer.write_all(CURSOR_SAVE)?;
274 self.cursor_saved = true;
275
276 self.writer.write_all(&cursor_position(log_row, 1))?;
278 self.writer.write_all(ERASE_LINE)?;
279
280 let safe_line =
283 Self::sanitize_overlay_log_line(text, usize::from(self.config.term_width));
284 if !safe_line.is_empty() {
285 self.writer.write_all(safe_line.as_bytes())?;
286 }
287
288 self.writer.write_all(CURSOR_RESTORE)?;
290 self.cursor_saved = false;
291 }
292 }
293 self.writer.flush()
294 }
295
296 pub fn present_ui<F>(&mut self, render_fn: F) -> io::Result<()>
303 where
304 F: FnOnce(&mut W, &InlineConfig) -> io::Result<()>,
305 {
306 if !self.config.is_valid() {
307 return Err(io::Error::new(
308 io::ErrorKind::InvalidInput,
309 "invalid inline mode configuration",
310 ));
311 }
312
313 if self.config.use_sync_output && !self.in_sync_block {
315 self.writer.write_all(SYNC_BEGIN)?;
316 self.in_sync_block = true;
317 }
318
319 self.writer.write_all(CURSOR_SAVE)?;
321 self.cursor_saved = true;
322
323 let operation_result = (|| -> io::Result<()> {
324 let ui_row = self.config.ui_top_row();
326 self.writer.write_all(&cursor_position(ui_row, 1))?;
327
328 for row in 0..self.config.ui_height {
330 self.writer
331 .write_all(&cursor_position(ui_row.saturating_add(row), 1))?;
332 self.writer.write_all(ERASE_LINE)?;
333 }
334
335 self.writer.write_all(&cursor_position(ui_row, 1))?;
337 render_fn(&mut self.writer, &self.config)?;
338 Ok(())
339 })();
340
341 let restore_result = self.writer.write_all(CURSOR_RESTORE);
343 if restore_result.is_ok() {
344 self.cursor_saved = false;
345 }
346
347 let sync_end_result = if self.in_sync_block {
348 let res = self.writer.write_all(SYNC_END);
349 if res.is_ok() {
350 self.in_sync_block = false;
351 }
352 Some(res)
353 } else {
354 None
355 };
356
357 let flush_result = self.writer.flush();
358
359 operation_result?;
360 restore_result?;
361 if let Some(res) = sync_end_result {
362 res?;
363 }
364 flush_result
365 }
366
367 fn sanitize_overlay_log_line(text: &str, max_cols: usize) -> String {
368 if max_cols == 0 {
369 return String::new();
370 }
371
372 let mut out = String::new();
373 let mut used_cols = 0usize;
374
375 for ch in text.chars() {
376 if ch == '\n' || ch == '\r' {
377 break;
378 }
379
380 if ch.is_control() {
382 continue;
383 }
384
385 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
386 if ch_width == 0 {
387 if !out.is_empty() {
389 out.push(ch);
390 }
391 continue;
392 }
393
394 if used_cols.saturating_add(ch_width) > max_cols {
395 break;
396 }
397
398 out.push(ch);
399 used_cols += ch_width;
400 if used_cols == max_cols {
401 break;
402 }
403 }
404
405 out
406 }
407
408 fn cleanup_internal(&mut self) -> io::Result<()> {
410 if self.in_sync_block {
412 let _ = self.writer.write_all(SYNC_END);
413 self.in_sync_block = false;
414 }
415
416 if self.scroll_region_set {
418 let _ = self.writer.write_all(RESET_SCROLL_REGION);
419 self.scroll_region_set = false;
420 }
421
422 if self.cursor_saved {
424 let _ = self.writer.write_all(CURSOR_RESTORE);
425 self.cursor_saved = false;
426 }
427
428 self.writer.flush()
429 }
430}
431
432impl<W: Write> Drop for InlineRenderer<W> {
433 fn drop(&mut self) {
434 let _ = self.cleanup_internal();
436 }
437}
438
439#[cfg(test)]
444mod tests {
445 use super::*;
446 use std::io::Cursor;
447
448 type TestWriter = Cursor<Vec<u8>>;
449
450 fn test_writer() -> TestWriter {
451 Cursor::new(Vec::new())
452 }
453
454 fn writer_contains_sequence(writer: &TestWriter, seq: &[u8]) -> bool {
455 writer
456 .get_ref()
457 .windows(seq.len())
458 .any(|window| window == seq)
459 }
460
461 fn writer_clear(writer: &mut TestWriter) {
462 writer.get_mut().clear();
463 }
464
465 #[test]
466 fn config_calculates_regions_correctly() {
467 let config = InlineConfig::new(6, 24, 80);
469 assert_eq!(config.ui_top_row(), 19); assert_eq!(config.log_bottom_row(), 18); }
472
473 #[test]
474 fn strategy_selection_prefers_overlay_in_mux() {
475 let mut caps = TerminalCapabilities::basic();
476 caps.in_tmux = true;
477 caps.scroll_region = true;
478 caps.sync_output = true;
479
480 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
481 }
482
483 #[test]
484 fn strategy_selection_uses_scroll_region_in_modern_terminal() {
485 let mut caps = TerminalCapabilities::basic();
486 caps.scroll_region = true;
487 caps.sync_output = true;
488
489 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::ScrollRegion);
490 }
491
492 #[test]
493 fn strategy_selection_uses_hybrid_without_sync() {
494 let mut caps = TerminalCapabilities::basic();
495 caps.scroll_region = true;
496 caps.sync_output = false;
497
498 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::Hybrid);
499 }
500
501 #[test]
502 fn enter_sets_scroll_region_for_scroll_strategy() {
503 let writer = test_writer();
504 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
505 let mut renderer = InlineRenderer::new(writer, config);
506
507 renderer.enter().unwrap();
508
509 assert!(writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
511 }
512
513 #[test]
514 fn exit_resets_scroll_region() {
515 let writer = test_writer();
516 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
517 let mut renderer = InlineRenderer::new(writer, config);
518
519 renderer.enter().unwrap();
520 renderer.exit().unwrap();
521
522 assert!(writer_contains_sequence(
524 &renderer.writer,
525 RESET_SCROLL_REGION
526 ));
527 }
528
529 #[test]
530 fn present_ui_saves_and_restores_cursor() {
531 let writer = test_writer();
532 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
533 let mut renderer = InlineRenderer::new(writer, config);
534
535 renderer
536 .present_ui(|w, _| {
537 w.write_all(b"UI Content")?;
538 Ok(())
539 })
540 .unwrap();
541
542 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
544 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
546 }
547
548 #[test]
549 fn present_ui_uses_sync_output_when_enabled() {
550 let writer = test_writer();
551 let config = InlineConfig::new(6, 24, 80)
552 .with_strategy(InlineStrategy::OverlayRedraw)
553 .with_sync_output(true);
554 let mut renderer = InlineRenderer::new(writer, config);
555
556 renderer.present_ui(|_, _| Ok(())).unwrap();
557
558 assert!(writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
560 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
561 }
562
563 #[test]
564 fn drop_cleans_up_scroll_region() {
565 let writer = test_writer();
566 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
567
568 {
569 let mut renderer = InlineRenderer::new(writer, config);
570 renderer.enter().unwrap();
571 }
573
574 }
576
577 #[test]
578 fn write_log_preserves_cursor_in_overlay_mode() {
579 let writer = test_writer();
580 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
581 let mut renderer = InlineRenderer::new(writer, config);
582
583 renderer.write_log("test log\n").unwrap();
584
585 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
587 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
588 }
589
590 #[test]
591 fn write_log_overlay_truncates_to_single_safe_line() {
592 let writer = test_writer();
593 let config = InlineConfig::new(6, 24, 5).with_strategy(InlineStrategy::OverlayRedraw);
594 let mut renderer = InlineRenderer::new(writer, config);
595
596 renderer.write_log("ABCDE\nSECOND").unwrap();
597
598 let output = String::from_utf8_lossy(renderer.writer.get_ref());
599 assert!(output.contains("ABCDE"));
600 assert!(!output.contains("SECOND"));
601 assert!(!output.contains('\n'));
602 }
603
604 #[test]
605 fn write_log_overlay_truncates_wide_chars_by_display_width() {
606 let writer = test_writer();
607 let config = InlineConfig::new(6, 24, 3).with_strategy(InlineStrategy::OverlayRedraw);
608 let mut renderer = InlineRenderer::new(writer, config);
609
610 renderer.write_log("ab界Z").unwrap();
611
612 let output = String::from_utf8_lossy(renderer.writer.get_ref());
613 assert!(output.contains("ab"));
614 assert!(!output.contains('界'));
615 assert!(!output.contains('Z'));
616 }
617
618 #[test]
619 fn write_log_overlay_allows_wide_char_when_it_exactly_fits_width() {
620 let writer = test_writer();
621 let config = InlineConfig::new(6, 24, 4).with_strategy(InlineStrategy::OverlayRedraw);
622 let mut renderer = InlineRenderer::new(writer, config);
623
624 renderer.write_log("ab界Z").unwrap();
625
626 let output = String::from_utf8_lossy(renderer.writer.get_ref());
627 assert!(output.contains("ab界"));
628 assert!(!output.contains('Z'));
629 }
630
631 #[test]
632 fn hybrid_does_not_set_scroll_region_in_enter() {
633 let writer = test_writer();
634 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::Hybrid);
635 let mut renderer = InlineRenderer::new(writer, config);
636
637 renderer.enter().unwrap();
638
639 assert!(!writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
641 assert!(!renderer.scroll_region_set);
642 }
643
644 #[test]
645 fn config_is_valid_checks_boundaries() {
646 let valid = InlineConfig::new(6, 24, 80);
648 assert!(valid.is_valid());
649
650 let full_ui = InlineConfig::new(24, 24, 80);
652 assert!(!full_ui.is_valid());
653
654 let no_ui = InlineConfig::new(0, 24, 80);
656 assert!(!no_ui.is_valid());
657
658 let tiny = InlineConfig::new(1, 1, 80);
660 assert!(!tiny.is_valid());
661 }
662
663 #[test]
664 fn log_bottom_row_zero_when_no_room() {
665 let config = InlineConfig::new(24, 24, 80);
667 assert_eq!(config.log_bottom_row(), 0);
668 }
669
670 #[test]
671 fn write_log_silently_drops_when_no_log_region() {
672 let writer = test_writer();
673 let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
675 let mut renderer = InlineRenderer::new(writer, config);
676
677 renderer.write_log("test log\n").unwrap();
679
680 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
682 }
683
684 #[test]
685 fn cleanup_does_not_restore_unsaved_cursor() {
686 let writer = test_writer();
687 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
688 let mut renderer = InlineRenderer::new(writer, config);
689
690 renderer.enter().unwrap();
692 writer_clear(&mut renderer.writer); renderer.exit().unwrap();
694
695 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
697 }
698
699 #[test]
700 fn inline_strategy_default_is_hybrid() {
701 assert_eq!(InlineStrategy::default(), InlineStrategy::Hybrid);
702 }
703
704 #[test]
705 fn config_ui_top_row_clamps_to_1() {
706 let config = InlineConfig::new(30, 24, 80);
708 assert!(config.ui_top_row() >= 1);
709 }
710
711 #[test]
712 fn strategy_select_fallback_no_scroll_no_sync() {
713 let mut caps = TerminalCapabilities::basic();
714 caps.scroll_region = false;
715 caps.sync_output = false;
716 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
717 }
718
719 #[test]
720 fn write_log_in_scroll_region_mode() {
721 let writer = test_writer();
722 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
723 let mut renderer = InlineRenderer::new(writer, config);
724
725 renderer.enter().unwrap();
726 renderer.write_log("hello\n").unwrap();
727
728 let output = renderer.writer.get_ref();
730 assert!(output.windows(b"hello\n".len()).any(|w| w == b"hello\n"));
731 }
732
733 #[test]
734 fn present_ui_clears_ui_lines() {
735 let writer = test_writer();
736 let config = InlineConfig::new(2, 10, 80).with_strategy(InlineStrategy::OverlayRedraw);
737 let mut renderer = InlineRenderer::new(writer, config);
738
739 renderer.present_ui(|_, _| Ok(())).unwrap();
740
741 let count = renderer
743 .writer
744 .get_ref()
745 .windows(ERASE_LINE.len())
746 .filter(|w| *w == ERASE_LINE)
747 .count();
748 assert_eq!(count, 2);
749 }
750
751 #[test]
752 fn present_ui_render_error_still_restores_state() {
753 let writer = test_writer();
754 let config = InlineConfig::new(2, 10, 80)
755 .with_strategy(InlineStrategy::OverlayRedraw)
756 .with_sync_output(true);
757 let mut renderer = InlineRenderer::new(writer, config);
758
759 let err = renderer
760 .present_ui(|_, _| Err(io::Error::other("boom")))
761 .unwrap_err();
762 assert_eq!(err.kind(), io::ErrorKind::Other);
763
764 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
765 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
766 assert!(!renderer.cursor_saved);
767 assert!(!renderer.in_sync_block);
768 }
769
770 #[test]
771 fn present_ui_rejects_invalid_config() {
772 let writer = test_writer();
773 let config = InlineConfig::new(0, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
774 let mut renderer = InlineRenderer::new(writer, config);
775
776 let err = renderer.present_ui(|_, _| Ok(())).unwrap_err();
777 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
778 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
779 }
780
781 #[test]
782 fn config_new_defaults() {
783 let config = InlineConfig::new(5, 20, 100);
784 assert_eq!(config.ui_height, 5);
785 assert_eq!(config.term_height, 20);
786 assert_eq!(config.term_width, 100);
787 assert_eq!(config.strategy, InlineStrategy::Hybrid);
788 assert!(!config.use_sync_output);
789 }
790}