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: the same DECSTBM scroll region as `ScrollRegion` but without
80    /// synchronized-output brackets, verified at startup by the DECSTBM
81    /// self-test (`caps_probe::probe_scroll_region`); a terminal that
82    /// ignores the region is switched to `OverlayRedraw` before the first
83    /// frame.
84    #[default]
85    Hybrid,
86}
87
88impl InlineStrategy {
89    /// Select the strategy from capabilities, in this order:
90    /// - inside a terminal multiplexer (tmux/screen/zellij/WezTerm mux):
91    ///   `OverlayRedraw` (muxes may not handle scroll regions correctly);
92    /// - scroll region and synchronized output: `ScrollRegion`;
93    /// - scroll region without synchronized output: `Hybrid`;
94    /// - otherwise `OverlayRedraw`.
95    ///
96    /// The runtime then runs the DECSTBM self-test for `ScrollRegion` and
97    /// `Hybrid` and falls back to `OverlayRedraw` when the terminal does not
98    /// honour the region (`TerminalWriter::set_scroll_region_verified`).
99    #[must_use]
100    pub fn select(caps: &TerminalCapabilities) -> Self {
101        if caps.in_any_mux() {
102            // Muxes may not handle scroll regions correctly
103            InlineStrategy::OverlayRedraw
104        } else if caps.use_scroll_region() && caps.use_sync_output() {
105            // Modern terminal with full support
106            InlineStrategy::ScrollRegion
107        } else if caps.use_scroll_region() {
108            // Scroll region available but no sync output - use hybrid
109            InlineStrategy::Hybrid
110        } else {
111            // Fallback to most portable option
112            InlineStrategy::OverlayRedraw
113        }
114    }
115}
116
117// ============================================================================
118// Inline Mode Session
119// ============================================================================
120
121/// Configuration for inline mode rendering.
122#[derive(Debug, Clone, Copy)]
123pub struct InlineConfig {
124    /// Height of the UI region (bottom N rows).
125    pub ui_height: u16,
126
127    /// Total terminal height.
128    pub term_height: u16,
129
130    /// Total terminal width.
131    pub term_width: u16,
132
133    /// Rendering strategy to use.
134    pub strategy: InlineStrategy,
135
136    /// Use synchronized output (DEC 2026) if available.
137    pub use_sync_output: bool,
138}
139
140impl InlineConfig {
141    /// Create config for a UI region of given height.
142    #[must_use]
143    pub fn new(ui_height: u16, term_height: u16, term_width: u16) -> Self {
144        Self {
145            ui_height,
146            term_height,
147            term_width,
148            strategy: InlineStrategy::default(),
149            use_sync_output: false,
150        }
151    }
152
153    /// Set the rendering strategy.
154    #[must_use]
155    pub const fn with_strategy(mut self, strategy: InlineStrategy) -> Self {
156        self.strategy = strategy;
157        self
158    }
159
160    /// Enable synchronized output.
161    #[must_use]
162    pub const fn with_sync_output(mut self, enabled: bool) -> Self {
163        self.use_sync_output = enabled;
164        self
165    }
166
167    /// Row where the UI region starts (1-indexed for ANSI).
168    ///
169    /// Returns at least 1 (valid ANSI row).
170    #[must_use]
171    pub const fn ui_top_row(&self) -> u16 {
172        let row = self
173            .term_height
174            .saturating_sub(self.ui_height)
175            .saturating_add(1);
176        // Ensure we return at least row 1 (valid ANSI row)
177        if row == 0 { 1 } else { row }
178    }
179
180    /// Row where the log region ends (1-indexed for ANSI).
181    ///
182    /// Returns 0 if there's no room for logs (UI takes full height).
183    /// Callers should check for 0 before using this value.
184    #[must_use]
185    pub const fn log_bottom_row(&self) -> u16 {
186        self.ui_top_row().saturating_sub(1)
187    }
188
189    /// Check if the configuration is valid for inline mode.
190    ///
191    /// Returns `true` if there's room for both logs and UI.
192    #[must_use]
193    pub const fn is_valid(&self) -> bool {
194        self.ui_height > 0 && self.ui_height < self.term_height && self.term_height > 1
195    }
196}
197
198// ============================================================================
199// Inline Mode Renderer
200// ============================================================================
201
202/// Inline mode renderer implementing the one-writer rule.
203///
204/// This struct owns terminal output and enforces that all writes go through it.
205/// Cleanup is guaranteed via `Drop`.
206pub struct InlineRenderer<W: Write> {
207    writer: W,
208    config: InlineConfig,
209    scroll_region_set: bool,
210    in_sync_block: bool,
211    cursor_saved: bool,
212}
213
214impl<W: Write> InlineRenderer<W> {
215    /// Create a new inline renderer.
216    ///
217    /// # Arguments
218    /// * `writer` - The terminal output (takes ownership to enforce one-writer rule).
219    /// * `config` - Inline mode configuration.
220    pub fn new(writer: W, config: InlineConfig) -> Self {
221        Self {
222            writer,
223            config,
224            scroll_region_set: false,
225            in_sync_block: false,
226            cursor_saved: false,
227        }
228    }
229
230    #[inline]
231    fn sync_output_enabled(&self) -> bool {
232        self.config.use_sync_output && TerminalCapabilities::with_overrides().use_sync_output()
233    }
234
235    /// Initialize inline mode on the terminal.
236    ///
237    /// For scroll-region strategy, this sets up DECSTBM.
238    /// For overlay/hybrid strategy, this just prepares state.
239    pub fn enter(&mut self) -> io::Result<()> {
240        match self.config.strategy {
241            InlineStrategy::ScrollRegion => {
242                // Set scroll region to log area (top of screen to just above UI)
243                let log_bottom = self.config.log_bottom_row();
244                if log_bottom > 0 {
245                    self.writer.write_all(&set_scroll_region(1, log_bottom))?;
246                    self.scroll_region_set = true;
247                }
248            }
249            InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
250                // No setup needed for overlay-based modes.
251                // Hybrid uses overlay as baseline; scroll-region would be an
252                // internal optimization applied per-operation, not upfront.
253            }
254        }
255        self.writer.flush()
256    }
257
258    /// Exit inline mode, restoring terminal state.
259    pub fn exit(&mut self) -> io::Result<()> {
260        self.cleanup_internal()
261    }
262
263    /// Write log output (goes to scrollback region).
264    ///
265    /// In scroll-region mode: writes to current cursor position in scroll region.
266    /// In overlay mode: saves cursor, writes, then restores cursor.
267    ///
268    /// Returns `Ok(())` even if there's no log region (logs are silently dropped
269    /// when UI takes the full terminal height).
270    pub fn write_log(&mut self, text: &str) -> io::Result<()> {
271        let log_row = self.config.log_bottom_row();
272
273        // If there's no room for logs, silently drop
274        if log_row == 0 {
275            return Ok(());
276        }
277
278        match self.config.strategy {
279            InlineStrategy::ScrollRegion => {
280                // Cursor should be in scroll region; just write
281                let safe_text = Self::sanitize_scroll_region_log_text(text);
282                if !safe_text.is_empty() {
283                    self.writer.write_all(safe_text.as_bytes())?;
284                }
285            }
286            InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
287                // Save cursor, move to log area, write, restore
288                self.writer.write_all(CURSOR_SAVE)?;
289                self.cursor_saved = true;
290
291                // Move to bottom of log region
292                self.writer.write_all(&cursor_position(log_row, 1))?;
293                self.writer.write_all(ERASE_LINE)?;
294
295                // Keep overlay logging single-line so wraps/newlines never scribble
296                // into the UI region below.
297                let safe_line =
298                    sanitize_overlay_log_line(text, usize::from(self.config.term_width));
299                if !safe_line.is_empty() {
300                    self.writer.write_all(safe_line.as_bytes())?;
301                }
302
303                // Restore cursor
304                self.writer.write_all(CURSOR_RESTORE)?;
305                self.cursor_saved = false;
306            }
307        }
308        self.writer.flush()
309    }
310
311    /// Present a UI frame.
312    ///
313    /// # Invariants
314    /// - Cursor position is saved before and restored after.
315    /// - UI region is redrawn without affecting scrollback.
316    /// - Synchronized output wraps the operation if enabled.
317    pub fn present_ui<F>(&mut self, render_fn: F) -> io::Result<()>
318    where
319        F: FnOnce(&mut W, &InlineConfig) -> io::Result<()>,
320    {
321        if !self.config.is_valid() {
322            return Err(io::Error::new(
323                io::ErrorKind::InvalidInput,
324                "invalid inline mode configuration",
325            ));
326        }
327
328        let sync_output_enabled = self.sync_output_enabled();
329
330        // Begin sync output to prevent flicker.
331        if sync_output_enabled && !self.in_sync_block {
332            // Mark active before write so cleanup conservatively emits SYNC_END
333            // even if begin write fails after partial bytes.
334            self.in_sync_block = true;
335            if let Err(err) = self.writer.write_all(SYNC_BEGIN) {
336                // Best-effort immediate close to avoid leaving terminal state
337                // in synchronized-output mode on begin-write failure.
338                let _ = self.writer.write_all(SYNC_END);
339                self.in_sync_block = false;
340                let _ = self.writer.flush();
341                return Err(err);
342            }
343        }
344
345        // Save cursor position
346        self.writer.write_all(CURSOR_SAVE)?;
347        self.cursor_saved = true;
348
349        let operation_result = (|| -> io::Result<()> {
350            // Move to UI region
351            let ui_row = self.config.ui_top_row();
352            self.writer.write_all(&cursor_position(ui_row, 1))?;
353
354            // Clear and render each UI line
355            for row in 0..self.config.ui_height {
356                self.writer
357                    .write_all(&cursor_position(ui_row.saturating_add(row), 1))?;
358                self.writer.write_all(ERASE_LINE)?;
359            }
360
361            // Move back to start of UI and let caller render
362            self.writer.write_all(&cursor_position(ui_row, 1))?;
363            render_fn(&mut self.writer, &self.config)?;
364            Ok(())
365        })();
366
367        // Always attempt to restore terminal state even if rendering failed.
368        let restore_result = self.writer.write_all(CURSOR_RESTORE);
369        if restore_result.is_ok() {
370            self.cursor_saved = false;
371        }
372
373        let sync_end_result = if sync_output_enabled && self.in_sync_block {
374            let res = self.writer.write_all(SYNC_END);
375            if res.is_ok() {
376                self.in_sync_block = false;
377            }
378            Some(res)
379        } else {
380            if !sync_output_enabled {
381                // Defensive stale-state cleanup: clear internal state without
382                // emitting DEC 2026 when policy disables synchronized output.
383                self.in_sync_block = false;
384            }
385            None
386        };
387
388        let flush_result = self.writer.flush();
389
390        // If cleanup fails, surface that first so callers can treat terminal
391        // state restoration issues as higher-severity than render errors.
392        let cleanup_error = restore_result
393            .err()
394            .or_else(|| sync_end_result.and_then(Result::err))
395            .or_else(|| flush_result.err());
396        if let Some(err) = cleanup_error {
397            return Err(err);
398        }
399        operation_result
400    }
401
402    fn sanitize_scroll_region_log_text(text: &str) -> String {
403        let bytes = text.as_bytes();
404        let mut out = String::with_capacity(text.len());
405        let mut i = 0;
406
407        while i < bytes.len() {
408            match bytes[i] {
409                // ESC - strip full sequence payload (CSI/OSC/DCS/APC/single-char escapes).
410                0x1B => {
411                    i = Self::skip_escape_sequence(bytes, i);
412                }
413                // Preserve LF and normalize CR to LF.
414                0x0A => {
415                    out.push('\n');
416                    i += 1;
417                }
418                0x0D => {
419                    out.push('\n');
420                    i += 1;
421                }
422                // Strip remaining C0 controls and DEL.
423                0x00..=0x1F | 0x7F => {
424                    i += 1;
425                }
426                // Printable ASCII.
427                0x20..=0x7E => {
428                    out.push(bytes[i] as char);
429                    i += 1;
430                }
431                // UTF-8: decode and drop C1 controls (U+0080..U+009F).
432                _ => {
433                    if let Some((ch, len)) = Self::decode_utf8_char(&bytes[i..]) {
434                        if !('\u{0080}'..='\u{009F}').contains(&ch) {
435                            out.push(ch);
436                        }
437                        i += len;
438                    } else {
439                        i += 1;
440                    }
441                }
442            }
443        }
444
445        out
446    }
447
448    fn skip_escape_sequence(bytes: &[u8], start: usize) -> usize {
449        let mut i = start + 1; // Skip ESC
450        if i >= bytes.len() {
451            return i;
452        }
453
454        match bytes[i] {
455            // CSI sequence: ESC [ params... final_byte
456            b'[' => {
457                i += 1;
458                while i < bytes.len() {
459                    let b = bytes[i];
460                    if (0x40..=0x7E).contains(&b) {
461                        return i + 1;
462                    }
463                    if !(0x20..=0x3F).contains(&b) {
464                        return i;
465                    }
466                    i += 1;
467                }
468            }
469            // OSC sequence: ESC ] ... (BEL or ST)
470            b']' => {
471                i += 1;
472                while i < bytes.len() {
473                    let b = bytes[i];
474                    if b == 0x07 {
475                        return i + 1;
476                    }
477                    if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
478                        return i + 2;
479                    }
480                    if b == 0x1B || b < 0x20 {
481                        return i;
482                    }
483                    i += 1;
484                }
485            }
486            // DCS/PM/APC: ESC P/^/_ ... ST
487            b'P' | b'^' | b'_' => {
488                i += 1;
489                while i < bytes.len() {
490                    let b = bytes[i];
491                    if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
492                        return i + 2;
493                    }
494                    if b == 0x1B || b < 0x20 {
495                        return i;
496                    }
497                    i += 1;
498                }
499            }
500            // Single-char escape sequences.
501            0x20..=0x7E => return i + 1,
502            _ => {}
503        }
504
505        i
506    }
507
508    fn decode_utf8_char(bytes: &[u8]) -> Option<(char, usize)> {
509        if bytes.is_empty() {
510            return None;
511        }
512
513        let first = bytes[0];
514        let (expected_len, mut codepoint) = match first {
515            0x00..=0x7F => return Some((first as char, 1)),
516            0xC0..=0xDF => (2, (first & 0x1F) as u32),
517            0xE0..=0xEF => (3, (first & 0x0F) as u32),
518            0xF0..=0xF7 => (4, (first & 0x07) as u32),
519            _ => return None,
520        };
521
522        if bytes.len() < expected_len {
523            return None;
524        }
525
526        for &b in bytes.iter().take(expected_len).skip(1) {
527            if (b & 0xC0) != 0x80 {
528                return None;
529            }
530            codepoint = (codepoint << 6) | (b & 0x3F) as u32;
531        }
532
533        let min_codepoint = match expected_len {
534            2 => 0x80,
535            3 => 0x800,
536            4 => 0x1_0000,
537            _ => return None,
538        };
539        if codepoint < min_codepoint {
540            return None;
541        }
542
543        char::from_u32(codepoint).map(|c| (c, expected_len))
544    }
545
546    /// Internal cleanup - guaranteed to run on drop.
547    fn cleanup_internal(&mut self) -> io::Result<()> {
548        let sync_output_enabled = self.sync_output_enabled();
549
550        // End any pending sync block
551        if self.in_sync_block {
552            if sync_output_enabled {
553                let _ = self.writer.write_all(SYNC_END);
554            }
555            self.in_sync_block = false;
556        }
557
558        // Reset scroll region if we set one
559        if self.scroll_region_set {
560            let _ = self.writer.write_all(RESET_SCROLL_REGION);
561            self.scroll_region_set = false;
562        }
563
564        // Restore cursor only if we saved it (avoid restoring to stale position)
565        if self.cursor_saved {
566            let _ = self.writer.write_all(CURSOR_RESTORE);
567            self.cursor_saved = false;
568        }
569
570        self.writer.flush()
571    }
572}
573
574/// Reduce arbitrary (already escape-sanitized or raw) text to at most one
575/// display line: everything from the first `\n`/`\r` onward is dropped,
576/// remaining control characters are skipped, and the result is truncated to
577/// `max_cols` terminal columns using Unicode display width.
578///
579/// Used by overlay-style inline logging ([`InlineRenderer::write_log`] and
580/// `TerminalWriter::write_log`) where emitting a newline would scroll (and
581/// corrupt) the displayed UI.
582pub fn sanitize_overlay_log_line(text: &str, max_cols: usize) -> String {
583    if max_cols == 0 {
584        return String::new();
585    }
586
587    let mut out = String::new();
588    let mut used_cols = 0usize;
589
590    for ch in text.chars() {
591        if ch == '\n' || ch == '\r' {
592            break;
593        }
594
595        // Skip ASCII control characters so logs cannot inject cursor motion.
596        if ch.is_control() {
597            continue;
598        }
599
600        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
601        if ch_width == 0 {
602            // Keep combining marks only when they can attach to prior text.
603            if !out.is_empty() {
604                out.push(ch);
605            }
606            continue;
607        }
608
609        if used_cols.saturating_add(ch_width) > max_cols {
610            break;
611        }
612
613        out.push(ch);
614        used_cols += ch_width;
615        if used_cols == max_cols {
616            break;
617        }
618    }
619
620    out
621}
622
623impl<W: Write> Drop for InlineRenderer<W> {
624    fn drop(&mut self) {
625        // Best-effort cleanup on drop (including panic)
626        let _ = self.cleanup_internal();
627    }
628}
629
630// ============================================================================
631// Tests
632// ============================================================================
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use std::io::Cursor;
638
639    type TestWriter = Cursor<Vec<u8>>;
640
641    fn test_writer() -> TestWriter {
642        Cursor::new(Vec::new())
643    }
644
645    fn writer_contains_sequence(writer: &TestWriter, seq: &[u8]) -> bool {
646        writer
647            .get_ref()
648            .windows(seq.len())
649            .any(|window| window == seq)
650    }
651
652    fn writer_clear(writer: &mut TestWriter) {
653        writer.get_mut().clear();
654    }
655
656    fn sync_policy_allows() -> bool {
657        TerminalCapabilities::with_overrides().use_sync_output()
658    }
659
660    #[test]
661    fn config_calculates_regions_correctly() {
662        // 24 row terminal, 6 row UI
663        let config = InlineConfig::new(6, 24, 80);
664        assert_eq!(config.ui_top_row(), 19); // rows 19-24 are UI
665        assert_eq!(config.log_bottom_row(), 18); // rows 1-18 are logs
666    }
667
668    #[test]
669    fn strategy_selection_prefers_overlay_in_mux() {
670        let mut caps = TerminalCapabilities::basic();
671        caps.in_tmux = true;
672        caps.scroll_region = true;
673        caps.sync_output = true;
674
675        assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
676    }
677
678    #[test]
679    fn strategy_selection_uses_scroll_region_in_modern_terminal() {
680        let mut caps = TerminalCapabilities::basic();
681        caps.scroll_region = true;
682        caps.sync_output = true;
683
684        assert_eq!(InlineStrategy::select(&caps), InlineStrategy::ScrollRegion);
685    }
686
687    #[test]
688    fn strategy_selection_uses_hybrid_without_sync() {
689        let mut caps = TerminalCapabilities::basic();
690        caps.scroll_region = true;
691        caps.sync_output = false;
692
693        assert_eq!(InlineStrategy::select(&caps), InlineStrategy::Hybrid);
694    }
695
696    #[test]
697    fn enter_sets_scroll_region_for_scroll_strategy() {
698        let writer = test_writer();
699        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
700        let mut renderer = InlineRenderer::new(writer, config);
701
702        renderer.enter().unwrap();
703
704        // Should set scroll region: ESC [ 1 ; 18 r
705        assert!(writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
706    }
707
708    #[test]
709    fn exit_resets_scroll_region() {
710        let writer = test_writer();
711        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
712        let mut renderer = InlineRenderer::new(writer, config);
713
714        renderer.enter().unwrap();
715        renderer.exit().unwrap();
716
717        // Should reset scroll region: ESC [ r
718        assert!(writer_contains_sequence(
719            &renderer.writer,
720            RESET_SCROLL_REGION
721        ));
722    }
723
724    #[test]
725    fn present_ui_saves_and_restores_cursor() {
726        let writer = test_writer();
727        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
728        let mut renderer = InlineRenderer::new(writer, config);
729
730        renderer
731            .present_ui(|w, _| {
732                w.write_all(b"UI Content")?;
733                Ok(())
734            })
735            .unwrap();
736
737        // Should save cursor (ESC 7)
738        assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
739        // Should restore cursor (ESC 8)
740        assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
741    }
742
743    #[test]
744    fn present_ui_uses_sync_output_when_enabled() {
745        let writer = test_writer();
746        let config = InlineConfig::new(6, 24, 80)
747            .with_strategy(InlineStrategy::OverlayRedraw)
748            .with_sync_output(true);
749        let mut renderer = InlineRenderer::new(writer, config);
750
751        renderer.present_ui(|_, _| Ok(())).unwrap();
752
753        if sync_policy_allows() {
754            assert!(writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
755            assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
756        } else {
757            assert!(!writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
758            assert!(!writer_contains_sequence(&renderer.writer, SYNC_END));
759        }
760    }
761
762    #[test]
763    fn drop_cleans_up_scroll_region() {
764        let writer = test_writer();
765        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
766
767        {
768            let mut renderer = InlineRenderer::new(writer, config);
769            renderer.enter().unwrap();
770            // Renderer dropped here
771        }
772
773        // Can't easily test drop output, but this verifies no panic
774    }
775
776    #[test]
777    fn write_log_preserves_cursor_in_overlay_mode() {
778        let writer = test_writer();
779        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
780        let mut renderer = InlineRenderer::new(writer, config);
781
782        renderer.write_log("test log\n").unwrap();
783
784        // Should save and restore cursor
785        assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
786        assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
787    }
788
789    #[test]
790    fn write_log_overlay_truncates_to_single_safe_line() {
791        let writer = test_writer();
792        let config = InlineConfig::new(6, 24, 5).with_strategy(InlineStrategy::OverlayRedraw);
793        let mut renderer = InlineRenderer::new(writer, config);
794
795        renderer.write_log("ABCDE\nSECOND").unwrap();
796
797        let output = String::from_utf8_lossy(renderer.writer.get_ref());
798        assert!(output.contains("ABCDE"));
799        assert!(!output.contains("SECOND"));
800        assert!(!output.contains('\n'));
801    }
802
803    #[test]
804    fn write_log_overlay_truncates_wide_chars_by_display_width() {
805        let writer = test_writer();
806        let config = InlineConfig::new(6, 24, 3).with_strategy(InlineStrategy::OverlayRedraw);
807        let mut renderer = InlineRenderer::new(writer, config);
808
809        renderer.write_log("ab界Z").unwrap();
810
811        let output = String::from_utf8_lossy(renderer.writer.get_ref());
812        assert!(output.contains("ab"));
813        assert!(!output.contains('界'));
814        assert!(!output.contains('Z'));
815    }
816
817    #[test]
818    fn write_log_overlay_allows_wide_char_when_it_exactly_fits_width() {
819        let writer = test_writer();
820        let config = InlineConfig::new(6, 24, 4).with_strategy(InlineStrategy::OverlayRedraw);
821        let mut renderer = InlineRenderer::new(writer, config);
822
823        renderer.write_log("ab界Z").unwrap();
824
825        let output = String::from_utf8_lossy(renderer.writer.get_ref());
826        assert!(output.contains("ab界"));
827        assert!(!output.contains('Z'));
828    }
829
830    #[test]
831    fn hybrid_does_not_set_scroll_region_in_enter() {
832        let writer = test_writer();
833        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::Hybrid);
834        let mut renderer = InlineRenderer::new(writer, config);
835
836        renderer.enter().unwrap();
837
838        // Hybrid should NOT set scroll region (uses overlay baseline)
839        assert!(!writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
840        assert!(!renderer.scroll_region_set);
841    }
842
843    #[test]
844    fn config_is_valid_checks_boundaries() {
845        // Valid config
846        let valid = InlineConfig::new(6, 24, 80);
847        assert!(valid.is_valid());
848
849        // UI takes all rows (no room for logs)
850        let full_ui = InlineConfig::new(24, 24, 80);
851        assert!(!full_ui.is_valid());
852
853        // Zero UI height
854        let no_ui = InlineConfig::new(0, 24, 80);
855        assert!(!no_ui.is_valid());
856
857        // Single row terminal
858        let tiny = InlineConfig::new(1, 1, 80);
859        assert!(!tiny.is_valid());
860    }
861
862    #[test]
863    fn log_bottom_row_zero_when_no_room() {
864        // UI takes full height
865        let config = InlineConfig::new(24, 24, 80);
866        assert_eq!(config.log_bottom_row(), 0);
867    }
868
869    #[test]
870    fn write_log_silently_drops_when_no_log_region() {
871        let writer = test_writer();
872        // UI takes full height - no room for logs
873        let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
874        let mut renderer = InlineRenderer::new(writer, config);
875
876        // Should succeed but not write anything meaningful
877        renderer.write_log("test log\n").unwrap();
878
879        // Should not have written cursor save/restore since we bailed early
880        assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
881    }
882
883    #[test]
884    fn cleanup_does_not_restore_unsaved_cursor() {
885        let writer = test_writer();
886        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
887        let mut renderer = InlineRenderer::new(writer, config);
888
889        // Just enter and exit, never save cursor explicitly
890        renderer.enter().unwrap();
891        writer_clear(&mut renderer.writer); // Clear output to check cleanup behavior
892        renderer.exit().unwrap();
893
894        // Should NOT restore cursor since we never saved it
895        assert!(!writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
896    }
897
898    #[test]
899    fn inline_strategy_default_is_hybrid() {
900        assert_eq!(InlineStrategy::default(), InlineStrategy::Hybrid);
901    }
902
903    #[test]
904    fn config_ui_top_row_clamps_to_1() {
905        // ui_height >= term_height means saturating_sub yields 0, +1 = 1
906        let config = InlineConfig::new(30, 24, 80);
907        assert!(config.ui_top_row() >= 1);
908    }
909
910    #[test]
911    fn strategy_select_fallback_no_scroll_no_sync() {
912        let mut caps = TerminalCapabilities::basic();
913        caps.scroll_region = false;
914        caps.sync_output = false;
915        assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
916    }
917
918    #[test]
919    fn write_log_in_scroll_region_mode() {
920        let writer = test_writer();
921        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
922        let mut renderer = InlineRenderer::new(writer, config);
923
924        renderer.enter().unwrap();
925        renderer.write_log("hello\n").unwrap();
926
927        // In scroll-region mode, log is written directly without cursor save/restore
928        let output = renderer.writer.get_ref();
929        assert!(output.windows(b"hello\n".len()).any(|w| w == b"hello\n"));
930    }
931
932    #[test]
933    fn write_log_in_scroll_region_mode_sanitizes_escape_payloads() {
934        let writer = test_writer();
935        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
936        let mut renderer = InlineRenderer::new(writer, config);
937
938        renderer.enter().unwrap();
939        renderer
940            .write_log("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x\n")
941            .unwrap();
942
943        let output = String::from_utf8_lossy(renderer.writer.get_ref());
944        assert!(output.contains("safetailx\n"));
945        assert!(
946            !output.contains("52;c;SGVsbG8"),
947            "OSC payload should not survive scroll-region log sanitization"
948        );
949        assert!(
950            !output.contains('\u{009d}'),
951            "C1 controls must be stripped in scroll-region logging"
952        );
953    }
954
955    #[test]
956    fn present_ui_clears_ui_lines() {
957        let writer = test_writer();
958        let config = InlineConfig::new(2, 10, 80).with_strategy(InlineStrategy::OverlayRedraw);
959        let mut renderer = InlineRenderer::new(writer, config);
960
961        renderer.present_ui(|_, _| Ok(())).unwrap();
962
963        // Should contain ERASE_LINE sequences for the 2 UI rows
964        let count = renderer
965            .writer
966            .get_ref()
967            .windows(ERASE_LINE.len())
968            .filter(|w| *w == ERASE_LINE)
969            .count();
970        assert_eq!(count, 2);
971    }
972
973    #[test]
974    fn present_ui_render_error_still_restores_state() {
975        let writer = test_writer();
976        let config = InlineConfig::new(2, 10, 80)
977            .with_strategy(InlineStrategy::OverlayRedraw)
978            .with_sync_output(true);
979        let mut renderer = InlineRenderer::new(writer, config);
980
981        let err = renderer
982            .present_ui(|_, _| Err(io::Error::other("boom")))
983            .unwrap_err();
984        assert_eq!(err.kind(), io::ErrorKind::Other);
985
986        assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
987        if sync_policy_allows() {
988            assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
989        } else {
990            assert!(!writer_contains_sequence(&renderer.writer, SYNC_END));
991        }
992        assert!(!renderer.cursor_saved);
993        assert!(!renderer.in_sync_block);
994    }
995
996    #[test]
997    fn cleanup_skips_sync_end_when_sync_output_disabled() {
998        let writer = test_writer();
999        let config = InlineConfig::new(2, 10, 80)
1000            .with_strategy(InlineStrategy::OverlayRedraw)
1001            .with_sync_output(false);
1002        let mut renderer = InlineRenderer::new(writer, config);
1003        renderer.in_sync_block = true;
1004
1005        renderer.cleanup_internal().unwrap();
1006
1007        assert!(
1008            !writer_contains_sequence(&renderer.writer, SYNC_END),
1009            "sync_end must not be emitted when synchronized output is disabled"
1010        );
1011        assert!(!renderer.in_sync_block);
1012    }
1013
1014    #[test]
1015    fn present_ui_rejects_invalid_config() {
1016        let writer = test_writer();
1017        let config = InlineConfig::new(0, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
1018        let mut renderer = InlineRenderer::new(writer, config);
1019
1020        let err = renderer.present_ui(|_, _| Ok(())).unwrap_err();
1021        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1022        assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
1023    }
1024
1025    #[test]
1026    fn config_new_defaults() {
1027        let config = InlineConfig::new(5, 20, 100);
1028        assert_eq!(config.ui_height, 5);
1029        assert_eq!(config.term_height, 20);
1030        assert_eq!(config.term_width, 100);
1031        assert_eq!(config.strategy, InlineStrategy::Hybrid);
1032        assert!(!config.use_sync_output);
1033    }
1034}