use std::cell::Cell;
use std::io::{self, Stdout, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crossterm::clipboard::CopyToClipboard;
use crossterm::event::{
self as ct, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{
BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement,
};
use crossterm::{cursor, execute};
use ratatui_core::terminal::Terminal;
use ratatui_crossterm::CrosstermBackend;
use super::app::App;
use super::engine::{Engine, TaskMode};
use super::handoff::{self, HandoffOutcome, HandoffScreen};
use super::signals::Signals;
use super::terminal_clipboard::TerminalClipboard;
use super::termination::Termination;
use crate::env::{AssetDirs, Env};
use crate::event::{Event, KeyEvent, KeyKind, MouseButton, MouseEvent, MouseKind};
use crate::keymap::{Key, KeyChord, Modifiers};
use crate::storage::Settings;
const IDLE_WAIT: Duration = Duration::from_millis(500);
const TASK_WAIT: Duration = Duration::from_millis(20);
pub struct Runtime<A: App> {
app: A,
dirs: AssetDirs,
theme: Option<String>,
settings: Option<Settings>,
}
impl<A: App> Runtime<A> {
pub fn new(app: A) -> Self {
Self { app, dirs: AssetDirs::default(), theme: None, settings: None }
}
#[must_use]
pub fn theme_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.dirs.themes = Some(dir.into());
self
}
#[must_use]
pub fn theme_source(mut self, file: impl Into<String>, text: impl Into<String>) -> Self {
self.dirs.theme_sources.push((file.into(), text.into()));
self
}
#[must_use]
pub fn icon_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.dirs.icons = Some(dir.into());
self
}
#[must_use]
pub fn icon_source(mut self, file: impl Into<String>, text: impl Into<String>) -> Self {
self.dirs.icon_sources.push((file.into(), text.into()));
self
}
#[must_use]
pub fn locale_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.dirs.locales = Some(dir.into());
self
}
#[must_use]
pub fn locale_source(mut self, file: impl Into<String>, text: impl Into<String>) -> Self {
self.dirs.locale_sources.push((file.into(), text.into()));
self
}
#[must_use]
pub fn keymap_file(mut self, file: impl Into<PathBuf>) -> Self {
self.dirs.keymap = Some(file.into());
self
}
#[must_use]
pub fn keymap_source(mut self, file: impl Into<String>, text: impl Into<String>) -> Self {
self.dirs.keymap_source = Some((file.into(), text.into()));
self
}
#[must_use]
pub fn theme(mut self, id: impl Into<String>) -> Self {
self.theme = Some(id.into());
self
}
#[must_use]
pub fn settings(mut self, settings: &Settings) -> Self {
self.settings = Some(settings.clone());
self
}
pub fn run(self) -> io::Result<()> {
let mut env = Env::load(&self.dirs)?;
if let Some(theme) = &self.theme {
env.set_theme(theme);
}
if let Some(settings) = &self.settings {
env.apply_settings(settings);
}
let signals = Signals::catch()?;
let guard = TerminalGuard::enter()?;
install_panic_hook();
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
let result = event_loop(&mut terminal, Engine::new(self.app, env, TaskMode::Threads), &guard, &signals);
if guard.abandoned.get() {
std::mem::forget(terminal);
} else {
drop(terminal);
}
drop(guard);
drop(signals);
result
}
}
fn event_loop<A: App>(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
mut engine: Engine<A>,
guard: &TerminalGuard,
signals: &Signals,
) -> io::Result<()> {
let start = Instant::now();
let mut clipboard = TerminalClipboard::default();
let mut gone = false;
loop {
let now = start.elapsed();
let heard = signals.take();
if heard.resized {
engine.dirty = true;
}
for cause in heard.causes {
if cause == Termination::Hangup && !gone && signals.terminal_gone() {
gone = true;
guard.abandon();
}
engine.terminate(cause, now);
}
engine.poll_tasks();
engine.run_queued_work();
if gone {
refuse_handoffs(&mut engine);
} else {
run_handoffs(terminal, &mut engine, guard, signals);
if let Err(error) = draw(terminal, &mut engine, &mut clipboard, start) {
hang_up_or(error, signals, guard, &mut gone)?;
}
}
engine.end_when_due(start.elapsed());
if engine.quit {
return Ok(());
}
let now = start.elapsed();
let mut wait = match (gone, engine.deadline()) {
(true, _) | (false, None) => IDLE_WAIT,
(false, Some(deadline)) => deadline.saturating_sub(now),
};
if let Some(deadline) = engine.ending_deadline() {
wait = wait.min(deadline.saturating_sub(now));
}
if engine.pending_tasks > 0 || (!gone && engine.clipboard_reader.is_reading()) {
wait = wait.min(TASK_WAIT);
}
if let Some(deadline) = clipboard.deadline().filter(|_| !gone) {
wait = wait.min(deadline.saturating_sub(now));
}
if (engine.dirty && !gone) || engine.has_queued_work() {
wait = Duration::ZERO;
}
if gone {
signals.wait(wait, false)?;
continue;
}
match read_input(&mut engine, &mut clipboard, signals, start, wait) {
Ok(true) => hang_up(signals, guard, &mut gone),
Ok(false) => {}
Err(error) => hang_up_or(error, signals, guard, &mut gone)?,
}
}
}
fn draw<A: App>(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
engine: &mut Engine<A>,
clipboard: &mut TerminalClipboard,
start: Instant,
) -> io::Result<()> {
let now = start.elapsed();
clipboard.update(engine, now)?;
engine.tick(now);
let animation_due = engine.deadline().is_some_and(|deadline| deadline <= now);
if engine.dirty || animation_due {
execute!(io::stdout(), BeginSynchronizedUpdate)?;
terminal.draw(|frame| engine.render(frame.buffer_mut(), start.elapsed()))?;
execute!(io::stdout(), EndSynchronizedUpdate)?;
for text in engine.clipboard.drain(..) {
execute!(io::stdout(), CopyToClipboard::to_clipboard_from(text))?;
}
}
Ok(())
}
fn read_input<A: App>(
engine: &mut Engine<A>,
clipboard: &mut TerminalClipboard,
signals: &Signals,
start: Instant,
wait: Duration,
) -> io::Result<bool> {
let Some(mut ready) = event_waiting(signals)? else {
return Ok(true);
};
if !ready && !wait.is_zero() {
let woken = signals.wait(wait, true)?;
if woken.hung_up {
return Ok(true);
}
if woken.keyboard {
let Some(waiting) = event_waiting(signals)? else {
return Ok(true);
};
ready = waiting;
}
}
while ready {
let event = ct::read()?;
if let ct::Event::Resize(..) = event {
engine.dirty = true;
}
let more = event_waiting(signals)?;
ready = more == Some(true);
for event in clipboard.filter(event, ready, engine, start.elapsed()) {
if let Some(event) = translate(event) {
engine.handle(event, start.elapsed());
}
}
if more.is_none() {
return Ok(true);
}
}
Ok(false)
}
fn event_waiting(signals: &Signals) -> io::Result<Option<bool>> {
if signals.hung_up_now() {
return Ok(None);
}
ct::poll(Duration::ZERO).map(Some)
}
fn hang_up_or(error: io::Error, signals: &Signals, guard: &TerminalGuard, gone: &mut bool) -> io::Result<()> {
if !signals.terminal_gone() {
return Err(error);
}
hang_up(signals, guard, gone);
Ok(())
}
fn hang_up(signals: &Signals, guard: &TerminalGuard, gone: &mut bool) {
*gone = true;
guard.abandon();
signals.hung_up();
}
fn refuse_handoffs<A: App>(engine: &mut Engine<A>) {
while let Some(work) = engine.take_handoff() {
let message = work.finish(HandoffOutcome::Failed("the terminal is gone".to_owned()));
engine.update(message);
}
}
fn run_handoffs<A: App>(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
engine: &mut Engine<A>,
guard: &TerminalGuard,
signals: &Signals,
) {
while let Some(work) = engine.take_handoff() {
let prompt = engine.env.i18n().translate("quvyta.handoff.pause", &[]);
let message = {
let mut release = |notice: Option<&str>| -> io::Result<()> {
guard.suspend()?;
let mut out = io::stdout();
execute!(out, Clear(ClearType::All), cursor::MoveTo(0, 0))?;
if let Some(text) = notice {
writeln!(out, "{text}")?;
}
out.flush()
};
let mut take = || -> io::Result<()> {
let resumed = guard.resume();
let area = terminal.size()?.into();
terminal.resize(area)?;
resumed
};
let mut wait_for_key = || wait_for_key_press(&prompt, signals);
let mut screen = HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key };
signals.handoff(true);
let message = handoff::run(work, &mut screen);
signals.handoff(false);
message
};
engine.dirty = true;
engine.update(message);
}
}
fn wait_for_key_press(prompt: &str, signals: &Signals) -> io::Result<()> {
let mut out = io::stdout();
write!(out, "\n{prompt}")?;
out.flush()?;
enable_raw_mode()?;
let pressed = wait_for_key(signals);
disable_raw_mode()?;
writeln!(out)?;
pressed
}
fn wait_for_key(signals: &Signals) -> io::Result<()> {
loop {
if signals.pending() {
return Ok(());
}
match event_waiting(signals)? {
None => return Ok(()),
Some(true) => {
if let ct::Event::Key(key) = ct::read()?
&& key.kind == ct::KeyEventKind::Press
{
return Ok(());
}
}
Some(false) => {
if signals.wait(IDLE_WAIT, true)?.hung_up {
return Ok(());
}
}
}
}
}
struct TerminalGuard {
keyboard_enhanced: bool,
abandoned: Cell<bool>,
}
impl TerminalGuard {
fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut guard = Self { keyboard_enhanced: false, abandoned: Cell::new(false) };
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, cursor::Hide)?;
guard.keyboard_enhanced = supports_keyboard_enhancement().unwrap_or(false);
guard.push_keyboard_flags()?;
Ok(guard)
}
fn suspend(&self) -> io::Result<()> {
release(self.keyboard_enhanced)
}
fn resume(&self) -> io::Result<()> {
take_back(&mut io::stdout(), self.keyboard_enhanced, enable_raw_mode)
}
fn abandon(&self) {
self.abandoned.set(true);
}
fn push_keyboard_flags(&self) -> io::Result<()> {
push_keyboard_flags(&mut io::stdout(), self.keyboard_enhanced)
}
}
fn take_back(out: &mut impl Write, keyboard_enhanced: bool, raw_on: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
let raw = raw_on();
let screen = execute!(out, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, cursor::Hide);
let flags = push_keyboard_flags(out, keyboard_enhanced);
raw.and(screen).and(flags)
}
fn push_keyboard_flags(out: &mut impl Write, keyboard_enhanced: bool) -> io::Result<()> {
if keyboard_enhanced {
execute!(
out,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
)
)?;
}
Ok(())
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
if !self.abandoned.get() {
restore(self.keyboard_enhanced);
}
}
}
fn release(keyboard_enhanced: bool) -> io::Result<()> {
give_back(&mut io::stdout(), keyboard_enhanced, disable_raw_mode)
}
pub(super) fn give_back(
out: &mut impl Write,
keyboard_enhanced: bool,
raw_off: impl FnOnce() -> io::Result<()>,
) -> io::Result<()> {
let flags = if keyboard_enhanced { execute!(out, PopKeyboardEnhancementFlags) } else { Ok(()) };
let screen = execute!(out, DisableBracketedPaste, DisableMouseCapture, LeaveAlternateScreen, cursor::Show);
let raw = raw_off();
let flushed = out.flush();
flags.and(screen).and(raw).and(flushed)
}
fn restore(keyboard_enhanced: bool) {
let _ = release(keyboard_enhanced);
}
fn install_panic_hook() {
on_panic_in_this_thread(|| restore(true));
}
fn on_panic_in_this_thread(on_panic: impl Fn() + Send + Sync + 'static) {
let owner = std::thread::current().id();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().id() == owner {
on_panic();
}
previous(info);
}));
}
fn translate(event: ct::Event) -> Option<Event> {
match event {
ct::Event::Key(key) => translate_key(key).map(Event::Key),
ct::Event::Mouse(mouse) => translate_mouse(mouse).map(Event::Mouse),
ct::Event::Paste(text) => Some(Event::Paste(text)),
ct::Event::FocusGained | ct::Event::FocusLost | ct::Event::Resize(..) => None,
}
}
fn modifiers(mods: ct::KeyModifiers) -> Modifiers {
Modifiers {
ctrl: mods.contains(ct::KeyModifiers::CONTROL),
alt: mods.contains(ct::KeyModifiers::ALT),
shift: mods.contains(ct::KeyModifiers::SHIFT),
}
}
fn translate_key(key: ct::KeyEvent) -> Option<KeyEvent> {
let mut mods = modifiers(key.modifiers);
let code = match key.code {
ct::KeyCode::Char(' ') => Key::Space,
ct::KeyCode::Char(c) if c.is_uppercase() => {
mods.shift = true;
Key::Char(c.to_lowercase().next().unwrap_or(c))
}
ct::KeyCode::Char(c) => {
if !c.is_alphabetic() {
mods.shift = false;
}
Key::Char(c)
}
ct::KeyCode::Enter => Key::Enter,
ct::KeyCode::Esc => Key::Esc,
ct::KeyCode::Tab => Key::Tab,
ct::KeyCode::BackTab => {
mods.shift = true;
Key::Tab
}
ct::KeyCode::Backspace => Key::Backspace,
ct::KeyCode::Delete => Key::Delete,
ct::KeyCode::Insert => Key::Insert,
ct::KeyCode::Home => Key::Home,
ct::KeyCode::End => Key::End,
ct::KeyCode::PageUp => Key::PageUp,
ct::KeyCode::PageDown => Key::PageDown,
ct::KeyCode::Up => Key::Up,
ct::KeyCode::Down => Key::Down,
ct::KeyCode::Left => Key::Left,
ct::KeyCode::Right => Key::Right,
ct::KeyCode::F(n) => Key::F(n),
ct::KeyCode::Menu => Key::Menu,
_ => return None,
};
let kind = match key.kind {
ct::KeyEventKind::Press => KeyKind::Press,
ct::KeyEventKind::Repeat => KeyKind::Repeat,
ct::KeyEventKind::Release => KeyKind::Release,
};
let text = match key.code {
ct::KeyCode::Char(c) if !mods.ctrl && !mods.alt => Some(c),
_ => None,
};
Some(KeyEvent { chord: KeyChord { key: code, mods }, kind, text })
}
fn translate_mouse(mouse: ct::MouseEvent) -> Option<MouseEvent> {
let button = |b: ct::MouseButton| match b {
ct::MouseButton::Left => MouseButton::Left,
ct::MouseButton::Right => MouseButton::Right,
ct::MouseButton::Middle => MouseButton::Middle,
};
let kind = match mouse.kind {
ct::MouseEventKind::Down(b) => MouseKind::Down(button(b)),
ct::MouseEventKind::Up(b) => MouseKind::Up(button(b)),
ct::MouseEventKind::Drag(b) => MouseKind::Drag(button(b)),
ct::MouseEventKind::Moved => MouseKind::Moved,
ct::MouseEventKind::ScrollUp => MouseKind::ScrollUp,
ct::MouseEventKind::ScrollDown => MouseKind::ScrollDown,
ct::MouseEventKind::ScrollLeft | ct::MouseEventKind::ScrollRight => return None,
};
Some(MouseEvent { kind, x: i32::from(mouse.column), y: i32::from(mouse.row), mods: modifiers(mouse.modifiers) })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn panics_on_other_threads_leave_the_terminal_alone() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let restores = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&restores);
on_panic_in_this_thread(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
let _ = std::thread::spawn(|| panic!("a background task failed")).join();
assert_eq!(restores.load(Ordering::SeqCst), 0, "the terminal stays in application mode");
let _ = std::panic::catch_unwind(|| panic!("the runtime failed"));
assert_eq!(restores.load(Ordering::SeqCst), 1, "a panic of the runtime thread restores it");
}
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::other("the terminal is gone"))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::other("the terminal is gone"))
}
}
#[test]
fn raw_mode_is_left_even_when_the_screen_cannot_be_written() {
let mut raw_left = false;
let result = give_back(&mut Broken, true, || {
raw_left = true;
Ok(())
});
assert!(raw_left, "raw mode is a terminal setting, not output, and is always left");
assert_eq!(result.expect_err("the failure is reported").to_string(), "the terminal is gone");
}
#[test]
fn leaving_application_mode_writes_every_step_after_one_fails() {
let mut out = Vec::new();
let result = give_back(&mut out, true, || Err(io::Error::other("no raw mode")));
assert_eq!(result.expect_err("the failure is reported").to_string(), "no raw mode");
let text = String::from_utf8(out).expect("escape codes");
assert!(text.contains("\x1b[?1049l"), "the alternate screen was left: {text:?}");
assert!(text.contains("\x1b[?25h"), "the cursor is shown again: {text:?}");
}
#[test]
fn taking_the_terminal_back_goes_on_when_raw_mode_fails() {
let mut out = Vec::new();
let result = take_back(&mut out, false, || Err(io::Error::other("no raw mode")));
assert_eq!(result.expect_err("the failure is reported").to_string(), "no raw mode");
let text = String::from_utf8(out).expect("escape codes");
assert!(text.contains("\x1b[?1049h"), "the alternate screen is entered again: {text:?}");
}
#[test]
fn translates_uppercase_and_backtab() {
let key = |code, mods| ct::KeyEvent::new(code, mods);
let a = translate_key(key(ct::KeyCode::Char('A'), ct::KeyModifiers::SHIFT)).expect("key");
assert_eq!(a.chord, "shift+a".parse().expect("chord"));
assert_eq!(a.text, Some('A'));
let question = translate_key(key(ct::KeyCode::Char('?'), ct::KeyModifiers::SHIFT)).expect("key");
assert_eq!(question.chord, "?".parse().expect("chord"));
let back = translate_key(key(ct::KeyCode::BackTab, ct::KeyModifiers::SHIFT)).expect("key");
assert_eq!(back.chord, "shift+tab".parse().expect("chord"));
let ctrl = translate_key(key(ct::KeyCode::Char('q'), ct::KeyModifiers::CONTROL)).expect("key");
assert_eq!(ctrl.chord, "ctrl+q".parse().expect("chord"));
assert_eq!(ctrl.text, None);
let menu = translate_key(key(ct::KeyCode::Menu, ct::KeyModifiers::NONE)).expect("key");
assert_eq!(menu.chord, "menu".parse().expect("chord"));
}
}