use std::ffi::OsStr;
use std::io::{self, IsTerminal};
use std::panic::PanicHookInfo;
use std::sync::atomic::{AtomicBool, Ordering};
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::cursor::Show;
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{enable_raw_mode, EnterAlternateScreen};
use ratatui::{DefaultTerminal, Terminal};
static ACTIVE: AtomicBool = AtomicBool::new(false);
pub(crate) fn unusable_terminal(
stdin_tty: bool,
stdout_tty: bool,
term: Option<&OsStr>,
) -> Option<&'static str> {
if !stdin_tty || !stdout_tty {
Some("stdin and stdout must both be a terminal")
} else if term.is_some_and(|t| t == "dumb") {
Some("TERM is \"dumb\"")
} else {
None
}
}
pub(crate) fn unusable_hint(reason: &str) -> String {
format!(
"Error: bacnet tui needs an interactive terminal ({reason}).\n\
hint: for scripts and pipes use the one-shot commands, such as \
`bacnet discover --json`."
)
}
pub(crate) fn check_terminal() -> Result<(), String> {
let term = std::env::var_os("TERM");
match unusable_terminal(
io::stdin().is_terminal(),
io::stdout().is_terminal(),
term.as_deref(),
) {
Some(reason) => Err(unusable_hint(reason)),
None => Ok(()),
}
}
pub(crate) type PanicHook = Box<dyn Fn(&PanicHookInfo<'_>) + Sync + Send + 'static>;
pub(crate) fn chain_panic_hook(
previous: PanicHook,
restore: impl Fn() + Sync + Send + 'static,
) -> PanicHook {
Box::new(move |info| {
restore();
previous(info);
})
}
pub(crate) fn enter() -> io::Result<DefaultTerminal> {
let previous = std::panic::take_hook();
std::panic::set_hook(chain_panic_hook(previous, restore));
enable_raw_mode()?;
ACTIVE.store(true, Ordering::SeqCst);
let terminal = execute!(io::stdout(), EnterAlternateScreen)
.and_then(|()| Terminal::new(CrosstermBackend::new(io::stdout())));
if terminal.is_err() {
restore();
}
terminal
}
pub(crate) fn is_active() -> bool {
ACTIVE.load(Ordering::SeqCst)
}
pub(crate) fn restore() {
if ACTIVE.swap(false, Ordering::SeqCst) {
let _ = ratatui::try_restore();
let _ = execute!(io::stdout(), Show);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SignalExit {
pub(crate) name: &'static str,
pub(crate) code: i32,
}
pub(crate) struct Signals {
#[cfg(unix)]
unix: Option<[(tokio::signal::unix::Signal, SignalExit); 3]>,
#[cfg(windows)]
windows: Option<(
tokio::signal::windows::CtrlClose,
tokio::signal::windows::CtrlBreak,
)>,
}
impl Signals {
pub(crate) fn install() -> io::Result<Self> {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let exit = |name, number: i32| SignalExit {
name,
code: 128 + number,
};
Ok(Self {
unix: Some([
(signal(SignalKind::terminate())?, exit("SIGTERM", 15)),
(signal(SignalKind::hangup())?, exit("SIGHUP", 1)),
(signal(SignalKind::interrupt())?, exit("SIGINT", 2)),
]),
})
}
#[cfg(windows)]
{
use tokio::signal::windows::{ctrl_break, ctrl_close};
Ok(Self {
windows: Some((ctrl_close()?, ctrl_break()?)),
})
}
#[cfg(not(any(unix, windows)))]
{
Ok(Self {})
}
}
#[cfg(test)]
pub(crate) fn none() -> Self {
Self {
#[cfg(unix)]
unix: None,
#[cfg(windows)]
windows: None,
}
}
pub(crate) async fn recv(&mut self) -> SignalExit {
#[cfg(unix)]
if let Some([(a, a_exit), (b, b_exit), (c, c_exit)]) = &mut self.unix {
tokio::select! {
Some(()) = a.recv() => return *a_exit,
Some(()) = b.recv() => return *b_exit,
Some(()) = c.recv() => return *c_exit,
else => {}
}
}
#[cfg(windows)]
if let Some((close, brk)) = &mut self.windows {
let exit = |name| SignalExit { name, code: 1 };
tokio::select! {
Some(()) = close.recv() => return exit("console close"),
Some(()) = brk.recv() => return exit("Ctrl-Break"),
else => {}
}
}
std::future::pending().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn guard_needs_two_ttys_and_a_real_term() {
assert_eq!(
unusable_terminal(true, true, Some(OsStr::new("xterm"))),
None
);
assert_eq!(
unusable_terminal(true, true, None),
None,
"Windows has no TERM"
);
for (stdin, stdout) in [(false, true), (true, false), (false, false)] {
assert!(unusable_terminal(stdin, stdout, None)
.unwrap()
.contains("must both be a terminal"));
}
assert!(unusable_terminal(true, true, Some(OsStr::new("dumb")))
.unwrap()
.contains("dumb"));
}
#[test]
fn panic_hook_restores_before_the_previous_hook() {
let order = Arc::new(Mutex::new(Vec::new()));
let previous: PanicHook = {
let order = Arc::clone(&order);
Box::new(move |_| order.lock().unwrap().push("previous hook"))
};
let hook = {
let order = Arc::clone(&order);
chain_panic_hook(previous, move || order.lock().unwrap().push("restore"))
};
let original = std::panic::take_hook();
std::panic::set_hook(hook);
let result = std::panic::catch_unwind(|| panic!("boom"));
std::panic::set_hook(original);
assert!(result.is_err());
assert_eq!(*order.lock().unwrap(), ["restore", "previous hook"]);
}
#[test]
fn restore_is_a_no_op_until_the_terminal_is_entered() {
restore();
restore();
assert!(!ACTIVE.load(Ordering::SeqCst));
}
}