Skip to main content

qframe/runtime/
terminal.rs

1//! Running an application in a real terminal.
2
3use std::io::{self, Stdout, Write};
4use std::path::PathBuf;
5use std::time::{Duration, Instant};
6
7use crossterm::clipboard::CopyToClipboard;
8use crossterm::event::{
9    self as ct, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
10    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
11};
12use crossterm::terminal::{
13    BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
14    enable_raw_mode, supports_keyboard_enhancement,
15};
16use crossterm::{cursor, execute};
17use ratatui_core::terminal::Terminal;
18use ratatui_crossterm::CrosstermBackend;
19
20use super::app::App;
21use super::engine::{Engine, TaskMode};
22use super::terminal_clipboard::TerminalClipboard;
23use crate::env::{AssetDirs, Env};
24use crate::event::{Event, KeyEvent, KeyKind, MouseButton, MouseEvent, MouseKind};
25use crate::keymap::{Key, KeyChord, Modifiers};
26use crate::storage::Settings;
27
28/// How long the loop sleeps when nothing is animating and no background work is running.
29const IDLE_WAIT: Duration = Duration::from_millis(500);
30/// How often finished background work is picked up.
31const TASK_WAIT: Duration = Duration::from_millis(20);
32
33/// Configures and runs an application in the terminal.
34pub struct Runtime<A: App> {
35    app: A,
36    dirs: AssetDirs,
37    theme: Option<String>,
38    settings: Option<Settings>,
39}
40
41impl<A: App> Runtime<A> {
42    /// A runtime for `app` with built-in files only.
43    pub fn new(app: A) -> Self {
44        Self { app, dirs: AssetDirs::default(), theme: None, settings: None }
45    }
46
47    /// Loads theme files from `dir`.
48    #[must_use]
49    pub fn theme_dir(mut self, dir: impl Into<PathBuf>) -> Self {
50        self.dirs.themes = Some(dir.into());
51        self
52    }
53
54    /// Loads icon set files from `dir`.
55    #[must_use]
56    pub fn icon_dir(mut self, dir: impl Into<PathBuf>) -> Self {
57        self.dirs.icons = Some(dir.into());
58        self
59    }
60
61    /// Loads locale files from `dir`.
62    #[must_use]
63    pub fn locale_dir(mut self, dir: impl Into<PathBuf>) -> Self {
64        self.dirs.locales = Some(dir.into());
65        self
66    }
67
68    /// Layers keymap `file` over the built-in keymap.
69    #[must_use]
70    pub fn keymap_file(mut self, file: impl Into<PathBuf>) -> Self {
71        self.dirs.keymap = Some(file.into());
72        self
73    }
74
75    /// Starts with theme `id` instead of the default.
76    #[must_use]
77    pub fn theme(mut self, id: impl Into<String>) -> Self {
78        self.theme = Some(id.into());
79        self
80    }
81
82    /// Starts with the theme, language, icon mode and reduced motion saved in `settings`, so
83    /// the first frame already looks the way the user left it. Saved values win over
84    /// [`Runtime::theme`].
85    #[must_use]
86    pub fn settings(mut self, settings: &Settings) -> Self {
87        self.settings = Some(settings.clone());
88        self
89    }
90
91    /// Takes over the terminal and runs until the application quits. The terminal is restored
92    /// on return and on panic.
93    ///
94    /// # Errors
95    ///
96    /// Returns I/O errors from loading asset directories or from the terminal.
97    pub fn run(self) -> io::Result<()> {
98        let mut env = Env::load(&self.dirs)?;
99        if let Some(theme) = &self.theme {
100            env.set_theme(theme);
101        }
102        if let Some(settings) = &self.settings {
103            env.apply_settings(settings);
104        }
105        let guard = TerminalGuard::enter()?;
106        install_panic_hook();
107        let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
108        let result = event_loop(&mut terminal, Engine::new(self.app, env, TaskMode::Threads));
109        drop(terminal);
110        drop(guard);
111        result
112    }
113}
114
115fn event_loop<A: App>(terminal: &mut Terminal<CrosstermBackend<Stdout>>, mut engine: Engine<A>) -> io::Result<()> {
116    let start = Instant::now();
117    let mut clipboard = TerminalClipboard::default();
118    loop {
119        let now = start.elapsed();
120        engine.poll_tasks();
121        engine.run_queued_work();
122        clipboard.update(&mut engine, now)?;
123        engine.tick(now);
124        let animation_due = engine.deadline().is_some_and(|deadline| deadline <= now);
125        if engine.dirty || animation_due {
126            execute!(io::stdout(), BeginSynchronizedUpdate)?;
127            terminal.draw(|frame| engine.render(frame.buffer_mut(), start.elapsed()))?;
128            execute!(io::stdout(), EndSynchronizedUpdate)?;
129            for text in engine.clipboard.drain(..) {
130                execute!(io::stdout(), CopyToClipboard::to_clipboard_from(text))?;
131            }
132        }
133        if engine.quit {
134            return Ok(());
135        }
136        let now = start.elapsed();
137        let mut wait = engine.deadline().map_or(IDLE_WAIT, |deadline| deadline.saturating_sub(now));
138        if engine.pending_tasks > 0 || engine.clipboard_reader.is_reading() {
139            wait = wait.min(TASK_WAIT);
140        }
141        if let Some(deadline) = clipboard.deadline() {
142            wait = wait.min(deadline.saturating_sub(now));
143        }
144        if engine.dirty || engine.has_queued_work() {
145            wait = Duration::ZERO;
146        }
147        if ct::poll(wait)? {
148            loop {
149                let event = ct::read()?;
150                if let ct::Event::Resize(..) = event {
151                    engine.dirty = true;
152                }
153                let more = ct::poll(Duration::ZERO)?;
154                for event in clipboard.filter(event, more, &mut engine, start.elapsed()) {
155                    if let Some(event) = translate(event) {
156                        engine.handle(event, start.elapsed());
157                    }
158                }
159                if !more {
160                    break;
161                }
162            }
163        }
164    }
165}
166
167/// Puts the terminal into application mode and restores it when dropped.
168struct TerminalGuard {
169    keyboard_enhanced: bool,
170}
171
172impl TerminalGuard {
173    fn enter() -> io::Result<Self> {
174        enable_raw_mode()?;
175        let mut out = io::stdout();
176        execute!(out, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, cursor::Hide)?;
177        let keyboard_enhanced = supports_keyboard_enhancement().unwrap_or(false);
178        if keyboard_enhanced {
179            execute!(
180                out,
181                PushKeyboardEnhancementFlags(
182                    KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
183                )
184            )?;
185        }
186        Ok(Self { keyboard_enhanced })
187    }
188}
189
190impl Drop for TerminalGuard {
191    fn drop(&mut self) {
192        restore(self.keyboard_enhanced);
193    }
194}
195
196fn restore(keyboard_enhanced: bool) {
197    let mut out = io::stdout();
198    if keyboard_enhanced {
199        let _ = execute!(out, PopKeyboardEnhancementFlags);
200    }
201    let _ = execute!(out, DisableBracketedPaste, DisableMouseCapture, LeaveAlternateScreen, cursor::Show);
202    let _ = disable_raw_mode();
203    let _ = out.flush();
204}
205
206fn install_panic_hook() {
207    on_panic_in_this_thread(|| restore(true));
208}
209
210/// Runs `on_panic` before the panic hook that was installed, for panics on the calling thread
211/// only. Hooks run for every panic, even one a background task catches and reports as its
212/// outcome; restoring the terminal then would leave the running application on the normal
213/// screen without raw mode.
214fn on_panic_in_this_thread(on_panic: impl Fn() + Send + Sync + 'static) {
215    let owner = std::thread::current().id();
216    let previous = std::panic::take_hook();
217    std::panic::set_hook(Box::new(move |info| {
218        if std::thread::current().id() == owner {
219            on_panic();
220        }
221        previous(info);
222    }));
223}
224
225/// Converts a crossterm event; events the framework does not use become `None`.
226fn translate(event: ct::Event) -> Option<Event> {
227    match event {
228        ct::Event::Key(key) => translate_key(key).map(Event::Key),
229        ct::Event::Mouse(mouse) => translate_mouse(mouse).map(Event::Mouse),
230        ct::Event::Paste(text) => Some(Event::Paste(text)),
231        ct::Event::FocusGained | ct::Event::FocusLost | ct::Event::Resize(..) => None,
232    }
233}
234
235fn modifiers(mods: ct::KeyModifiers) -> Modifiers {
236    Modifiers {
237        ctrl: mods.contains(ct::KeyModifiers::CONTROL),
238        alt: mods.contains(ct::KeyModifiers::ALT),
239        shift: mods.contains(ct::KeyModifiers::SHIFT),
240    }
241}
242
243fn translate_key(key: ct::KeyEvent) -> Option<KeyEvent> {
244    let mut mods = modifiers(key.modifiers);
245    let code = match key.code {
246        ct::KeyCode::Char(' ') => Key::Space,
247        ct::KeyCode::Char(c) if c.is_uppercase() => {
248            mods.shift = true;
249            Key::Char(c.to_lowercase().next().unwrap_or(c))
250        }
251        ct::KeyCode::Char(c) => {
252            if !c.is_alphabetic() {
253                mods.shift = false;
254            }
255            Key::Char(c)
256        }
257        ct::KeyCode::Enter => Key::Enter,
258        ct::KeyCode::Esc => Key::Esc,
259        ct::KeyCode::Tab => Key::Tab,
260        ct::KeyCode::BackTab => {
261            mods.shift = true;
262            Key::Tab
263        }
264        ct::KeyCode::Backspace => Key::Backspace,
265        ct::KeyCode::Delete => Key::Delete,
266        ct::KeyCode::Insert => Key::Insert,
267        ct::KeyCode::Home => Key::Home,
268        ct::KeyCode::End => Key::End,
269        ct::KeyCode::PageUp => Key::PageUp,
270        ct::KeyCode::PageDown => Key::PageDown,
271        ct::KeyCode::Up => Key::Up,
272        ct::KeyCode::Down => Key::Down,
273        ct::KeyCode::Left => Key::Left,
274        ct::KeyCode::Right => Key::Right,
275        ct::KeyCode::F(n) => Key::F(n),
276        ct::KeyCode::Menu => Key::Menu,
277        _ => return None,
278    };
279    let kind = match key.kind {
280        ct::KeyEventKind::Press => KeyKind::Press,
281        ct::KeyEventKind::Repeat => KeyKind::Repeat,
282        ct::KeyEventKind::Release => KeyKind::Release,
283    };
284    let text = match key.code {
285        ct::KeyCode::Char(c) if !mods.ctrl && !mods.alt => Some(c),
286        _ => None,
287    };
288    Some(KeyEvent { chord: KeyChord { key: code, mods }, kind, text })
289}
290
291fn translate_mouse(mouse: ct::MouseEvent) -> Option<MouseEvent> {
292    let button = |b: ct::MouseButton| match b {
293        ct::MouseButton::Left => MouseButton::Left,
294        ct::MouseButton::Right => MouseButton::Right,
295        ct::MouseButton::Middle => MouseButton::Middle,
296    };
297    let kind = match mouse.kind {
298        ct::MouseEventKind::Down(b) => MouseKind::Down(button(b)),
299        ct::MouseEventKind::Up(b) => MouseKind::Up(button(b)),
300        ct::MouseEventKind::Drag(b) => MouseKind::Drag(button(b)),
301        ct::MouseEventKind::Moved => MouseKind::Moved,
302        ct::MouseEventKind::ScrollUp => MouseKind::ScrollUp,
303        ct::MouseEventKind::ScrollDown => MouseKind::ScrollDown,
304        ct::MouseEventKind::ScrollLeft | ct::MouseEventKind::ScrollRight => return None,
305    };
306    Some(MouseEvent { kind, x: i32::from(mouse.column), y: i32::from(mouse.row), mods: modifiers(mouse.modifiers) })
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn panics_on_other_threads_leave_the_terminal_alone() {
315        use std::sync::Arc;
316        use std::sync::atomic::{AtomicUsize, Ordering};
317        let restores = Arc::new(AtomicUsize::new(0));
318        let counter = Arc::clone(&restores);
319        on_panic_in_this_thread(move || {
320            counter.fetch_add(1, Ordering::SeqCst);
321        });
322        // A task's panic is caught and becomes its outcome; the application keeps running.
323        let _ = std::thread::spawn(|| panic!("a background task failed")).join();
324        assert_eq!(restores.load(Ordering::SeqCst), 0, "the terminal stays in application mode");
325        let _ = std::panic::catch_unwind(|| panic!("the runtime failed"));
326        assert_eq!(restores.load(Ordering::SeqCst), 1, "a panic of the runtime thread restores it");
327    }
328
329    #[test]
330    fn translates_uppercase_and_backtab() {
331        let key = |code, mods| ct::KeyEvent::new(code, mods);
332        let a = translate_key(key(ct::KeyCode::Char('A'), ct::KeyModifiers::SHIFT)).expect("key");
333        assert_eq!(a.chord, "shift+a".parse().expect("chord"));
334        assert_eq!(a.text, Some('A'));
335        let question = translate_key(key(ct::KeyCode::Char('?'), ct::KeyModifiers::SHIFT)).expect("key");
336        assert_eq!(question.chord, "?".parse().expect("chord"));
337        let back = translate_key(key(ct::KeyCode::BackTab, ct::KeyModifiers::SHIFT)).expect("key");
338        assert_eq!(back.chord, "shift+tab".parse().expect("chord"));
339        let ctrl = translate_key(key(ct::KeyCode::Char('q'), ct::KeyModifiers::CONTROL)).expect("key");
340        assert_eq!(ctrl.chord, "ctrl+q".parse().expect("chord"));
341        assert_eq!(ctrl.text, None);
342        let menu = translate_key(key(ct::KeyCode::Menu, ct::KeyModifiers::NONE)).expect("key");
343        assert_eq!(menu.chord, "menu".parse().expect("chord"));
344    }
345}