Skip to main content

perspt_tui/
tui_runner.rs

1//! TUI Runner - Async event loop for responsive TUI
2//!
3//! Provides a centralized async event loop using tokio::select! that handles:
4//! - Terminal events (keyboard, mouse, resize) via crossterm EventStream
5//! - Backend events (streaming chunks, agent updates) via channels
6//! - Periodic ticks for animations
7//!
8//! Inspired by Codex CLI's architecture for maximum responsiveness.
9
10use crate::app_event::{AppEvent, AppEventReceiver, AppEventSender};
11use crossterm::event::EventStream;
12use futures::StreamExt;
13use std::io::{self, stdout, Stdout};
14use std::time::Duration;
15use tokio::time::interval;
16
17use crossterm::{
18    event::{
19        DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
20        KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
21    },
22    execute,
23    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
24};
25use ratatui::{backend::CrosstermBackend, Terminal};
26
27/// Terminal type alias
28pub type TuiTerminal = Terminal<CrosstermBackend<Stdout>>;
29
30/// Frame rate for animation ticks (60 FPS)
31const TICK_RATE_MS: u64 = 16;
32
33/// Minimum time between renders to avoid excessive CPU usage
34const MIN_RENDER_INTERVAL_MS: u64 = 16;
35
36/// TUI Runner configuration
37pub struct TuiRunnerConfig {
38    /// Enable mouse capture
39    pub mouse_capture: bool,
40    /// Enable keyboard enhancement flags
41    pub keyboard_enhancement: bool,
42    /// Use alternate screen
43    pub alternate_screen: bool,
44}
45
46impl Default for TuiRunnerConfig {
47    fn default() -> Self {
48        Self {
49            mouse_capture: true,
50            keyboard_enhancement: true,
51            alternate_screen: false, // Inline mode by default for chat
52        }
53    }
54}
55
56/// Initialize terminal with optional settings
57pub fn init_terminal(config: &TuiRunnerConfig) -> io::Result<TuiTerminal> {
58    enable_raw_mode()?;
59
60    if config.alternate_screen {
61        execute!(stdout(), EnterAlternateScreen)?;
62    }
63
64    // Enable keyboard enhancement for better modifier detection
65    if config.keyboard_enhancement {
66        let _ = execute!(
67            stdout(),
68            PushKeyboardEnhancementFlags(
69                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
70                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
71            )
72        );
73    }
74
75    execute!(stdout(), EnableBracketedPaste)?;
76
77    if config.mouse_capture {
78        execute!(stdout(), EnableMouseCapture)?;
79    }
80
81    let backend = CrosstermBackend::new(stdout());
82    Terminal::new(backend)
83}
84
85/// Restore terminal to original state
86pub fn restore_terminal(config: &TuiRunnerConfig) -> io::Result<()> {
87    if config.keyboard_enhancement {
88        let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
89    }
90
91    if config.mouse_capture {
92        let _ = execute!(stdout(), DisableMouseCapture);
93    }
94
95    execute!(stdout(), DisableBracketedPaste)?;
96
97    if config.alternate_screen {
98        execute!(stdout(), LeaveAlternateScreen)?;
99    }
100
101    disable_raw_mode()?;
102    Ok(())
103}
104
105/// Run the async event loop
106///
107/// This function drives the TUI by:
108/// 1. Listening for terminal events via EventStream
109/// 2. Listening for app events via the receiver channel
110/// 3. Sending periodic tick events for animations
111///
112/// All events are forwarded to the app_tx channel for unified handling.
113pub async fn run_event_loop(
114    app_tx: AppEventSender,
115    mut app_rx: AppEventReceiver,
116    mut on_event: impl FnMut(AppEvent) -> bool, // Returns false to quit
117) {
118    let mut event_stream = EventStream::new();
119    let mut tick_interval = interval(Duration::from_millis(TICK_RATE_MS));
120
121    loop {
122        tokio::select! {
123            // Terminal events (keyboard, mouse, resize)
124            maybe_event = event_stream.next() => {
125                match maybe_event {
126                    Some(Ok(event)) => {
127                        if !on_event(AppEvent::Terminal(event)) {
128                            break;
129                        }
130                    }
131                    Some(Err(e)) => {
132                        let _ = app_tx.send(AppEvent::Error(e.to_string()));
133                    }
134                    None => break, // Stream ended
135                }
136            }
137
138            // App events from other sources (streaming, agent updates)
139            Some(event) = app_rx.recv() => {
140                if !on_event(event) {
141                    break;
142                }
143            }
144
145            // Periodic tick for animations
146            _ = tick_interval.tick() => {
147                if !on_event(AppEvent::Tick) {
148                    break;
149                }
150            }
151        }
152    }
153}
154
155/// Frame rate limiter to prevent excessive rendering
156pub struct FrameRateLimiter {
157    last_render: std::time::Instant,
158    min_interval: Duration,
159}
160
161impl Default for FrameRateLimiter {
162    fn default() -> Self {
163        Self {
164            last_render: std::time::Instant::now(),
165            min_interval: Duration::from_millis(MIN_RENDER_INTERVAL_MS),
166        }
167    }
168}
169
170impl FrameRateLimiter {
171    /// Check if enough time has passed for a new render
172    pub fn should_render(&mut self) -> bool {
173        let now = std::time::Instant::now();
174        if now.duration_since(self.last_render) >= self.min_interval {
175            self.last_render = now;
176            true
177        } else {
178            false
179        }
180    }
181
182    /// Force a render (for important updates)
183    pub fn force_render(&mut self) {
184        self.last_render = std::time::Instant::now();
185    }
186}