Skip to main content

dev_prune/tui/
mod.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Terminal UI components for dev-prune.
5
6use std::io::{Stdout, stdout};
7use std::panic::PanicHookInfo;
8use std::sync::Arc;
9use std::time::Duration;
10
11use anyhow::Result;
12use crossterm::ExecutableCommand;
13use crossterm::cursor::Show;
14use crossterm::event;
15use crossterm::terminal::{
16    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
17};
18use ratatui::Terminal;
19use ratatui::backend::CrosstermBackend;
20
21pub mod config_view;
22pub mod selection_view;
23pub mod status_view;
24
25/// Whether a full-screen view can be opened, and should be.
26///
27/// Both ends of the terminal test matter: every view here draws on stdout but reads
28/// keys from stdin, and with either end redirected it would open on a screen no
29/// keypress can ever leave. `DEV_PRUNE_NO_TUI` answers the question that test cannot:
30/// whether the thing holding the terminal is a person. An agent driving `devp` through
31/// a pty passes every terminal check and will never press a key, so it sets the
32/// variable and gets the line-by-line fallbacks instead.
33///
34/// Every full-screen entry point comes through here. The variable used to be honored
35/// only by the config wizard, while `status` and `run` opened their views on the
36/// terminal test alone, which is exactly the keypress trap the variable promises to
37/// prevent.
38pub fn full_screen_is_usable() -> bool {
39    use std::io::IsTerminal;
40    if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
41        return false;
42    }
43    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
44}
45
46/// Put the terminal back the way it was found.
47///
48/// Every step is best-effort and independent: if leaving the alternate screen fails there
49/// is still a raw-mode flag to clear, and a terminal left in raw mode with a hidden cursor
50/// is a terminal the user has to close and reopen.
51fn restore_terminal() {
52    let _ = disable_raw_mode();
53    let _ = stdout().execute(LeaveAlternateScreen);
54    let _ = stdout().execute(Show);
55}
56
57/// An entered full-screen terminal session that always exits cleanly.
58///
59/// The three ways out of a TUI are a normal return, an error, and a panic. Before this
60/// guard existed each view handled the first by hand and leaked the terminal on the other
61/// two — `?` between "raw mode on" and the restore call would return with the screen still
62/// swapped and echo still off, which reads to the user as a hung shell.
63pub(crate) struct Tui {
64    pub terminal: Terminal<CrosstermBackend<Stdout>>,
65    prior_hook: Arc<dyn Fn(&PanicHookInfo<'_>) + Sync + Send + 'static>,
66}
67
68impl Tui {
69    /// Enter raw mode and the alternate screen, and arm the restore paths.
70    pub fn new() -> Result<Self> {
71        let prior_hook: Arc<dyn Fn(&PanicHookInfo<'_>) + Sync + Send> =
72            Arc::from(std::panic::take_hook());
73
74        // Restore first, then let the previous hook print: a panic message rendered into
75        // the alternate screen vanishes the moment the screen is dropped.
76        let hook_for_panic = Arc::clone(&prior_hook);
77        std::panic::set_hook(Box::new(move |info| {
78            restore_terminal();
79            hook_for_panic(info);
80        }));
81
82        if let Err(e) = enable_raw_mode() {
83            // The panic hook above is already armed; put the caller's back before
84            // handing the error up, exactly as the late-failure arm below does.
85            std::panic::set_hook(Box::new(move |info| prior_hook(info)));
86            return Err(e.into());
87        }
88        if let Err(e) = stdout().execute(EnterAlternateScreen) {
89            // Raw mode is on but `Self` will never exist, so `Drop` cannot turn it
90            // off — returning through `?` here left the shell in raw mode.
91            restore_terminal();
92            std::panic::set_hook(Box::new(move |info| prior_hook(info)));
93            return Err(e.into());
94        }
95
96        match Terminal::new(CrosstermBackend::new(stdout())) {
97            Ok(terminal) => Ok(Self {
98                terminal,
99                prior_hook,
100            }),
101            Err(e) => {
102                // Constructing the backend failed *after* the screen was swapped. `Drop`
103                // never runs for a `Self` that was never built, so both the screen and
104                // the panic hook have to be put back by hand here. Note this is a
105                // `set_hook`, not a `take_hook`: taking would install std's default and
106                // silently discard whatever hook the caller had before.
107                restore_terminal();
108                std::panic::set_hook(Box::new(move |info| prior_hook(info)));
109                Err(e.into())
110            }
111        }
112    }
113
114    /// Discard input that arrived before the view was ready for it.
115    ///
116    /// The Enter keypress that launched the command is still queued when the loop starts,
117    /// and on Windows its KeyPress/KeyRelease pair arrives inside the loop and confirms
118    /// the selection instantly. The sleep gives the console time to deliver it so that the
119    /// drain below has something to drain.
120    pub fn drain_stale_input(&self, settle: Duration) {
121        std::thread::sleep(settle);
122        // Deliberately not `?`: a failure to drain must never abort the view.
123        while matches!(event::poll(Duration::from_millis(50)), Ok(true)) {
124            let _ = event::read();
125        }
126    }
127}
128
129impl Drop for Tui {
130    fn drop(&mut self) {
131        restore_terminal();
132        let _ = self.terminal.show_cursor();
133        // Hand the panic hook back to whoever owned it, rather than to std's default.
134        let prior = Arc::clone(&self.prior_hook);
135        std::panic::set_hook(Box::new(move |info| prior(info)));
136    }
137}