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 selection_view;
22pub mod status_view;
23
24/// Put the terminal back the way it was found.
25///
26/// Every step is best-effort and independent: if leaving the alternate screen fails there
27/// is still a raw-mode flag to clear, and a terminal left in raw mode with a hidden cursor
28/// is a terminal the user has to close and reopen.
29fn restore_terminal() {
30 let _ = disable_raw_mode();
31 let _ = stdout().execute(LeaveAlternateScreen);
32 let _ = stdout().execute(Show);
33}
34
35/// An entered full-screen terminal session that always exits cleanly.
36///
37/// The three ways out of a TUI are a normal return, an error, and a panic. Before this
38/// guard existed each view handled the first by hand and leaked the terminal on the other
39/// two — `?` between "raw mode on" and the restore call would return with the screen still
40/// swapped and echo still off, which reads to the user as a hung shell.
41pub(crate) struct Tui {
42 pub terminal: Terminal<CrosstermBackend<Stdout>>,
43 prior_hook: Arc<dyn Fn(&PanicHookInfo<'_>) + Sync + Send + 'static>,
44}
45
46impl Tui {
47 /// Enter raw mode and the alternate screen, and arm the restore paths.
48 pub fn new() -> Result<Self> {
49 let prior_hook: Arc<dyn Fn(&PanicHookInfo<'_>) + Sync + Send> =
50 Arc::from(std::panic::take_hook());
51
52 // Restore first, then let the previous hook print: a panic message rendered into
53 // the alternate screen vanishes the moment the screen is dropped.
54 let hook_for_panic = Arc::clone(&prior_hook);
55 std::panic::set_hook(Box::new(move |info| {
56 restore_terminal();
57 hook_for_panic(info);
58 }));
59
60 enable_raw_mode()?;
61 stdout().execute(EnterAlternateScreen)?;
62
63 match Terminal::new(CrosstermBackend::new(stdout())) {
64 Ok(terminal) => Ok(Self {
65 terminal,
66 prior_hook,
67 }),
68 Err(e) => {
69 // Constructing the backend failed *after* the screen was swapped. `Drop`
70 // never runs for a `Self` that was never built, so both the screen and
71 // the panic hook have to be put back by hand here. Note this is a
72 // `set_hook`, not a `take_hook`: taking would install std's default and
73 // silently discard whatever hook the caller had before.
74 restore_terminal();
75 std::panic::set_hook(Box::new(move |info| prior_hook(info)));
76 Err(e.into())
77 }
78 }
79 }
80
81 /// Discard input that arrived before the view was ready for it.
82 ///
83 /// The Enter keypress that launched the command is still queued when the loop starts,
84 /// and on Windows its KeyPress/KeyRelease pair arrives inside the loop and confirms
85 /// the selection instantly. The sleep gives the console time to deliver it so that the
86 /// drain below has something to drain.
87 pub fn drain_stale_input(&self, settle: Duration) {
88 std::thread::sleep(settle);
89 // Deliberately not `?`: a failure to drain must never abort the view.
90 while matches!(event::poll(Duration::from_millis(50)), Ok(true)) {
91 let _ = event::read();
92 }
93 }
94}
95
96impl Drop for Tui {
97 fn drop(&mut self) {
98 restore_terminal();
99 let _ = self.terminal.show_cursor();
100 // Hand the panic hook back to whoever owned it, rather than to std's default.
101 let prior = Arc::clone(&self.prior_hook);
102 std::panic::set_hook(Box::new(move |info| prior(info)));
103 }
104}