foundry_debugger/tui/
mod.rs

1//! The TUI implementation.
2
3use crossterm::{
4    event::{self, DisableMouseCapture, EnableMouseCapture, Event},
5    execute,
6    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use eyre::Result;
9use ratatui::{
10    backend::{Backend, CrosstermBackend},
11    Terminal,
12};
13use std::{
14    io,
15    ops::ControlFlow,
16    sync::{mpsc, Arc},
17    thread,
18    time::{Duration, Instant},
19};
20
21mod context;
22use crate::debugger::DebuggerContext;
23use context::TUIContext;
24
25mod draw;
26
27type DebuggerTerminal = Terminal<CrosstermBackend<io::Stdout>>;
28
29/// Debugger exit reason.
30#[derive(Debug)]
31pub enum ExitReason {
32    /// Exit using 'q'.
33    CharExit,
34}
35
36/// The TUI debugger.
37pub struct TUI<'a> {
38    debugger_context: &'a mut DebuggerContext,
39}
40
41impl<'a> TUI<'a> {
42    /// Creates a new debugger.
43    pub fn new(debugger_context: &'a mut DebuggerContext) -> Self {
44        Self { debugger_context }
45    }
46
47    /// Starts the debugger TUI.
48    pub fn try_run(&mut self) -> Result<ExitReason> {
49        let backend = CrosstermBackend::new(io::stdout());
50        let terminal = Terminal::new(backend)?;
51        TerminalGuard::with(terminal, |terminal| self.try_run_real(terminal))
52    }
53
54    #[instrument(target = "debugger", name = "run", skip_all, ret)]
55    fn try_run_real(&mut self, terminal: &mut DebuggerTerminal) -> Result<ExitReason> {
56        // Create the context.
57        let mut cx = TUIContext::new(self.debugger_context);
58
59        cx.init();
60
61        // Create an event listener in a different thread.
62        let (tx, rx) = mpsc::channel();
63        thread::Builder::new()
64            .name("event-listener".into())
65            .spawn(move || Self::event_listener(tx))
66            .expect("failed to spawn thread");
67
68        // Start the event loop.
69        loop {
70            cx.draw(terminal)?;
71            match cx.handle_event(rx.recv()?) {
72                ControlFlow::Continue(()) => {}
73                ControlFlow::Break(reason) => return Ok(reason),
74            }
75        }
76    }
77
78    fn event_listener(tx: mpsc::Sender<Event>) {
79        // This is the recommend tick rate from `ratatui`, based on their examples
80        let tick_rate = Duration::from_millis(200);
81
82        let mut last_tick = Instant::now();
83        loop {
84            // Poll events since last tick - if last tick is greater than tick_rate, we
85            // demand immediate availability of the event. This may affect interactivity,
86            // but I'm not sure as it is hard to test.
87            if event::poll(tick_rate.saturating_sub(last_tick.elapsed())).unwrap() {
88                let event = event::read().unwrap();
89                if tx.send(event).is_err() {
90                    return;
91                }
92            }
93
94            // Force update if time has passed
95            if last_tick.elapsed() > tick_rate {
96                last_tick = Instant::now();
97            }
98        }
99    }
100}
101
102// TODO: Update once on 1.82
103#[expect(deprecated)]
104type PanicHandler = Box<dyn Fn(&std::panic::PanicInfo<'_>) + 'static + Sync + Send>;
105
106/// Handles terminal state.
107#[must_use]
108struct TerminalGuard<B: Backend + io::Write> {
109    terminal: Terminal<B>,
110    hook: Option<Arc<PanicHandler>>,
111}
112
113impl<B: Backend + io::Write> TerminalGuard<B> {
114    fn with<T>(terminal: Terminal<B>, mut f: impl FnMut(&mut Terminal<B>) -> T) -> T {
115        let mut guard = Self { terminal, hook: None };
116        guard.setup();
117        f(&mut guard.terminal)
118    }
119
120    fn setup(&mut self) {
121        let previous = Arc::new(std::panic::take_hook());
122        self.hook = Some(previous.clone());
123        // We need to restore the terminal state before displaying the panic message.
124        // TODO: Use `std::panic::update_hook` when it's stable
125        std::panic::set_hook(Box::new(move |info| {
126            Self::half_restore(&mut std::io::stdout());
127            (previous)(info)
128        }));
129
130        let _ = enable_raw_mode();
131        let _ = execute!(*self.terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture);
132        let _ = self.terminal.hide_cursor();
133        let _ = self.terminal.clear();
134    }
135
136    fn restore(&mut self) {
137        if !std::thread::panicking() {
138            // Drop the current hook to guarantee that `self.hook` is the only reference to it.
139            let _ = std::panic::take_hook();
140            // Restore the previous panic hook.
141            let prev = self.hook.take().unwrap();
142            let prev = match Arc::try_unwrap(prev) {
143                Ok(prev) => prev,
144                Err(_) => unreachable!("`self.hook` is not the only reference to the panic hook"),
145            };
146            std::panic::set_hook(prev);
147
148            // NOTE: Our panic handler calls this function, so we only have to call it here if we're
149            // not panicking.
150            Self::half_restore(self.terminal.backend_mut());
151        }
152
153        let _ = self.terminal.show_cursor();
154    }
155
156    fn half_restore(w: &mut impl io::Write) {
157        let _ = disable_raw_mode();
158        let _ = execute!(*w, LeaveAlternateScreen, DisableMouseCapture);
159    }
160}
161
162impl<B: Backend + io::Write> Drop for TerminalGuard<B> {
163    #[inline]
164    fn drop(&mut self) {
165        self.restore();
166    }
167}