Skip to main content

bevy_repl/
context.rs

1use bevy::prelude::*;
2use bevy_ratatui::{
3    crossterm::{
4        ExecutableCommand, cursor,
5        terminal::{disable_raw_mode, enable_raw_mode},
6    },
7    error::ErrorPlugin,
8    event::EventPlugin,
9    kitty::{KittyEnabled, KittyPlugin},
10    translation::TranslationPlugin,
11};
12use color_eyre::{
13    self,
14    config::{EyreHook, HookBuilder, PanicHook},
15    eyre,
16};
17use ratatui::{Terminal, backend::CrosstermBackend};
18use std::io::{Stdout, stdout};
19use std::panic;
20
21/// The plugin behaves like a [`RatatuiContext`] but for [`ReplContext`]. It
22/// adds the [`ReplContext`] resource to the bevy application.
23pub struct ReplContextPlugin;
24
25impl Plugin for ReplContextPlugin {
26    fn build(&self, app: &mut bevy::prelude::App) {
27        // We can use the regular bevy_ratatui events plugin because it doesn't
28        // manage the RatatuiContext resource.
29        if !app.is_plugin_added::<EventPlugin>() {
30            app.add_plugins(EventPlugin::default());
31        }
32        // Enable kitty keyboard protocol to receive modifiers with special keys
33        // like Enter where the terminal supports it (needed for Ctrl+Enter).
34        if !app.is_plugin_added::<KittyPlugin>() {
35            app.add_plugins(KittyPlugin);
36        }
37        // We can use the regular bevy_ratatui translation plugin because it
38        // doesn't manage the RatatuiContext resource.
39        if !app.is_plugin_added::<TranslationPlugin>() {
40            app.add_plugins(TranslationPlugin);
41        }
42        // We are incompatible with bevy_ratatui's ErrorPlugin. If it is added,
43        // prefer to use theirs.
44        if !app.is_plugin_added::<ErrorPlugin>() {
45            app.add_systems(Startup, error_setup);
46        }
47        // Replicates the bevy_ratatui ContextPlugin
48        app.add_systems(Startup, context_setup);
49        // Replicates the bevy_ratatui CleanupPlugin
50        app.add_observer(context_cleanup);
51    }
52}
53
54/// Behaves like a [`RatatuiContext`] but uses the main terminal screen (no
55/// alternate screen) using `crossterm` via `ratatui`. Use this as you would use
56/// a [`RatatuiContext`].
57#[derive(Resource, Deref, DerefMut, Debug)]
58pub struct ReplContext(Terminal<CrosstermBackend<Stdout>>);
59
60impl Drop for ReplContext {
61    fn drop(&mut self) {
62        if let Err(err) = ReplContext::restore() {
63            eprintln!("Failed to restore terminal: {}", err);
64        }
65    }
66}
67
68impl ReplContext {
69    fn init() -> Result<Self> {
70        let stdout = stdout();
71        enable_raw_mode()?;
72        let backend = CrosstermBackend::new(stdout);
73        let terminal = Terminal::new(backend)?;
74        Ok(Self(terminal))
75    }
76
77    fn restore() -> Result<()> {
78        let mut stdout = stdout();
79        stdout.execute(cursor::Show)?;
80        disable_raw_mode()?;
81        Ok(())
82    }
83}
84
85/// A startup system that sets up the terminal context.
86pub fn context_setup(mut commands: Commands) -> Result {
87    let terminal = ReplContext::init()?;
88    commands.insert_resource(terminal);
89
90    Ok(())
91}
92
93/// Equivalent to what [`CleanupPlugin`] does, but for the REPL context. (The
94/// regular cleanup plugin will remove [`RatatuiContext`] resources but not
95/// [`ReplContext`] so we have to do it ourselves.)
96fn context_cleanup(_trigger: Trigger<AppExit>, mut commands: Commands) {
97    commands.remove_resource::<KittyEnabled>();
98    commands.remove_resource::<ReplContext>();
99}
100
101/// Installs hooks for panic and error handling. This is a ripoff of the
102/// bevy_ratatui [`ErrorPlugin`].
103///
104/// Makes the app resilient to panics and errors by restoring the terminal
105/// before printing the panic or error message. This prevents error messages
106/// from being messed up by the terminal state.
107pub fn error_setup() -> Result {
108    let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks();
109    set_panic_hook(panic_hook);
110    set_error_hook(eyre_hook)?;
111    Ok(())
112}
113
114/// Install a panic hook that restores the terminal before printing the panic.
115fn set_panic_hook(panic_hook: PanicHook) {
116    let panic_hook = panic_hook.into_panic_hook();
117    panic::set_hook(Box::new(move |panic_info| {
118        let _ = ReplContext::restore();
119        panic_hook(panic_info);
120    }));
121}
122
123/// Install an error hook that restores the terminal before printing the error.
124fn set_error_hook(eyre_hook: EyreHook) -> Result {
125    let eyre_hook = eyre_hook.into_eyre_hook();
126    eyre::set_hook(Box::new(move |error| {
127        let _ = ReplContext::restore();
128        eyre_hook(error)
129    }))?;
130
131    Ok(())
132}