1use 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
28const IDLE_WAIT: Duration = Duration::from_millis(500);
30const TASK_WAIT: Duration = Duration::from_millis(20);
32
33pub 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 pub fn new(app: A) -> Self {
44 Self { app, dirs: AssetDirs::default(), theme: None, settings: None }
45 }
46
47 #[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 #[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 #[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 #[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 #[must_use]
77 pub fn theme(mut self, id: impl Into<String>) -> Self {
78 self.theme = Some(id.into());
79 self
80 }
81
82 #[must_use]
86 pub fn settings(mut self, settings: &Settings) -> Self {
87 self.settings = Some(settings.clone());
88 self
89 }
90
91 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
167struct 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
210fn 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
225fn 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 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}