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.use_scroll_region() && caps.use_sync_output() {
98            // Modern terminal with full support
99            InlineStrategy::ScrollRegion
100        } else if caps.use_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    #[inline]
224    fn sync_output_enabled(&self) -> bool {
225        self.config.use_sync_output && TerminalCapabilities::with_overrides().use_sync_output()
226    }
227
228    /// Initialize inline mode on the terminal.
229    ///
230    /// For scroll-region strategy, this sets up DECSTBM.
231    /// For overlay/hybrid strategy, this just prepares state.
232    pub fn enter(&mut self) -> io::Result<()> {
233        match self.config.strategy {
234            InlineStrategy::ScrollRegion => {
235                // Set scroll region to log area (top of screen to just above UI)
236                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                // No setup needed for overlay-based modes.
244                // Hybrid uses overlay as baseline; scroll-region would be an
245                // internal optimization applied per-operation, not upfront.
246            }
247        }
248        self.writer.flush()
249    }
250
251    /// Exit inline mode, restoring terminal state.
252    pub fn exit(&mut self) -> io::Result<()> {
253        self.cleanup_internal()
254    }
255
256    /// Write log output (goes to scrollback region).
257    ///
258    /// In scroll-region mode: writes to current cursor position in scroll region.
259    /// In overlay mode: saves cursor, writes, then restores cursor.
260    ///
261    /// Returns `Ok(())` even if there's no log region (logs are silently dropped
262    /// when UI takes the full terminal height).
263    pub fn write_log(&mut self, text: &str) -> io::Result<()> {
264        let log_row = self.config.log_bottom_row();
265
266        // If there's no room for logs, silently drop
267        if log_row == 0 {
268            return Ok(());
269        }
270
271        match self.config.strategy {
272            InlineStrategy::ScrollRegion => {
273                // Cursor should be in scroll region; just write
274                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                // Save cursor, move to log area, write, restore
281                self.writer.write_all(CURSOR_SAVE)?;
282                self.cursor_saved = true;
283
284                // Move to bottom of log region
285                self.writer.write_all(&cursor_position(log_row, 1))?;
286                self.writer.write_all(ERASE_LINE)?;
287
288                // Keep overlay logging single-line so wraps/newlines never scribble
289                // into the UI region below.
290                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                // Restore cursor
297                self.writer.write_all(CURSOR_RESTORE)?;
298                self.cursor_saved = false;
299            }
300        }
301        self.writer.flush()
302    }
303
304    /// Present a UI frame.
305    ///
306    /// # Invariants
307    /// - Cursor position is saved before and restored after.
308    /// - UI region is redrawn without affecting scrollback.
309    /// - Synchronized output wraps the operation if enabled.
310    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        // Begin sync output to prevent flicker.
324        if sync_output_enabled && !self.in_sync_block {
325            // Mark active before write so cleanup conservatively emits SYNC_END
326            // even if begin write fails after partial bytes.
327            self.in_sync_block = true;
328            if let Err(err) = self.writer.write_all(SYNC_BEGIN) {
329                // Best-effort immediate close to avoid leaving terminal state
330                // in synchronized-output mode on begin-write failure.
331                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        // Save cursor position
339        self.writer.write_all(CURSOR_SAVE)?;
340        self.cursor_saved = true;
341
342        let operation_result = (|| -> io::Result<()> {
343            // Move to UI region
344            let ui_row = self.config.ui_top_row();
345            self.writer.write_all(&cursor_position(ui_row, 1))?;
346
347            // Clear and render each UI line
348            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            // Move back to start of UI and let caller render
355            self.writer.write_all(&cursor_position(ui_row, 1))?;
356            render_fn(&mut self.writer, &self.config)?;
357            Ok(())
358        })();
359
360        // Always attempt to restore terminal state even if rendering failed.
361        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                // Defensive stale-state cleanup: clear internal state without
375                // emitting DEC 2026 when policy disables synchronized output.
376                self.in_sync_block = false;
377            }
378            None
379        };
380
381        let flush_result = self.writer.flush();
382
383        // If cleanup fails, surface that first so callers can treat terminal
384        // state restoration issues as higher-severity than render errors.
385        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                // ESC - strip full sequence payload (CSI/OSC/DCS/APC/single-char escapes).
403                0x1B => {
404                    i = Self::skip_escape_sequence(bytes, i);
405                }
406                // Preserve LF and normalize CR to LF.
407                0x0A => {
408                    out.push('\n');
409                    i += 1;
410                }
411                0x0D => {
412                    out.push('\n');
413                    i += 1;
414                }
415                // Strip remaining C0 controls and DEL.
416                0x00..=0x1F | 0x7F => {
417                    i += 1;
418                }
419                // Printable ASCII.
420                0x20..=0x7E => {
421                    out.push(bytes[i] as char);
422                    i += 1;
423                }
424                // UTF-8: decode and drop C1 controls (U+0080..U+009F).
425                _ => {
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; // Skip ESC
443        if i >= bytes.len() {
444            return i;
445        }
446
447        match bytes[i] {
448            // CSI sequence: ESC [ params... final_byte
449            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            // OSC sequence: ESC ] ... (BEL or ST)
463            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            // DCS/PM/APC: ESC P/^/_ ... ST
480            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            // Single-char escape sequences.
494            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            // Skip ASCII control characters so logs cannot inject cursor motion.
553            if ch.is_control() {
554                continue;
555            }
556
557            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
558            if ch_width == 0 {
559                // Keep combining marks only when they can attach to prior text.
560                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    /// Internal cleanup - guaranteed to run on drop.
581    fn cleanup_internal(&mut self) -> io::Result<()> {
582        let sync_output_enabled = self.sync_output_enabled();
583
584        // End any pending sync block
585        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        // Reset scroll region if we set one
593        if self.scroll_region_set {
594            let _ = self.writer.write_all(RESET_SCROLL_REGION);
595            self.scroll_region_set = false;
596        }
597
598        // Restore cursor only if we saved it (avoid restoring to stale position)
599        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        // Best-effort cleanup on drop (including panic)
611        let _ = self.cleanup_internal();
612    }
613}
614
615// ============================================================================
616// Tests
617// ============================================================================
618
619#[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        // 24 row terminal, 6 row UI
648        let config = InlineConfig::new(6, 24, 80);
649        assert_eq!(config.ui_top_row(), 19); // rows 19-24 are UI
650        assert_eq!(config.log_bottom_row(), 18); // rows 1-18 are logs
651    }
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        // Should set scroll region: ESC [ 1 ; 18 r
690        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        // Should reset scroll region: ESC [ r
703        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        // Should save cursor (ESC 7)
723        assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
724        // Should restore cursor (ESC 8)
725        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            // Renderer dropped here
756        }
757
758        // Can't easily test drop output, but this verifies no panic
759    }
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        // Should save and restore cursor
770        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        // Hybrid should NOT set scroll region (uses overlay baseline)
824        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        // Valid config
831        let valid = InlineConfig::new(6, 24, 80);
832        assert!(valid.is_valid());
833
834        // UI takes all rows (no room for logs)
835        let full_ui = InlineConfig::new(24, 24, 80);
836        assert!(!full_ui.is_valid());
837
838        // Zero UI height
839        let no_ui = InlineConfig::new(0, 24, 80);
840        assert!(!no_ui.is_valid());
841
842        // Single row terminal
843        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        // UI takes full height
850        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        // UI takes full height - no room for logs
858        let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
859        let mut renderer = InlineRenderer::new(writer, config);
860
861        // Should succeed but not write anything meaningful
862        renderer.write_log("test log\n").unwrap();
863
864        // Should not have written cursor save/restore since we bailed early
865        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        // Just enter and exit, never save cursor explicitly
875        renderer.enter().unwrap();
876        writer_clear(&mut renderer.writer); // Clear output to check cleanup behavior
877        renderer.exit().unwrap();
878
879        // Should NOT restore cursor since we never saved it
880        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        // ui_height >= term_height means saturating_sub yields 0, +1 = 1
891        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        // In scroll-region mode, log is written directly without cursor save/restore
913        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        // Should contain ERASE_LINE sequences for the 2 UI rows
949        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}