1#![forbid(unsafe_code)]
2
3use std::io::{self, Write};
23
24use crate::terminal_capabilities::TerminalCapabilities;
25
26const CURSOR_SAVE: &[u8] = b"\x1b7";
32
33const CURSOR_RESTORE: &[u8] = b"\x1b8";
35
36fn cursor_position(row: u16, col: u16) -> Vec<u8> {
38 format!("\x1b[{};{}H", row, col).into_bytes()
39}
40
41fn set_scroll_region(top: u16, bottom: u16) -> Vec<u8> {
43 format!("\x1b[{};{}r", top, bottom).into_bytes()
44}
45
46const RESET_SCROLL_REGION: &[u8] = b"\x1b[r";
48
49#[allow(dead_code)] const ERASE_TO_EOL: &[u8] = b"\x1b[0K";
52
53const ERASE_LINE: &[u8] = b"\x1b[2K";
55
56const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
58
59const SYNC_END: &[u8] = b"\x1b[?2026l";
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum InlineStrategy {
69 ScrollRegion,
72
73 OverlayRedraw,
76
77 #[default]
80 Hybrid,
81}
82
83impl InlineStrategy {
84 #[must_use]
91 pub fn select(caps: &TerminalCapabilities) -> Self {
92 if caps.in_any_mux() {
93 InlineStrategy::OverlayRedraw
95 } else if caps.scroll_region && caps.sync_output {
96 InlineStrategy::ScrollRegion
98 } else if caps.scroll_region {
99 InlineStrategy::Hybrid
101 } else {
102 InlineStrategy::OverlayRedraw
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy)]
114pub struct InlineConfig {
115 pub ui_height: u16,
117
118 pub term_height: u16,
120
121 pub term_width: u16,
123
124 pub strategy: InlineStrategy,
126
127 pub use_sync_output: bool,
129}
130
131impl InlineConfig {
132 #[must_use]
134 pub fn new(ui_height: u16, term_height: u16, term_width: u16) -> Self {
135 Self {
136 ui_height,
137 term_height,
138 term_width,
139 strategy: InlineStrategy::default(),
140 use_sync_output: false,
141 }
142 }
143
144 #[must_use]
146 pub const fn with_strategy(mut self, strategy: InlineStrategy) -> Self {
147 self.strategy = strategy;
148 self
149 }
150
151 #[must_use]
153 pub const fn with_sync_output(mut self, enabled: bool) -> Self {
154 self.use_sync_output = enabled;
155 self
156 }
157
158 #[must_use]
162 pub const fn ui_top_row(&self) -> u16 {
163 let row = self
164 .term_height
165 .saturating_sub(self.ui_height)
166 .saturating_add(1);
167 if row == 0 { 1 } else { row }
169 }
170
171 #[must_use]
176 pub const fn log_bottom_row(&self) -> u16 {
177 self.ui_top_row().saturating_sub(1)
178 }
179
180 #[must_use]
184 pub const fn is_valid(&self) -> bool {
185 self.ui_height > 0 && self.ui_height < self.term_height && self.term_height > 1
186 }
187}
188
189pub struct InlineRenderer<W: Write> {
198 writer: W,
199 config: InlineConfig,
200 scroll_region_set: bool,
201 in_sync_block: bool,
202 cursor_saved: bool,
203}
204
205impl<W: Write> InlineRenderer<W> {
206 pub fn new(writer: W, config: InlineConfig) -> Self {
212 Self {
213 writer,
214 config,
215 scroll_region_set: false,
216 in_sync_block: false,
217 cursor_saved: false,
218 }
219 }
220
221 pub fn enter(&mut self) -> io::Result<()> {
226 match self.config.strategy {
227 InlineStrategy::ScrollRegion => {
228 let log_bottom = self.config.log_bottom_row();
230 if log_bottom > 0 {
231 self.writer.write_all(&set_scroll_region(1, log_bottom))?;
232 self.scroll_region_set = true;
233 }
234 }
235 InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
236 }
240 }
241 self.writer.flush()
242 }
243
244 pub fn exit(&mut self) -> io::Result<()> {
246 self.cleanup_internal()
247 }
248
249 pub fn write_log(&mut self, text: &str) -> io::Result<()> {
257 let log_row = self.config.log_bottom_row();
258
259 if log_row == 0 {
261 return Ok(());
262 }
263
264 match self.config.strategy {
265 InlineStrategy::ScrollRegion => {
266 self.writer.write_all(text.as_bytes())?;
268 }
269 InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
270 self.writer.write_all(CURSOR_SAVE)?;
272 self.cursor_saved = true;
273
274 self.writer.write_all(&cursor_position(log_row, 1))?;
276
277 self.writer.write_all(text.as_bytes())?;
279
280 self.writer.write_all(CURSOR_RESTORE)?;
282 self.cursor_saved = false;
283 }
284 }
285 self.writer.flush()
286 }
287
288 pub fn present_ui<F>(&mut self, render_fn: F) -> io::Result<()>
295 where
296 F: FnOnce(&mut W, &InlineConfig) -> io::Result<()>,
297 {
298 if self.config.use_sync_output && !self.in_sync_block {
300 self.writer.write_all(SYNC_BEGIN)?;
301 self.in_sync_block = true;
302 }
303
304 self.writer.write_all(CURSOR_SAVE)?;
306 self.cursor_saved = true;
307
308 let ui_row = self.config.ui_top_row();
310 self.writer.write_all(&cursor_position(ui_row, 1))?;
311
312 for row in 0..self.config.ui_height {
314 self.writer
315 .write_all(&cursor_position(ui_row.saturating_add(row), 1))?;
316 self.writer.write_all(ERASE_LINE)?;
317 }
318
319 self.writer.write_all(&cursor_position(ui_row, 1))?;
321 render_fn(&mut self.writer, &self.config)?;
322
323 self.writer.write_all(CURSOR_RESTORE)?;
325 self.cursor_saved = false;
326
327 if self.in_sync_block {
329 self.writer.write_all(SYNC_END)?;
330 self.in_sync_block = false;
331 }
332
333 self.writer.flush()
334 }
335
336 fn cleanup_internal(&mut self) -> io::Result<()> {
338 if self.in_sync_block {
340 let _ = self.writer.write_all(SYNC_END);
341 self.in_sync_block = false;
342 }
343
344 if self.scroll_region_set {
346 let _ = self.writer.write_all(RESET_SCROLL_REGION);
347 self.scroll_region_set = false;
348 }
349
350 if self.cursor_saved {
352 let _ = self.writer.write_all(CURSOR_RESTORE);
353 self.cursor_saved = false;
354 }
355
356 self.writer.flush()
357 }
358}
359
360impl<W: Write> Drop for InlineRenderer<W> {
361 fn drop(&mut self) {
362 let _ = self.cleanup_internal();
364 }
365}
366
367#[cfg(test)]
372mod tests {
373 use super::*;
374 use std::io::Cursor;
375
376 type TestWriter = Cursor<Vec<u8>>;
377
378 fn test_writer() -> TestWriter {
379 Cursor::new(Vec::new())
380 }
381
382 fn writer_contains_sequence(writer: &TestWriter, seq: &[u8]) -> bool {
383 writer
384 .get_ref()
385 .windows(seq.len())
386 .any(|window| window == seq)
387 }
388
389 fn writer_clear(writer: &mut TestWriter) {
390 writer.get_mut().clear();
391 }
392
393 #[test]
394 fn config_calculates_regions_correctly() {
395 let config = InlineConfig::new(6, 24, 80);
397 assert_eq!(config.ui_top_row(), 19); assert_eq!(config.log_bottom_row(), 18); }
400
401 #[test]
402 fn strategy_selection_prefers_overlay_in_mux() {
403 let mut caps = TerminalCapabilities::basic();
404 caps.in_tmux = true;
405 caps.scroll_region = true;
406 caps.sync_output = true;
407
408 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
409 }
410
411 #[test]
412 fn strategy_selection_uses_scroll_region_in_modern_terminal() {
413 let mut caps = TerminalCapabilities::basic();
414 caps.scroll_region = true;
415 caps.sync_output = true;
416
417 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::ScrollRegion);
418 }
419
420 #[test]
421 fn strategy_selection_uses_hybrid_without_sync() {
422 let mut caps = TerminalCapabilities::basic();
423 caps.scroll_region = true;
424 caps.sync_output = false;
425
426 assert_eq!(InlineStrategy::select(&caps), InlineStrategy::Hybrid);
427 }
428
429 #[test]
430 fn enter_sets_scroll_region_for_scroll_strategy() {
431 let writer = test_writer();
432 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
433 let mut renderer = InlineRenderer::new(writer, config);
434
435 renderer.enter().unwrap();
436
437 assert!(writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
439 }
440
441 #[test]
442 fn exit_resets_scroll_region() {
443 let writer = test_writer();
444 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
445 let mut renderer = InlineRenderer::new(writer, config);
446
447 renderer.enter().unwrap();
448 renderer.exit().unwrap();
449
450 assert!(writer_contains_sequence(
452 &renderer.writer,
453 RESET_SCROLL_REGION
454 ));
455 }
456
457 #[test]
458 fn present_ui_saves_and_restores_cursor() {
459 let writer = test_writer();
460 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
461 let mut renderer = InlineRenderer::new(writer, config);
462
463 renderer
464 .present_ui(|w, _| {
465 w.write_all(b"UI Content")?;
466 Ok(())
467 })
468 .unwrap();
469
470 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
472 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
474 }
475
476 #[test]
477 fn present_ui_uses_sync_output_when_enabled() {
478 let writer = test_writer();
479 let config = InlineConfig::new(6, 24, 80)
480 .with_strategy(InlineStrategy::OverlayRedraw)
481 .with_sync_output(true);
482 let mut renderer = InlineRenderer::new(writer, config);
483
484 renderer.present_ui(|_, _| Ok(())).unwrap();
485
486 assert!(writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
488 assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
489 }
490
491 #[test]
492 fn drop_cleans_up_scroll_region() {
493 let writer = test_writer();
494 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
495
496 {
497 let mut renderer = InlineRenderer::new(writer, config);
498 renderer.enter().unwrap();
499 }
501
502 }
504
505 #[test]
506 fn write_log_preserves_cursor_in_overlay_mode() {
507 let writer = test_writer();
508 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
509 let mut renderer = InlineRenderer::new(writer, config);
510
511 renderer.write_log("test log\n").unwrap();
512
513 assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
515 assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
516 }
517
518 #[test]
519 fn hybrid_does_not_set_scroll_region_in_enter() {
520 let writer = test_writer();
521 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::Hybrid);
522 let mut renderer = InlineRenderer::new(writer, config);
523
524 renderer.enter().unwrap();
525
526 assert!(!writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
528 assert!(!renderer.scroll_region_set);
529 }
530
531 #[test]
532 fn config_is_valid_checks_boundaries() {
533 let valid = InlineConfig::new(6, 24, 80);
535 assert!(valid.is_valid());
536
537 let full_ui = InlineConfig::new(24, 24, 80);
539 assert!(!full_ui.is_valid());
540
541 let no_ui = InlineConfig::new(0, 24, 80);
543 assert!(!no_ui.is_valid());
544
545 let tiny = InlineConfig::new(1, 1, 80);
547 assert!(!tiny.is_valid());
548 }
549
550 #[test]
551 fn log_bottom_row_zero_when_no_room() {
552 let config = InlineConfig::new(24, 24, 80);
554 assert_eq!(config.log_bottom_row(), 0);
555 }
556
557 #[test]
558 fn write_log_silently_drops_when_no_log_region() {
559 let writer = test_writer();
560 let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
562 let mut renderer = InlineRenderer::new(writer, config);
563
564 renderer.write_log("test log\n").unwrap();
566
567 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
569 }
570
571 #[test]
572 fn cleanup_does_not_restore_unsaved_cursor() {
573 let writer = test_writer();
574 let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
575 let mut renderer = InlineRenderer::new(writer, config);
576
577 renderer.enter().unwrap();
579 writer_clear(&mut renderer.writer); renderer.exit().unwrap();
581
582 assert!(!writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
584 }
585}