Skip to main content

ftui_core/
inline_mode.rs

1#![forbid(unsafe_code)]
2
3//! Inline Mode Spike: Validates correctness-first inline mode strategies.
4//!
5//! This module implements the Phase -1 spike (bd-10i.1.1) to validate inline mode
6//! strategies for FrankenTUI. Inline mode preserves terminal scrollback while
7//! rendering a stable UI region + streaming logs.
8//!
9//! # Strategies Implemented
10//!
11//! - **Strategy A (Scroll-Region)**: Uses DECSTBM to constrain scrolling to a region.
12//! - **Strategy B (Overlay-Redraw)**: Save cursor, clear UI, write logs, redraw UI, restore.
13//! - **Strategy C (Hybrid)**: Overlay-redraw baseline with scroll-region optimization where safe.
14//!
15//! # Key Invariants
16//!
17//! 1. Cursor is restored after each frame present.
18//! 2. Terminal modes are restored on normal exit AND panic.
19//! 3. No full-screen clears in inline mode (preserves scrollback).
20//! 4. One writer owns terminal output (enforced by ownership).
21
22use std::io::{self, Write};
23
24use unicode_width::UnicodeWidthChar;
25
26use crate::terminal_capabilities::TerminalCapabilities;
27
28// ============================================================================
29// ANSI Escape Sequences
30// ============================================================================
31
32/// DEC cursor save (ESC 7) - more portable than CSI s.
33const CURSOR_SAVE: &[u8] = b"\x1b7";
34
35/// DEC cursor restore (ESC 8) - more portable than CSI u.
36const CURSOR_RESTORE: &[u8] = b"\x1b8";
37
38/// CSI sequence to move cursor to position (1-indexed).
39fn cursor_position(row: u16, col: u16) -> Vec<u8> {
40    format!("\x1b[{};{}H", row, col).into_bytes()
41}
42
43/// Set scroll region (DECSTBM): CSI top ; bottom r (1-indexed).
44fn set_scroll_region(top: u16, bottom: u16) -> Vec<u8> {
45    format!("\x1b[{};{}r", top, bottom).into_bytes()
46}
47
48/// Reset scroll region to full screen: CSI r.
49const RESET_SCROLL_REGION: &[u8] = b"\x1b[r";
50
51/// Erase line from cursor to end: CSI 0 K.
52#[allow(dead_code)] // Kept for future use in inline mode optimization
53const ERASE_TO_EOL: &[u8] = b"\x1b[0K";
54
55/// Erase entire line: CSI 2 K.
56const ERASE_LINE: &[u8] = b"\x1b[2K";
57
58/// Synchronized output begin (DEC 2026): CSI ? 2026 h.
59const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
60
61/// Synchronized output end (DEC 2026): CSI ? 2026 l.
62const SYNC_END: &[u8] = b"\x1b[?2026l";
63
64// ============================================================================
65// Inline Mode Strategy
66// ============================================================================
67
68/// Inline mode rendering strategy.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum InlineStrategy {
71    /// Use scroll regions (DECSTBM) to anchor UI while logs scroll.
72    /// More efficient but less portable (muxes may misbehave).
73    ScrollRegion,
74
75    /// Overlay redraw: save cursor, write logs, redraw UI, restore cursor.
76    /// More portable but more redraw work.
77    OverlayRedraw,
78
79    /// Hybrid: overlay-redraw baseline with scroll-region optimization
80    /// where safe (detected modern terminals without mux).
81    #[default]
82    Hybrid,
83}
84
85impl InlineStrategy {
86    /// Select strategy based on terminal capabilities.
87    ///
88    /// Hybrid mode uses scroll-region only when:
89    /// - Not in a terminal multiplexer (tmux/screen/zellij)
90    /// - Scroll region capability is detected
91    /// - Synchronized output is available (reduces flicker)
92    #[must_use]
93    pub fn select(caps: &TerminalCapabilities) -> Self {
94        if caps.in_any_mux() {
95            // Muxes may not handle scroll regions correctly
96            InlineStrategy::OverlayRedraw
97        } else if caps.scroll_region && caps.sync_output {
98            // Modern terminal with full support
99            InlineStrategy::ScrollRegion
100        } else if caps.scroll_region {
101            // Scroll region available but no sync output - use hybrid
102            InlineStrategy::Hybrid
103        } else {
104            // Fallback to most portable option
105            InlineStrategy::OverlayRedraw
106        }
107    }
108}
109
110// ============================================================================
111// Inline Mode Session
112// ============================================================================
113
114/// Configuration for inline mode rendering.
115#[derive(Debug, Clone, Copy)]
116pub struct InlineConfig {
117    /// Height of the UI region (bottom N rows).
118    pub ui_height: u16,
119
120    /// Total terminal height.
121    pub term_height: u16,
122
123    /// Total terminal width.
124    pub term_width: u16,
125
126    /// Rendering strategy to use.
127    pub strategy: InlineStrategy,
128
129    /// Use synchronized output (DEC 2026) if available.
130    pub use_sync_output: bool,
131}
132
133impl InlineConfig {
134    /// Create config for a UI region of given height.
135    #[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    /// Set the rendering strategy.
147    #[must_use]
148    pub const fn with_strategy(mut self, strategy: InlineStrategy) -> Self {
149        self.strategy = strategy;
150        self
151    }
152
153    /// Enable synchronized output.
154    #[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    /// Row where the UI region starts (1-indexed for ANSI).
161    ///
162    /// Returns at least 1 (valid ANSI row).
163    #[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        // Ensure we return at least row 1 (valid ANSI row)
170        if row == 0 { 1 } else { row }
171    }
172
173    /// Row where the log region ends (1-indexed for ANSI).
174    ///
175    /// Returns 0 if there's no room for logs (UI takes full height).
176    /// Callers should check for 0 before using this value.
177    #[must_use]
178    pub const fn log_bottom_row(&self) -> u16 {
179        self.ui_top_row().saturating_sub(1)
180    }
181
182    /// Check if the configuration is valid for inline mode.
183    ///
184    /// Returns `true` if there's room for both logs and UI.
185    #[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
191// ============================================================================
192// Inline Mode Renderer
193// ============================================================================
194
195/// Inline mode renderer implementing the one-writer rule.
196///
197/// This struct owns terminal output and enforces that all writes go through it.
198/// Cleanup is guaranteed via `Drop`.
199pub 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    /// Create a new inline renderer.
209    ///
210    /// # Arguments
211    /// * `writer` - The terminal output (takes ownership to enforce one-writer rule).
212    /// * `config` - Inline mode configuration.
213    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    /// Initialize inline mode on the terminal.
224    ///
225    /// For scroll-region strategy, this sets up DECSTBM.
226    /// For overlay/hybrid strategy, this just prepares state.
227    pub fn enter(&mut self) -> io::Result<()> {
228        match self.config.strategy {
229            InlineStrategy::ScrollRegion => {
230                // Set scroll region to log area (top of screen to just above UI)
231                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                // No setup needed for overlay-based modes.
239                // Hybrid uses overlay as baseline; scroll-region would be an
240                // internal optimization applied per-operation, not upfront.
241            }
242        }
243        self.writer.flush()
244    }
245
246    /// Exit inline mode, restoring terminal state.
247    pub fn exit(&mut self) -> io::Result<()> {
248        self.cleanup_internal()
249    }
250
251    /// Write log output (goes to scrollback region).
252    ///
253    /// In scroll-region mode: writes to current cursor position in scroll region.
254    /// In overlay mode: saves cursor, writes, then restores cursor.
255    ///
256    /// Returns `Ok(())` even if there's no log region (logs are silently dropped
257    /// when UI takes the full terminal height).
258    pub fn write_log(&mut self, text: &str) -> io::Result<()> {
259        let log_row = self.config.log_bottom_row();
260
261        // If there's no room for logs, silently drop
262        if log_row == 0 {
263            return Ok(());
264        }
265
266        match self.config.strategy {
267            InlineStrategy::ScrollRegion => {
268                // Cursor should be in scroll region; just write
269                self.writer.write_all(text.as_bytes())?;
270            }
271            InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
272                // Save cursor, move to log area, write, restore
273                self.writer.write_all(CURSOR_SAVE)?;
274                self.cursor_saved = true;
275
276                // Move to bottom of log region
277                self.writer.write_all(&cursor_position(log_row, 1))?;
278                self.writer.write_all(ERASE_LINE)?;
279
280                // Keep overlay logging single-line so wraps/newlines never scribble
281                // into the UI region below.
282                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                // Restore cursor
289                self.writer.write_all(CURSOR_RESTORE)?;
290                self.cursor_saved = false;
291            }
292        }
293        self.writer.flush()
294    }
295
296    /// Present a UI frame.
297    ///
298    /// # Invariants
299    /// - Cursor position is saved before and restored after.
300    /// - UI region is redrawn without affecting scrollback.
301    /// - Synchronized output wraps the operation if enabled.
302    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        // Begin sync output to prevent flicker
314        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        // Save cursor position
320        self.writer.write_all(CURSOR_SAVE)?;
321        self.cursor_saved = true;
322
323        let operation_result = (|| -> io::Result<()> {
324            // Move to UI region
325            let ui_row = self.config.ui_top_row();
326            self.writer.write_all(&cursor_position(ui_row, 1))?;
327
328            // Clear and render each UI line
329            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            // Move back to start of UI and let caller render
336            self.writer.write_all(&cursor_position(ui_row, 1))?;
337            render_fn(&mut self.writer, &self.config)?;
338            Ok(())
339        })();
340
341        // Always attempt to restore terminal state even if rendering failed.
342        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            // Skip ASCII control characters so logs cannot inject cursor motion.
381            if ch.is_control() {
382                continue;
383            }
384
385            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
386            if ch_width == 0 {
387                // Keep combining marks only when they can attach to prior text.
388                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    /// Internal cleanup - guaranteed to run on drop.
409    fn cleanup_internal(&mut self) -> io::Result<()> {
410        // End any pending sync block
411        if self.in_sync_block {
412            let _ = self.writer.write_all(SYNC_END);
413            self.in_sync_block = false;
414        }
415
416        // Reset scroll region if we set one
417        if self.scroll_region_set {
418            let _ = self.writer.write_all(RESET_SCROLL_REGION);
419            self.scroll_region_set = false;
420        }
421
422        // Restore cursor only if we saved it (avoid restoring to stale position)
423        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        // Best-effort cleanup on drop (including panic)
435        let _ = self.cleanup_internal();
436    }
437}
438
439// ============================================================================
440// Tests
441// ============================================================================
442
443#[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        // 24 row terminal, 6 row UI
468        let config = InlineConfig::new(6, 24, 80);
469        assert_eq!(config.ui_top_row(), 19); // rows 19-24 are UI
470        assert_eq!(config.log_bottom_row(), 18); // rows 1-18 are logs
471    }
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        // Should set scroll region: ESC [ 1 ; 18 r
510        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        // Should reset scroll region: ESC [ r
523        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        // Should save cursor (ESC 7)
543        assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
544        // Should restore cursor (ESC 8)
545        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        // Should have sync begin and end
559        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            // Renderer dropped here
572        }
573
574        // Can't easily test drop output, but this verifies no panic
575    }
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        // Should save and restore cursor
586        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        // Hybrid should NOT set scroll region (uses overlay baseline)
640        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        // Valid config
647        let valid = InlineConfig::new(6, 24, 80);
648        assert!(valid.is_valid());
649
650        // UI takes all rows (no room for logs)
651        let full_ui = InlineConfig::new(24, 24, 80);
652        assert!(!full_ui.is_valid());
653
654        // Zero UI height
655        let no_ui = InlineConfig::new(0, 24, 80);
656        assert!(!no_ui.is_valid());
657
658        // Single row terminal
659        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        // UI takes full height
666        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        // UI takes full height - no room for logs
674        let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
675        let mut renderer = InlineRenderer::new(writer, config);
676
677        // Should succeed but not write anything meaningful
678        renderer.write_log("test log\n").unwrap();
679
680        // Should not have written cursor save/restore since we bailed early
681        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        // Just enter and exit, never save cursor explicitly
691        renderer.enter().unwrap();
692        writer_clear(&mut renderer.writer); // Clear output to check cleanup behavior
693        renderer.exit().unwrap();
694
695        // Should NOT restore cursor since we never saved it
696        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        // ui_height >= term_height means saturating_sub yields 0, +1 = 1
707        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        // In scroll-region mode, log is written directly without cursor save/restore
729        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        // Should contain ERASE_LINE sequences for the 2 UI rows
742        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}