1use std::cell::Cell;
4use std::io::{self, Stdout, Write};
5use std::path::PathBuf;
6use std::time::{Duration, Instant};
7
8use crossterm::clipboard::CopyToClipboard;
9use crossterm::event::{
10 self as ct, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
11 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
12};
13use crossterm::terminal::{
14 Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
15 supports_keyboard_enhancement,
16};
17use crossterm::{cursor, execute};
18use ratatui_core::terminal::Terminal;
19use ratatui_crossterm::CrosstermBackend;
20
21use super::app::App;
22use super::detached::{self, DetachedOutcome};
23use super::engine::{Engine, HandOver, TaskMode};
24use super::follow::{Member, Start};
25use super::graphics_probe::LateAnswer;
26use super::handoff::{self, HandoffOutcome, HandoffScreen};
27use super::present::{Screen, pointer_shapes_supported};
28use super::signals::Signals;
29use super::terminal_clipboard::TerminalClipboard;
30use super::termination::Termination;
31use crate::env::{AssetDirs, Env};
32use crate::event::{Event, KeyEvent, KeyKind, MouseButton, MouseEvent, MouseKind};
33use crate::keymap::{Key, KeyChord, Modifiers};
34use crate::storage::{Ecosystem, Preferences, Settings};
35
36const IDLE_WAIT: Duration = Duration::from_millis(500);
38const TASK_WAIT: Duration = Duration::from_millis(20);
40
41pub struct Runtime<A: App> {
43 app: A,
44 dirs: AssetDirs,
45 theme: Option<String>,
46 settings: Option<Settings>,
47 preferences: Option<Preferences>,
48 member: Option<Member>,
49}
50
51impl<A: App> Runtime<A> {
52 pub fn new(app: A) -> Self {
54 Self { app, dirs: AssetDirs::default(), theme: None, settings: None, preferences: None, member: None }
55 }
56
57 #[must_use]
59 pub fn theme_dir(mut self, dir: impl Into<PathBuf>) -> Self {
60 self.dirs.themes = Some(dir.into());
61 self
62 }
63
64 #[must_use]
69 pub fn theme_source(mut self, file: impl Into<String>, text: impl Into<String>) -> Self {
70 self.dirs.theme_sources.push((file.into(), text.into()));
71 self
72 }
73
74 #[must_use]
76 pub fn icon_dir(mut self, dir: impl Into<PathBuf>) -> Self {
77 self.dirs.icons = Some(dir.into());
78 self
79 }
80
81 #[must_use]
92 pub fn icon_source(mut self, file: impl Into<String>, text: impl Into<String>) -> Self {
93 self.dirs.icon_sources.push((file.into(), text.into()));
94 self
95 }
96
97 #[must_use]
99 pub fn locale_dir(mut self, dir: impl Into<PathBuf>) -> Self {
100 self.dirs.locales = Some(dir.into());
101 self
102 }
103
104 #[must_use]
122 pub fn locale_source(mut self, file: impl Into<String>, text: impl Into<String>) -> Self {
123 self.dirs.locale_sources.push((file.into(), text.into()));
124 self
125 }
126
127 #[must_use]
129 pub fn keymap_file(mut self, file: impl Into<PathBuf>) -> Self {
130 self.dirs.keymap = Some(file.into());
131 self
132 }
133
134 #[must_use]
152 pub fn keymap_source(mut self, file: impl Into<String>, text: impl Into<String>) -> Self {
153 self.dirs.keymap_source = Some((file.into(), text.into()));
154 self
155 }
156
157 #[must_use]
159 pub fn theme(mut self, id: impl Into<String>) -> Self {
160 self.theme = Some(id.into());
161 self
162 }
163
164 #[must_use]
168 pub fn settings(mut self, settings: &Settings) -> Self {
169 self.settings = Some(settings.clone());
170 self
171 }
172
173 #[must_use]
196 pub fn preferences(mut self, preferences: &Preferences) -> Self {
197 self.preferences = Some(preferences.clone());
198 self
199 }
200
201 #[must_use]
237 pub fn member(mut self, ecosystem: Ecosystem, app: &str) -> Self {
238 self.member = Some(Member::new(ecosystem, app, None));
239 self
240 }
241
242 #[must_use]
245 pub fn member_in(mut self, ecosystem: Ecosystem, config_dir: impl Into<PathBuf>, app: &str) -> Self {
246 self.member = Some(Member::new(ecosystem, app, Some(config_dir.into())));
247 self
248 }
249
250 pub fn run(self) -> io::Result<()> {
279 let mut env = Env::load(&self.dirs)?;
280 let start = Start { theme: self.theme.as_deref(), settings: self.settings, preferences: self.preferences };
281 let follow = start.apply(&mut env, self.member, true);
282 let signals = Signals::catch()?;
285 let mut guard = TerminalGuard::enter()?;
286 let late = ask_graphics(&mut env, &signals);
289 guard.enhance_keyboard()?;
290 install_panic_hook();
291 let shapes = pointer_shapes_supported(|name| std::env::var(name).ok());
292 let mut screen = Screen::new(Terminal::new(CrosstermBackend::new(io::stdout()))?).pointer_shapes(shapes);
293 env.set_cell_pixels(cell_pixels());
294 let mut engine = Engine::new(self.app, env, TaskMode::Threads);
295 if let Some(follow) = follow {
296 engine.follow(follow);
297 }
298 let result = event_loop(&mut screen, engine, &guard, &signals, late);
299 if !guard.abandoned.get() {
300 let _ = screen.reset_pointer_shape();
303 #[cfg(feature = "image")]
305 let _ = screen.release_pictures();
306 }
307 let terminal = screen.into_terminal();
308 if guard.abandoned.get() {
309 std::mem::forget(terminal);
311 } else {
312 drop(terminal);
313 }
314 drop(guard);
315 drop(signals);
316 result
317 }
318}
319
320#[cfg(unix)]
324fn ask_graphics(env: &mut Env, signals: &Signals) -> LateAnswer {
325 use super::graphics_probe::{PROBE_WAIT, late_from, probe};
326 use rustix::termios::isatty;
327 if !env.graphics_worth_asking() || !isatty(signals.tty()) || !isatty(io::stdout()) {
328 return LateAnswer::default();
329 }
330 match probe(signals.tty(), &mut io::stdout(), PROBE_WAIT) {
331 Ok(probe) => {
332 env.set_terminal_graphics(probe.graphics);
333 if probe.answered { LateAnswer::default() } else { late_from(Instant::now()) }
334 }
335 Err(_) => late_from(Instant::now()),
337 }
338}
339
340#[cfg(not(unix))]
343fn ask_graphics(_env: &mut Env, _signals: &Signals) -> LateAnswer {
344 LateAnswer::default()
345}
346
347fn event_loop<A: App>(
348 terminal: &mut Screen<Stdout>,
349 mut engine: Engine<A>,
350 guard: &TerminalGuard,
351 signals: &Signals,
352 mut late: LateAnswer,
353) -> io::Result<()> {
354 let start = Instant::now();
355 let mut clipboard = TerminalClipboard::default();
356 let mut gone = false;
359 loop {
360 let now = start.elapsed();
361 let heard = signals.take();
362 if heard.resized {
363 engine.dirty = true;
366 }
367 for cause in heard.causes {
368 if cause == Termination::Hangup && !gone && signals.terminal_gone() {
369 gone = true;
370 guard.abandon();
371 }
372 engine.terminate(cause, now);
373 }
374 engine.poll_tasks();
375 engine.run_queued_work();
376 engine.follow_preferences();
377 if gone {
378 refuse_handoffs(&mut engine);
379 } else {
380 run_handoffs(terminal, &mut engine, guard, signals);
381 if let Err(error) = draw(terminal, &mut engine, &mut clipboard, start) {
383 hang_up_or(error, signals, guard, &mut gone)?;
384 }
385 }
386 engine.end_when_due(start.elapsed());
387 if engine.quit {
388 return Ok(());
389 }
390 let now = start.elapsed();
391 let mut wait = match (gone, engine.deadline()) {
392 (true, _) | (false, None) => IDLE_WAIT,
394 (false, Some(deadline)) => deadline.saturating_sub(now),
395 };
396 if let Some(deadline) = engine.ending_deadline() {
397 wait = wait.min(deadline.saturating_sub(now));
398 }
399 if engine.pending_tasks > 0 || (!gone && engine.clipboard_reader.is_reading()) {
400 wait = wait.min(TASK_WAIT);
401 }
402 if let Some(deadline) = clipboard.deadline().filter(|_| !gone) {
403 wait = wait.min(deadline.saturating_sub(now));
404 }
405 let held = if gone { None } else { engine.frame_deadline(now) };
408 if let Some(at) = held {
409 wait = wait.min(at.saturating_sub(now));
410 }
411 if (engine.dirty && held.is_none() && !gone) || engine.has_queued_work() {
412 wait = Duration::ZERO;
413 }
414 if gone {
415 signals.wait(wait, false)?;
416 continue;
417 }
418 let mut input = Input { clipboard: &mut clipboard, late: &mut late };
419 match read_input(&mut engine, &mut input, signals, start, wait) {
420 Ok(true) => hang_up(signals, guard, &mut gone),
421 Ok(false) => {}
422 Err(error) => hang_up_or(error, signals, guard, &mut gone)?,
423 }
424 }
425}
426
427fn draw<A: App>(
431 terminal: &mut Screen<Stdout>,
432 engine: &mut Engine<A>,
433 clipboard: &mut TerminalClipboard,
434 start: Instant,
435) -> io::Result<()> {
436 let now = start.elapsed();
437 clipboard.update(engine, now)?;
438 engine.tick(now);
439 if engine.frame_due(now) {
440 terminal.present(|buffer| {
441 engine.render(buffer, start.elapsed());
442 engine.painted()
443 })?;
444 for text in engine.clipboard.drain(..) {
445 execute!(io::stdout(), CopyToClipboard::to_clipboard_from(text))?;
446 }
447 }
448 Ok(())
449}
450
451struct Input<'a> {
453 clipboard: &'a mut TerminalClipboard,
454 late: &'a mut LateAnswer,
455}
456
457fn read_input<A: App>(
460 engine: &mut Engine<A>,
461 input: &mut Input<'_>,
462 signals: &Signals,
463 start: Instant,
464 wait: Duration,
465) -> io::Result<bool> {
466 let Some(mut ready) = event_waiting(signals)? else {
469 return Ok(true);
470 };
471 if !ready && !wait.is_zero() {
472 let woken = signals.wait(wait, true)?;
473 if woken.hung_up {
474 return Ok(true);
475 }
476 if woken.keyboard {
477 let Some(waiting) = event_waiting(signals)? else {
478 return Ok(true);
479 };
480 ready = waiting;
481 }
482 }
483 while ready {
484 let event = ct::read()?;
485 if let ct::Event::Resize(..) = event {
486 engine.dirty = true;
487 took_cell(engine, cell_pixels());
489 }
490 let more = event_waiting(signals)?;
491 ready = more == Some(true);
492 for event in input.late.filter(event, ready, Instant::now()) {
493 for event in input.clipboard.filter(event, ready, engine, start.elapsed()) {
494 if let Some(event) = translate(event) {
495 engine.handle(event, start.elapsed());
496 }
497 }
498 }
499 if input.late.take_kitty() {
500 heard_kitty_late(engine);
501 }
502 if more.is_none() {
503 return Ok(true);
504 }
505 }
506 Ok(false)
507}
508
509fn cell_pixels() -> Option<(u16, u16)> {
512 let size = crossterm::terminal::window_size().ok()?;
513 cell_of(size.columns, size.rows, size.width, size.height)
514}
515
516fn cell_of(columns: u16, rows: u16, width: u16, height: u16) -> Option<(u16, u16)> {
519 let cell = (width.checked_div(columns)?, height.checked_div(rows)?);
520 (cell.0 > 0 && cell.1 > 0).then_some(cell)
521}
522
523fn took_cell<A: App>(engine: &mut Engine<A>, cell: Option<(u16, u16)>) {
526 if engine.env.cell_pixels() != cell {
527 engine.env.set_cell_pixels(cell);
528 engine.dirty = true;
529 }
530}
531
532fn heard_kitty_late<A: App>(engine: &mut Engine<A>) {
536 engine.env.set_terminal_graphics(crate::graphics::Graphics::Kitty);
537 engine.dirty = true;
538}
539
540fn event_waiting(signals: &Signals) -> io::Result<Option<bool>> {
544 if signals.hung_up_now() {
545 return Ok(None);
546 }
547 ct::poll(Duration::ZERO).map(Some)
548}
549
550fn hang_up_or(error: io::Error, signals: &Signals, guard: &TerminalGuard, gone: &mut bool) -> io::Result<()> {
553 if !signals.terminal_gone() {
554 return Err(error);
555 }
556 hang_up(signals, guard, gone);
557 Ok(())
558}
559
560fn hang_up(signals: &Signals, guard: &TerminalGuard, gone: &mut bool) {
563 *gone = true;
564 guard.abandon();
565 signals.hung_up();
566}
567
568fn refuse_handoffs<A: App>(engine: &mut Engine<A>) {
571 const GONE: &str = "the terminal is gone";
572 while let Some(work) = engine.take_handoff() {
573 let message = match work {
574 HandOver::Wait(handoff) => handoff.finish(HandoffOutcome::Failed(GONE.to_owned())),
575 HandOver::Detach(handoff) => handoff.finish(DetachedOutcome::Failed(GONE.to_owned()), engine.deliveries()),
576 };
577 engine.update(message);
578 }
579}
580
581fn run_handoffs<A: App>(
586 terminal: &mut Screen<Stdout>,
587 engine: &mut Engine<A>,
588 guard: &TerminalGuard,
589 signals: &Signals,
590) {
591 while let Some(work) = engine.take_handoff() {
592 let _ = terminal.reset_pointer_shape();
595 #[cfg(feature = "image")]
597 let _ = terminal.release_pictures();
598 let prompt = engine.env.i18n().translate("quvyta.handoff.pause", &[]);
599 let deliveries = engine.deliveries();
600 let message = {
601 let mut release = |notice: Option<&str>| -> io::Result<()> {
602 guard.suspend()?;
603 let mut out = io::stdout();
604 execute!(out, Clear(ClearType::All), cursor::MoveTo(0, 0))?;
605 if let Some(text) = notice {
606 writeln!(out, "{text}")?;
607 }
608 out.flush()
609 };
610 let mut take = || -> io::Result<()> {
611 let resumed = guard.resume();
614 let area = terminal.size()?;
620 terminal.redraw_all(area)?;
621 resumed
622 };
623 let mut wait_for_key = || wait_for_key_press(&prompt, signals);
624 let mut screen = HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key };
625 signals.handoff(true);
627 let message = match work {
628 HandOver::Wait(handoff) => handoff::run(handoff, &mut screen),
629 HandOver::Detach(handoff) => detached::run(handoff, &mut screen, &deliveries),
630 };
631 signals.handoff(false);
632 message
633 };
634 engine.dirty = true;
635 engine.update(message);
636 }
637}
638
639fn wait_for_key_press(prompt: &str, signals: &Signals) -> io::Result<()> {
641 let mut out = io::stdout();
642 write!(out, "\n{prompt}")?;
643 out.flush()?;
644 enable_raw_mode()?;
646 let pressed = wait_for_key(signals);
647 disable_raw_mode()?;
648 writeln!(out)?;
649 pressed
650}
651
652fn wait_for_key(signals: &Signals) -> io::Result<()> {
655 loop {
656 if signals.pending() {
657 return Ok(());
658 }
659 match event_waiting(signals)? {
660 None => return Ok(()),
662 Some(true) => {
663 if let ct::Event::Key(key) = ct::read()?
664 && key.kind == ct::KeyEventKind::Press
665 {
666 return Ok(());
667 }
668 }
669 Some(false) => {
670 if signals.wait(IDLE_WAIT, true)?.hung_up {
671 return Ok(());
672 }
673 }
674 }
675 }
676}
677
678struct TerminalGuard {
682 keyboard_enhanced: bool,
683 abandoned: Cell<bool>,
686}
687
688impl TerminalGuard {
689 fn enter() -> io::Result<Self> {
690 enable_raw_mode()?;
691 let guard = Self { keyboard_enhanced: false, abandoned: Cell::new(false) };
694 execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, cursor::Hide)?;
695 Ok(guard)
696 }
697
698 fn enhance_keyboard(&mut self) -> io::Result<()> {
703 self.keyboard_enhanced = supports_keyboard_enhancement().unwrap_or(false);
704 self.push_keyboard_flags()
705 }
706
707 fn suspend(&self) -> io::Result<()> {
709 release(self.keyboard_enhanced)
710 }
711
712 fn resume(&self) -> io::Result<()> {
715 take_back(&mut io::stdout(), self.keyboard_enhanced, enable_raw_mode)
716 }
717
718 fn abandon(&self) {
720 self.abandoned.set(true);
721 }
722
723 fn push_keyboard_flags(&self) -> io::Result<()> {
724 push_keyboard_flags(&mut io::stdout(), self.keyboard_enhanced)
725 }
726}
727
728fn take_back(out: &mut impl Write, keyboard_enhanced: bool, raw_on: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
733 let raw = raw_on();
734 let screen = execute!(out, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, cursor::Hide);
735 let flags = push_keyboard_flags(out, keyboard_enhanced);
736 raw.and(screen).and(flags)
737}
738
739fn push_keyboard_flags(out: &mut impl Write, keyboard_enhanced: bool) -> io::Result<()> {
740 if keyboard_enhanced {
741 execute!(
742 out,
743 PushKeyboardEnhancementFlags(
744 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
745 )
746 )?;
747 }
748 Ok(())
749}
750
751impl Drop for TerminalGuard {
752 fn drop(&mut self) {
753 if !self.abandoned.get() {
754 restore(self.keyboard_enhanced);
755 }
756 }
757}
758
759fn release(keyboard_enhanced: bool) -> io::Result<()> {
761 give_back(&mut io::stdout(), keyboard_enhanced, disable_raw_mode)
762}
763
764pub(super) fn give_back(
769 out: &mut impl Write,
770 keyboard_enhanced: bool,
771 raw_off: impl FnOnce() -> io::Result<()>,
772) -> io::Result<()> {
773 let flags = if keyboard_enhanced { execute!(out, PopKeyboardEnhancementFlags) } else { Ok(()) };
774 let screen = execute!(out, DisableBracketedPaste, DisableMouseCapture, LeaveAlternateScreen, cursor::Show);
775 let raw = raw_off();
776 let flushed = out.flush();
777 flags.and(screen).and(raw).and(flushed)
778}
779
780fn restore(keyboard_enhanced: bool) {
782 let _ = release(keyboard_enhanced);
783}
784
785fn install_panic_hook() {
786 on_panic_in_this_thread(|| restore(true));
787}
788
789fn on_panic_in_this_thread(on_panic: impl Fn() + Send + Sync + 'static) {
794 let owner = std::thread::current().id();
795 let previous = std::panic::take_hook();
796 std::panic::set_hook(Box::new(move |info| {
797 if std::thread::current().id() == owner {
798 on_panic();
799 }
800 previous(info);
801 }));
802}
803
804fn translate(event: ct::Event) -> Option<Event> {
806 match event {
807 ct::Event::Key(key) => translate_key(key).map(Event::Key),
808 ct::Event::Mouse(mouse) => translate_mouse(mouse).map(Event::Mouse),
809 ct::Event::Paste(text) => Some(Event::Paste(text)),
810 ct::Event::FocusGained | ct::Event::FocusLost | ct::Event::Resize(..) => None,
811 }
812}
813
814fn modifiers(mods: ct::KeyModifiers) -> Modifiers {
815 Modifiers {
816 ctrl: mods.contains(ct::KeyModifiers::CONTROL),
817 alt: mods.contains(ct::KeyModifiers::ALT),
818 shift: mods.contains(ct::KeyModifiers::SHIFT),
819 }
820}
821
822fn translate_key(key: ct::KeyEvent) -> Option<KeyEvent> {
823 let mut mods = modifiers(key.modifiers);
824 let code = match key.code {
825 ct::KeyCode::Char(' ') => Key::Space,
826 ct::KeyCode::Char(c) if c.is_uppercase() => {
827 mods.shift = true;
828 Key::Char(c.to_lowercase().next().unwrap_or(c))
829 }
830 ct::KeyCode::Char(c) => {
831 if !c.is_alphabetic() {
832 mods.shift = false;
833 }
834 Key::Char(c)
835 }
836 ct::KeyCode::Enter => Key::Enter,
837 ct::KeyCode::Esc => Key::Esc,
838 ct::KeyCode::Tab => Key::Tab,
839 ct::KeyCode::BackTab => {
840 mods.shift = true;
841 Key::Tab
842 }
843 ct::KeyCode::Backspace => Key::Backspace,
844 ct::KeyCode::Delete => Key::Delete,
845 ct::KeyCode::Insert => Key::Insert,
846 ct::KeyCode::Home => Key::Home,
847 ct::KeyCode::End => Key::End,
848 ct::KeyCode::PageUp => Key::PageUp,
849 ct::KeyCode::PageDown => Key::PageDown,
850 ct::KeyCode::Up => Key::Up,
851 ct::KeyCode::Down => Key::Down,
852 ct::KeyCode::Left => Key::Left,
853 ct::KeyCode::Right => Key::Right,
854 ct::KeyCode::F(n) => Key::F(n),
855 ct::KeyCode::Menu => Key::Menu,
856 _ => return None,
857 };
858 let kind = match key.kind {
859 ct::KeyEventKind::Press => KeyKind::Press,
860 ct::KeyEventKind::Repeat => KeyKind::Repeat,
861 ct::KeyEventKind::Release => KeyKind::Release,
862 };
863 let text = match key.code {
864 ct::KeyCode::Char(c) if !mods.ctrl && !mods.alt => Some(c),
865 _ => None,
866 };
867 Some(KeyEvent { chord: KeyChord { key: code, mods }, kind, text })
868}
869
870fn translate_mouse(mouse: ct::MouseEvent) -> Option<MouseEvent> {
871 let button = |b: ct::MouseButton| match b {
872 ct::MouseButton::Left => MouseButton::Left,
873 ct::MouseButton::Right => MouseButton::Right,
874 ct::MouseButton::Middle => MouseButton::Middle,
875 };
876 let kind = match mouse.kind {
877 ct::MouseEventKind::Down(b) => MouseKind::Down(button(b)),
878 ct::MouseEventKind::Up(b) => MouseKind::Up(button(b)),
879 ct::MouseEventKind::Drag(b) => MouseKind::Drag(button(b)),
880 ct::MouseEventKind::Moved => MouseKind::Moved,
881 ct::MouseEventKind::ScrollUp => MouseKind::ScrollUp,
882 ct::MouseEventKind::ScrollDown => MouseKind::ScrollDown,
883 ct::MouseEventKind::ScrollLeft | ct::MouseEventKind::ScrollRight => return None,
884 };
885 Some(MouseEvent { kind, x: i32::from(mouse.column), y: i32::from(mouse.row), mods: modifiers(mouse.modifiers) })
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891
892 struct Told(Vec<crate::graphics::Graphics>);
894
895 impl App for Told {
896 type Msg = crate::graphics::Graphics;
897 fn update(&mut self, graphics: Self::Msg) -> crate::runtime::Command<Self::Msg> {
898 self.0.push(graphics);
899 crate::runtime::Command::none()
900 }
901 fn view(&self, _ui: &mut crate::widget::View<'_, Self::Msg>) {}
902 fn graphics(&self, graphics: crate::graphics::Graphics) -> Option<Self::Msg> {
903 Some(graphics)
904 }
905 }
906
907 #[test]
908 fn a_late_kitty_answer_turns_pictures_to_kitty_and_the_application_hears_it() {
909 use crate::graphics::Graphics;
910 let mut engine = Engine::new(Told(Vec::new()), Env::builtin(), TaskMode::Inline);
911 let area = ratatui_core::layout::Rect::new(0, 0, 10, 4);
912 let mut buffer = ratatui_core::buffer::Buffer::empty(area);
913 engine.render(&mut buffer, Duration::ZERO);
914 assert_eq!(engine.app.0, [Graphics::HalfBlock], "no answer in time");
915 engine.dirty = false;
916 heard_kitty_late(&mut engine);
917 assert!(engine.dirty, "a frame is due");
918 engine.render(&mut buffer, Duration::from_secs(1));
919 assert_eq!(engine.app.0, [Graphics::HalfBlock, Graphics::Kitty]);
920 assert_eq!(engine.env.graphics(), Graphics::Kitty);
921 }
922
923 #[test]
924 fn a_cell_is_the_window_divided_by_its_columns_and_rows() {
925 assert_eq!(cell_of(80, 24, 800, 480), Some((10, 20)));
926 assert_eq!(cell_of(100, 30, 905, 571), Some((9, 19)), "a margin is left out");
927 assert_eq!(cell_of(80, 24, 0, 0), None, "a terminal that reports no pixels");
928 assert_eq!(cell_of(0, 0, 800, 480), None);
929 assert_eq!(cell_of(80, 24, 40, 480), None, "fewer pixels than columns");
930 }
931
932 struct Seen(std::cell::RefCell<Vec<Option<(u16, u16)>>>);
934
935 impl App for Seen {
936 type Msg = ();
937 fn update(&mut self, (): ()) -> crate::runtime::Command<()> {
938 crate::runtime::Command::none()
939 }
940 fn view(&self, ui: &mut crate::widget::View<'_, ()>) {
941 self.0.borrow_mut().push(ui.env().cell_pixels());
942 }
943 }
944
945 #[test]
946 fn a_new_cell_size_is_a_new_frame_and_the_same_one_is_not() {
947 let mut engine = Engine::new(Seen(std::cell::RefCell::default()), Env::builtin(), TaskMode::Inline);
948 took_cell(&mut engine, Some((10, 20)));
949 let area = ratatui_core::layout::Rect::new(0, 0, 10, 4);
950 let mut buffer = ratatui_core::buffer::Buffer::empty(area);
951 engine.render(&mut buffer, Duration::ZERO);
952 engine.dirty = false;
953 took_cell(&mut engine, Some((10, 20)));
954 assert!(!engine.dirty, "nothing changed");
955 took_cell(&mut engine, Some((14, 28)));
956 assert!(engine.dirty, "a font grew under the same columns and rows");
957 engine.render(&mut buffer, Duration::from_secs(1));
958 assert_eq!(*engine.app.0.borrow(), [Some((10, 20)), Some((14, 28))]);
959 }
960
961 #[test]
962 fn panics_on_other_threads_leave_the_terminal_alone() {
963 use std::sync::Arc;
964 use std::sync::atomic::{AtomicUsize, Ordering};
965 let restores = Arc::new(AtomicUsize::new(0));
966 let counter = Arc::clone(&restores);
967 on_panic_in_this_thread(move || {
968 counter.fetch_add(1, Ordering::SeqCst);
969 });
970 let _ = std::thread::spawn(|| panic!("a background task failed")).join();
972 assert_eq!(restores.load(Ordering::SeqCst), 0, "the terminal stays in application mode");
973 let _ = std::panic::catch_unwind(|| panic!("the runtime failed"));
974 assert_eq!(restores.load(Ordering::SeqCst), 1, "a panic of the runtime thread restores it");
975 }
976
977 struct Broken;
979
980 impl Write for Broken {
981 fn write(&mut self, _: &[u8]) -> io::Result<usize> {
982 Err(io::Error::other("the terminal is gone"))
983 }
984
985 fn flush(&mut self) -> io::Result<()> {
986 Err(io::Error::other("the terminal is gone"))
987 }
988 }
989
990 #[test]
991 fn raw_mode_is_left_even_when_the_screen_cannot_be_written() {
992 let mut raw_left = false;
993 let result = give_back(&mut Broken, true, || {
994 raw_left = true;
995 Ok(())
996 });
997 assert!(raw_left, "raw mode is a terminal setting, not output, and is always left");
998 assert_eq!(result.expect_err("the failure is reported").to_string(), "the terminal is gone");
999 }
1000
1001 #[test]
1002 fn leaving_application_mode_writes_every_step_after_one_fails() {
1003 let mut out = Vec::new();
1004 let result = give_back(&mut out, true, || Err(io::Error::other("no raw mode")));
1005 assert_eq!(result.expect_err("the failure is reported").to_string(), "no raw mode");
1006 let text = String::from_utf8(out).expect("escape codes");
1007 assert!(text.contains("\x1b[?1049l"), "the alternate screen was left: {text:?}");
1008 assert!(text.contains("\x1b[?25h"), "the cursor is shown again: {text:?}");
1009 }
1010
1011 #[test]
1012 fn taking_the_terminal_back_goes_on_when_raw_mode_fails() {
1013 let mut out = Vec::new();
1015 let result = take_back(&mut out, false, || Err(io::Error::other("no raw mode")));
1016 assert_eq!(result.expect_err("the failure is reported").to_string(), "no raw mode");
1017 let text = String::from_utf8(out).expect("escape codes");
1018 assert!(text.contains("\x1b[?1049h"), "the alternate screen is entered again: {text:?}");
1019 }
1020
1021 #[test]
1022 fn translates_uppercase_and_backtab() {
1023 let key = |code, mods| ct::KeyEvent::new(code, mods);
1024 let a = translate_key(key(ct::KeyCode::Char('A'), ct::KeyModifiers::SHIFT)).expect("key");
1025 assert_eq!(a.chord, "shift+a".parse().expect("chord"));
1026 assert_eq!(a.text, Some('A'));
1027 let question = translate_key(key(ct::KeyCode::Char('?'), ct::KeyModifiers::SHIFT)).expect("key");
1028 assert_eq!(question.chord, "?".parse().expect("chord"));
1029 let back = translate_key(key(ct::KeyCode::BackTab, ct::KeyModifiers::SHIFT)).expect("key");
1030 assert_eq!(back.chord, "shift+tab".parse().expect("chord"));
1031 let ctrl = translate_key(key(ct::KeyCode::Char('q'), ct::KeyModifiers::CONTROL)).expect("key");
1032 assert_eq!(ctrl.chord, "ctrl+q".parse().expect("chord"));
1033 assert_eq!(ctrl.text, None);
1034 let menu = translate_key(key(ct::KeyCode::Menu, ct::KeyModifiers::NONE)).expect("key");
1035 assert_eq!(menu.chord, "menu".parse().expect("chord"));
1036 }
1037}