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 crate::terminal_capabilities::TerminalCapabilities;
25
26// ============================================================================
27// ANSI Escape Sequences
28// ============================================================================
29
30/// DEC cursor save (ESC 7) - more portable than CSI s.
31const CURSOR_SAVE: &[u8] = b"\x1b7";
32
33/// DEC cursor restore (ESC 8) - more portable than CSI u.
34const CURSOR_RESTORE: &[u8] = b"\x1b8";
35
36/// CSI sequence to move cursor to position (1-indexed).
37fn cursor_position(row: u16, col: u16) -> Vec<u8> {
38    format!("\x1b[{};{}H", row, col).into_bytes()
39}
40
41/// Set scroll region (DECSTBM): CSI top ; bottom r (1-indexed).
42fn set_scroll_region(top: u16, bottom: u16) -> Vec<u8> {
43    format!("\x1b[{};{}r", top, bottom).into_bytes()
44}
45
46/// Reset scroll region to full screen: CSI r.
47const RESET_SCROLL_REGION: &[u8] = b"\x1b[r";
48
49/// Erase line from cursor to end: CSI 0 K.
50#[allow(dead_code)] // Kept for future use in inline mode optimization
51const ERASE_TO_EOL: &[u8] = b"\x1b[0K";
52
53/// Erase entire line: CSI 2 K.
54const ERASE_LINE: &[u8] = b"\x1b[2K";
55
56/// Synchronized output begin (DEC 2026): CSI ? 2026 h.
57const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
58
59/// Synchronized output end (DEC 2026): CSI ? 2026 l.
60const SYNC_END: &[u8] = b"\x1b[?2026l";
61
62// ============================================================================
63// Inline Mode Strategy
64// ============================================================================
65
66/// Inline mode rendering strategy.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum InlineStrategy {
69    /// Use scroll regions (DECSTBM) to anchor UI while logs scroll.
70    /// More efficient but less portable (muxes may misbehave).
71    ScrollRegion,
72
73    /// Overlay redraw: save cursor, write logs, redraw UI, restore cursor.
74    /// More portable but more redraw work.
75    OverlayRedraw,
76
77    /// Hybrid: overlay-redraw baseline with scroll-region optimization
78    /// where safe (detected modern terminals without mux).
79    #[default]
80    Hybrid,
81}
82
83impl InlineStrategy {
84    /// Select strategy based on terminal capabilities.
85    ///
86    /// Hybrid mode uses scroll-region only when:
87    /// - Not in a terminal multiplexer (tmux/screen/zellij)
88    /// - Scroll region capability is detected
89    /// - Synchronized output is available (reduces flicker)
90    #[must_use]
91    pub fn select(caps: &TerminalCapabilities) -> Self {
92        if caps.in_any_mux() {
93            // Muxes may not handle scroll regions correctly
94            InlineStrategy::OverlayRedraw
95        } else if caps.scroll_region && caps.sync_output {
96            // Modern terminal with full support
97            InlineStrategy::ScrollRegion
98        } else if caps.scroll_region {
99            // Scroll region available but no sync output - use hybrid
100            InlineStrategy::Hybrid
101        } else {
102            // Fallback to most portable option
103            InlineStrategy::OverlayRedraw
104        }
105    }
106}
107
108// ============================================================================
109// Inline Mode Session
110// ============================================================================
111
112/// Configuration for inline mode rendering.
113#[derive(Debug, Clone, Copy)]
114pub struct InlineConfig {
115    /// Height of the UI region (bottom N rows).
116    pub ui_height: u16,
117
118    /// Total terminal height.
119    pub term_height: u16,
120
121    /// Total terminal width.
122    pub term_width: u16,
123
124    /// Rendering strategy to use.
125    pub strategy: InlineStrategy,
126
127    /// Use synchronized output (DEC 2026) if available.
128    pub use_sync_output: bool,
129}
130
131impl InlineConfig {
132    /// Create config for a UI region of given height.
133    #[must_use]
134    pub fn new(ui_height: u16, term_height: u16, term_width: u16) -> Self {
135        Self {
136            ui_height,
137            term_height,
138            term_width,
139            strategy: InlineStrategy::default(),
140            use_sync_output: false,
141        }
142    }
143
144    /// Set the rendering strategy.
145    #[must_use]
146    pub const fn with_strategy(mut self, strategy: InlineStrategy) -> Self {
147        self.strategy = strategy;
148        self
149    }
150
151    /// Enable synchronized output.
152    #[must_use]
153    pub const fn with_sync_output(mut self, enabled: bool) -> Self {
154        self.use_sync_output = enabled;
155        self
156    }
157
158    /// Row where the UI region starts (1-indexed for ANSI).
159    ///
160    /// Returns at least 1 (valid ANSI row).
161    #[must_use]
162    pub const fn ui_top_row(&self) -> u16 {
163        let row = self
164            .term_height
165            .saturating_sub(self.ui_height)
166            .saturating_add(1);
167        // Ensure we return at least row 1 (valid ANSI row)
168        if row == 0 { 1 } else { row }
169    }
170
171    /// Row where the log region ends (1-indexed for ANSI).
172    ///
173    /// Returns 0 if there's no room for logs (UI takes full height).
174    /// Callers should check for 0 before using this value.
175    #[must_use]
176    pub const fn log_bottom_row(&self) -> u16 {
177        self.ui_top_row().saturating_sub(1)
178    }
179
180    /// Check if the configuration is valid for inline mode.
181    ///
182    /// Returns `true` if there's room for both logs and UI.
183    #[must_use]
184    pub const fn is_valid(&self) -> bool {
185        self.ui_height > 0 && self.ui_height < self.term_height && self.term_height > 1
186    }
187}
188
189// ============================================================================
190// Inline Mode Renderer
191// ============================================================================
192
193/// Inline mode renderer implementing the one-writer rule.
194///
195/// This struct owns terminal output and enforces that all writes go through it.
196/// Cleanup is guaranteed via `Drop`.
197pub struct InlineRenderer<W: Write> {
198    writer: W,
199    config: InlineConfig,
200    scroll_region_set: bool,
201    in_sync_block: bool,
202    cursor_saved: bool,
203}
204
205impl<W: Write> InlineRenderer<W> {
206    /// Create a new inline renderer.
207    ///
208    /// # Arguments
209    /// * `writer` - The terminal output (takes ownership to enforce one-writer rule).
210    /// * `config` - Inline mode configuration.
211    pub fn new(writer: W, config: InlineConfig) -> Self {
212        Self {
213            writer,
214            config,
215            scroll_region_set: false,
216            in_sync_block: false,
217            cursor_saved: false,
218        }
219    }
220
221    /// Initialize inline mode on the terminal.
222    ///
223    /// For scroll-region strategy, this sets up DECSTBM.
224    /// For overlay/hybrid strategy, this just prepares state.
225    pub fn enter(&mut self) -> io::Result<()> {
226        match self.config.strategy {
227            InlineStrategy::ScrollRegion => {
228                // Set scroll region to log area (top of screen to just above UI)
229                let log_bottom = self.config.log_bottom_row();
230                if log_bottom > 0 {
231                    self.writer.write_all(&set_scroll_region(1, log_bottom))?;
232                    self.scroll_region_set = true;
233                }
234            }
235            InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
236                // No setup needed for overlay-based modes.
237                // Hybrid uses overlay as baseline; scroll-region would be an
238                // internal optimization applied per-operation, not upfront.
239            }
240        }
241        self.writer.flush()
242    }
243
244    /// Exit inline mode, restoring terminal state.
245    pub fn exit(&mut self) -> io::Result<()> {
246        self.cleanup_internal()
247    }
248
249    /// Write log output (goes to scrollback region).
250    ///
251    /// In scroll-region mode: writes to current cursor position in scroll region.
252    /// In overlay mode: saves cursor, writes, then restores cursor.
253    ///
254    /// Returns `Ok(())` even if there's no log region (logs are silently dropped
255    /// when UI takes the full terminal height).
256    pub fn write_log(&mut self, text: &str) -> io::Result<()> {
257        let log_row = self.config.log_bottom_row();
258
259        // If there's no room for logs, silently drop
260        if log_row == 0 {
261            return Ok(());
262        }
263
264        match self.config.strategy {
265            InlineStrategy::ScrollRegion => {
266                // Cursor should be in scroll region; just write
267                self.writer.write_all(text.as_bytes())?;
268            }
269            InlineStrategy::OverlayRedraw | InlineStrategy::Hybrid => {
270                // Save cursor, move to log area, write, restore
271                self.writer.write_all(CURSOR_SAVE)?;
272                self.cursor_saved = true;
273
274                // Move to bottom of log region
275                self.writer.write_all(&cursor_position(log_row, 1))?;
276
277                // Write the log line
278                self.writer.write_all(text.as_bytes())?;
279
280                // Restore cursor
281                self.writer.write_all(CURSOR_RESTORE)?;
282                self.cursor_saved = false;
283            }
284        }
285        self.writer.flush()
286    }
287
288    /// Present a UI frame.
289    ///
290    /// # Invariants
291    /// - Cursor position is saved before and restored after.
292    /// - UI region is redrawn without affecting scrollback.
293    /// - Synchronized output wraps the operation if enabled.
294    pub fn present_ui<F>(&mut self, render_fn: F) -> io::Result<()>
295    where
296        F: FnOnce(&mut W, &InlineConfig) -> io::Result<()>,
297    {
298        // Begin sync output to prevent flicker
299        if self.config.use_sync_output && !self.in_sync_block {
300            self.writer.write_all(SYNC_BEGIN)?;
301            self.in_sync_block = true;
302        }
303
304        // Save cursor position
305        self.writer.write_all(CURSOR_SAVE)?;
306        self.cursor_saved = true;
307
308        // Move to UI region
309        let ui_row = self.config.ui_top_row();
310        self.writer.write_all(&cursor_position(ui_row, 1))?;
311
312        // Clear and render each UI line
313        for row in 0..self.config.ui_height {
314            self.writer
315                .write_all(&cursor_position(ui_row.saturating_add(row), 1))?;
316            self.writer.write_all(ERASE_LINE)?;
317        }
318
319        // Move back to start of UI and let caller render
320        self.writer.write_all(&cursor_position(ui_row, 1))?;
321        render_fn(&mut self.writer, &self.config)?;
322
323        // Restore cursor position
324        self.writer.write_all(CURSOR_RESTORE)?;
325        self.cursor_saved = false;
326
327        // End sync output
328        if self.in_sync_block {
329            self.writer.write_all(SYNC_END)?;
330            self.in_sync_block = false;
331        }
332
333        self.writer.flush()
334    }
335
336    /// Internal cleanup - guaranteed to run on drop.
337    fn cleanup_internal(&mut self) -> io::Result<()> {
338        // End any pending sync block
339        if self.in_sync_block {
340            let _ = self.writer.write_all(SYNC_END);
341            self.in_sync_block = false;
342        }
343
344        // Reset scroll region if we set one
345        if self.scroll_region_set {
346            let _ = self.writer.write_all(RESET_SCROLL_REGION);
347            self.scroll_region_set = false;
348        }
349
350        // Restore cursor only if we saved it (avoid restoring to stale position)
351        if self.cursor_saved {
352            let _ = self.writer.write_all(CURSOR_RESTORE);
353            self.cursor_saved = false;
354        }
355
356        self.writer.flush()
357    }
358}
359
360impl<W: Write> Drop for InlineRenderer<W> {
361    fn drop(&mut self) {
362        // Best-effort cleanup on drop (including panic)
363        let _ = self.cleanup_internal();
364    }
365}
366
367// ============================================================================
368// Tests
369// ============================================================================
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use std::io::Cursor;
375
376    type TestWriter = Cursor<Vec<u8>>;
377
378    fn test_writer() -> TestWriter {
379        Cursor::new(Vec::new())
380    }
381
382    fn writer_contains_sequence(writer: &TestWriter, seq: &[u8]) -> bool {
383        writer
384            .get_ref()
385            .windows(seq.len())
386            .any(|window| window == seq)
387    }
388
389    fn writer_clear(writer: &mut TestWriter) {
390        writer.get_mut().clear();
391    }
392
393    #[test]
394    fn config_calculates_regions_correctly() {
395        // 24 row terminal, 6 row UI
396        let config = InlineConfig::new(6, 24, 80);
397        assert_eq!(config.ui_top_row(), 19); // rows 19-24 are UI
398        assert_eq!(config.log_bottom_row(), 18); // rows 1-18 are logs
399    }
400
401    #[test]
402    fn strategy_selection_prefers_overlay_in_mux() {
403        let mut caps = TerminalCapabilities::basic();
404        caps.in_tmux = true;
405        caps.scroll_region = true;
406        caps.sync_output = true;
407
408        assert_eq!(InlineStrategy::select(&caps), InlineStrategy::OverlayRedraw);
409    }
410
411    #[test]
412    fn strategy_selection_uses_scroll_region_in_modern_terminal() {
413        let mut caps = TerminalCapabilities::basic();
414        caps.scroll_region = true;
415        caps.sync_output = true;
416
417        assert_eq!(InlineStrategy::select(&caps), InlineStrategy::ScrollRegion);
418    }
419
420    #[test]
421    fn strategy_selection_uses_hybrid_without_sync() {
422        let mut caps = TerminalCapabilities::basic();
423        caps.scroll_region = true;
424        caps.sync_output = false;
425
426        assert_eq!(InlineStrategy::select(&caps), InlineStrategy::Hybrid);
427    }
428
429    #[test]
430    fn enter_sets_scroll_region_for_scroll_strategy() {
431        let writer = test_writer();
432        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
433        let mut renderer = InlineRenderer::new(writer, config);
434
435        renderer.enter().unwrap();
436
437        // Should set scroll region: ESC [ 1 ; 18 r
438        assert!(writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
439    }
440
441    #[test]
442    fn exit_resets_scroll_region() {
443        let writer = test_writer();
444        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
445        let mut renderer = InlineRenderer::new(writer, config);
446
447        renderer.enter().unwrap();
448        renderer.exit().unwrap();
449
450        // Should reset scroll region: ESC [ r
451        assert!(writer_contains_sequence(
452            &renderer.writer,
453            RESET_SCROLL_REGION
454        ));
455    }
456
457    #[test]
458    fn present_ui_saves_and_restores_cursor() {
459        let writer = test_writer();
460        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
461        let mut renderer = InlineRenderer::new(writer, config);
462
463        renderer
464            .present_ui(|w, _| {
465                w.write_all(b"UI Content")?;
466                Ok(())
467            })
468            .unwrap();
469
470        // Should save cursor (ESC 7)
471        assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
472        // Should restore cursor (ESC 8)
473        assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
474    }
475
476    #[test]
477    fn present_ui_uses_sync_output_when_enabled() {
478        let writer = test_writer();
479        let config = InlineConfig::new(6, 24, 80)
480            .with_strategy(InlineStrategy::OverlayRedraw)
481            .with_sync_output(true);
482        let mut renderer = InlineRenderer::new(writer, config);
483
484        renderer.present_ui(|_, _| Ok(())).unwrap();
485
486        // Should have sync begin and end
487        assert!(writer_contains_sequence(&renderer.writer, SYNC_BEGIN));
488        assert!(writer_contains_sequence(&renderer.writer, SYNC_END));
489    }
490
491    #[test]
492    fn drop_cleans_up_scroll_region() {
493        let writer = test_writer();
494        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
495
496        {
497            let mut renderer = InlineRenderer::new(writer, config);
498            renderer.enter().unwrap();
499            // Renderer dropped here
500        }
501
502        // Can't easily test drop output, but this verifies no panic
503    }
504
505    #[test]
506    fn write_log_preserves_cursor_in_overlay_mode() {
507        let writer = test_writer();
508        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
509        let mut renderer = InlineRenderer::new(writer, config);
510
511        renderer.write_log("test log\n").unwrap();
512
513        // Should save and restore cursor
514        assert!(writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
515        assert!(writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
516    }
517
518    #[test]
519    fn hybrid_does_not_set_scroll_region_in_enter() {
520        let writer = test_writer();
521        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::Hybrid);
522        let mut renderer = InlineRenderer::new(writer, config);
523
524        renderer.enter().unwrap();
525
526        // Hybrid should NOT set scroll region (uses overlay baseline)
527        assert!(!writer_contains_sequence(&renderer.writer, b"\x1b[1;18r"));
528        assert!(!renderer.scroll_region_set);
529    }
530
531    #[test]
532    fn config_is_valid_checks_boundaries() {
533        // Valid config
534        let valid = InlineConfig::new(6, 24, 80);
535        assert!(valid.is_valid());
536
537        // UI takes all rows (no room for logs)
538        let full_ui = InlineConfig::new(24, 24, 80);
539        assert!(!full_ui.is_valid());
540
541        // Zero UI height
542        let no_ui = InlineConfig::new(0, 24, 80);
543        assert!(!no_ui.is_valid());
544
545        // Single row terminal
546        let tiny = InlineConfig::new(1, 1, 80);
547        assert!(!tiny.is_valid());
548    }
549
550    #[test]
551    fn log_bottom_row_zero_when_no_room() {
552        // UI takes full height
553        let config = InlineConfig::new(24, 24, 80);
554        assert_eq!(config.log_bottom_row(), 0);
555    }
556
557    #[test]
558    fn write_log_silently_drops_when_no_log_region() {
559        let writer = test_writer();
560        // UI takes full height - no room for logs
561        let config = InlineConfig::new(24, 24, 80).with_strategy(InlineStrategy::OverlayRedraw);
562        let mut renderer = InlineRenderer::new(writer, config);
563
564        // Should succeed but not write anything meaningful
565        renderer.write_log("test log\n").unwrap();
566
567        // Should not have written cursor save/restore since we bailed early
568        assert!(!writer_contains_sequence(&renderer.writer, CURSOR_SAVE));
569    }
570
571    #[test]
572    fn cleanup_does_not_restore_unsaved_cursor() {
573        let writer = test_writer();
574        let config = InlineConfig::new(6, 24, 80).with_strategy(InlineStrategy::ScrollRegion);
575        let mut renderer = InlineRenderer::new(writer, config);
576
577        // Just enter and exit, never save cursor explicitly
578        renderer.enter().unwrap();
579        writer_clear(&mut renderer.writer); // Clear output to check cleanup behavior
580        renderer.exit().unwrap();
581
582        // Should NOT restore cursor since we never saved it
583        assert!(!writer_contains_sequence(&renderer.writer, CURSOR_RESTORE));
584    }
585}