use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminationIntent {
StopCurrent,
QuitAll,
}
pub fn classify_key(key: &KeyEvent) -> Option<TerminationIntent> {
if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('x')) {
return Some(TerminationIntent::QuitAll);
}
if key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('x')) {
return Some(TerminationIntent::StopCurrent);
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScreenOutcome {
ReturnToParent,
QuitAll,
Continue,
}
static QUIT_SIGNAL_INSTALLED: OnceLock<Arc<AtomicBool>> = OnceLock::new();
static QUIT_SIGNAL_INIT_LOCK: Mutex<()> = Mutex::new(());
pub fn install_quit_signal() -> Result<Arc<AtomicBool>> {
if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
return Ok(existing.clone());
}
let _guard = QUIT_SIGNAL_INIT_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
return Ok(existing.clone());
}
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})?;
Ok(QUIT_SIGNAL_INSTALLED.get_or_init(|| running).clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
#[test]
fn plain_q_is_stop_current() {
let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
}
#[test]
fn esc_is_stop_current() {
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
}
#[test]
fn plain_x_without_control_modifier_is_stop_current() {
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
}
#[test]
fn ctrl_c_is_quit_all() {
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
}
#[test]
fn ctrl_x_is_quit_all() {
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
}
#[test]
fn ctrl_shift_c_is_still_quit_all() {
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
}
#[test]
fn space_is_not_a_termination_key() {
let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
assert_eq!(classify_key(&key), None);
}
#[test]
fn plain_c_without_control_modifier_is_not_a_termination_key() {
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
assert_eq!(classify_key(&key), None);
}
#[test]
fn ctrl_q_is_not_a_termination_key() {
let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
assert_eq!(classify_key(&key), None);
}
#[test]
fn unrelated_key_is_not_a_termination_key() {
let key = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE);
assert_eq!(classify_key(&key), None);
}
#[test]
fn install_quit_signal_returns_a_flag_initially_true() {
let running = install_quit_signal().expect("installing the quit signal must succeed");
assert!(running.load(Ordering::SeqCst));
}
#[test]
fn install_quit_signal_is_idempotent_across_repeated_calls() {
let first = install_quit_signal().expect("first install must succeed");
let second = install_quit_signal().expect("second install must not error");
assert!(Arc::ptr_eq(&first, &second));
}
#[test]
fn install_quit_signal_survives_concurrent_first_callers() {
let handles: Vec<_> = (0..16).map(|_| std::thread::spawn(install_quit_signal)).collect();
let flags: Vec<Arc<AtomicBool>> = handles
.into_iter()
.map(|handle| {
handle
.join()
.expect("thread must not panic")
.expect("install_quit_signal must not error")
})
.collect();
let first = &flags[0];
for flag in &flags[1..] {
assert!(
Arc::ptr_eq(first, flag),
"all concurrent callers must observe the same shared flag"
);
}
}
}