Skip to main content

mecha10_cli/dev/
termination.rs

1//! Shared termination/intent model for `mecha10 dev`'s interactive TUI screens.
2//!
3//! Before this module existed, "what does this key mean" was answered independently by every
4//! screen that owned terminal input: the outer dev-mode loop (`ops/interactive.rs`) had its own
5//! `is_dev_quit_key`, the teleop sub-screen (`ui/teleop.rs`) had its own `is_quit_all_key` plus a
6//! separate zero-velocity `x`/Space branch, and three more call sites each registered their own
7//! `ctrlc::set_handler`. Because those handlers didn't compose, the same physical key (`x`) meant
8//! two unrelated things depending on which screen happened to own input at the time: "stop the
9//! simulation" at the outer dev-mode menu, or "zero velocity, stay put" inside teleop. A user who
10//! pressed `s` (enter simulation, which auto-opens teleop) then `x` got the second meaning and
11//! nothing stopped - the reported bug this module fixes.
12//!
13//! `classify_key` gives every screen the same answer for "what does this key mean", and
14//! `ScreenOutcome` lets a nested screen (teleop) report what happened without needing to know
15//! about the outer loop's own shutdown flag - the translation from "screen backed out" to "what
16//! that means for this caller" belongs to the caller (see `ops/teleop.rs::enter_teleop_mode`),
17//! not to the screen itself.
18//!
19//! `install_quit_signal` replaces the four independent, copy-pasted `ctrlc::set_handler`
20//! registrations (`ops/interactive.rs`, `ops/lifecycle.rs::run_watch_mode`,
21//! `handlers/logs.rs::follow_logs`, plus what used to be `ui/teleop.rs`'s own Ctrl+C handling
22//! before it was folded into `classify_key`) with one shared helper.
23
24use anyhow::Result;
25use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::{Arc, Mutex, OnceLock};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum TerminationIntent {
31    StopCurrent,
32    QuitAll,
33}
34
35/// Classify a key event into a `TerminationIntent`, if it is one. Pure, dependency-free function
36/// so it's unit testable without a live terminal/event loop.
37///
38/// - Bare `q` / `Esc` / bare `x` (no modifiers) -> `StopCurrent`.
39/// - Ctrl+C / Ctrl+X (any other modifiers alongside Control, e.g. Shift from a capital-C keycap,
40///   still match - `contains` rather than exact equality) -> `QuitAll`.
41/// - Anything else (including Space, and `q`/`x` with unrelated modifiers) -> `None`.
42pub fn classify_key(key: &KeyEvent) -> Option<TerminationIntent> {
43    if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('x')) {
44        return Some(TerminationIntent::QuitAll);
45    }
46
47    if key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('x')) {
48        return Some(TerminationIntent::StopCurrent);
49    }
50
51    None
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ScreenOutcome {
56    ReturnToParent,
57    QuitAll,
58    Continue,
59}
60
61static QUIT_SIGNAL_INSTALLED: OnceLock<Arc<AtomicBool>> = OnceLock::new();
62
63static QUIT_SIGNAL_INIT_LOCK: Mutex<()> = Mutex::new(());
64
65/// Install a single process-wide Ctrl+C handler and return the shared "keep running" flag it
66/// clears on signal. Replaces the four independent, copy-pasted `ctrlc::set_handler`
67/// registrations that used to exist across `ops/interactive.rs`, `ops/lifecycle.rs`, and
68/// `handlers/logs.rs`.
69///
70/// Calling this more than once within a process is safe: only the first call actually registers
71/// the OS-level handler; subsequent (and concurrent) calls just return the same shared
72/// `AtomicBool`, since `ctrlc::set_handler` itself can only be installed once and would error on
73/// a second registration attempt. The registration itself is serialized by
74/// `QUIT_SIGNAL_INIT_LOCK` so concurrent first-callers can't race each other into that error.
75pub fn install_quit_signal() -> Result<Arc<AtomicBool>> {
76    if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
77        return Ok(existing.clone());
78    }
79
80    let _guard = QUIT_SIGNAL_INIT_LOCK
81        .lock()
82        .unwrap_or_else(|poisoned| poisoned.into_inner());
83
84    if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
85        return Ok(existing.clone());
86    }
87
88    let running = Arc::new(AtomicBool::new(true));
89    let r = running.clone();
90    ctrlc::set_handler(move || {
91        r.store(false, Ordering::SeqCst);
92    })?;
93
94    Ok(QUIT_SIGNAL_INSTALLED.get_or_init(|| running).clone())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crossterm::event::KeyEvent;
101
102    #[test]
103    fn plain_q_is_stop_current() {
104        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
105        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
106    }
107
108    #[test]
109    fn esc_is_stop_current() {
110        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
111        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
112    }
113
114    #[test]
115    fn plain_x_without_control_modifier_is_stop_current() {
116        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
117        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
118    }
119
120    #[test]
121    fn ctrl_c_is_quit_all() {
122        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
123        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
124    }
125
126    #[test]
127    fn ctrl_x_is_quit_all() {
128        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
129        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
130    }
131
132    #[test]
133    fn ctrl_shift_c_is_still_quit_all() {
134        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
135        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
136    }
137
138    #[test]
139    fn space_is_not_a_termination_key() {
140        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
141        assert_eq!(classify_key(&key), None);
142    }
143
144    #[test]
145    fn plain_c_without_control_modifier_is_not_a_termination_key() {
146        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
147        assert_eq!(classify_key(&key), None);
148    }
149
150    #[test]
151    fn ctrl_q_is_not_a_termination_key() {
152        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
153        assert_eq!(classify_key(&key), None);
154    }
155
156    #[test]
157    fn unrelated_key_is_not_a_termination_key() {
158        let key = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE);
159        assert_eq!(classify_key(&key), None);
160    }
161
162    #[test]
163    fn install_quit_signal_returns_a_flag_initially_true() {
164        let running = install_quit_signal().expect("installing the quit signal must succeed");
165        assert!(running.load(Ordering::SeqCst));
166    }
167
168    #[test]
169    fn install_quit_signal_is_idempotent_across_repeated_calls() {
170        let first = install_quit_signal().expect("first install must succeed");
171        let second = install_quit_signal().expect("second install must not error");
172        assert!(Arc::ptr_eq(&first, &second));
173    }
174
175    #[test]
176    fn install_quit_signal_survives_concurrent_first_callers() {
177        let handles: Vec<_> = (0..16).map(|_| std::thread::spawn(install_quit_signal)).collect();
178
179        let flags: Vec<Arc<AtomicBool>> = handles
180            .into_iter()
181            .map(|handle| {
182                handle
183                    .join()
184                    .expect("thread must not panic")
185                    .expect("install_quit_signal must not error")
186            })
187            .collect();
188
189        let first = &flags[0];
190        for flag in &flags[1..] {
191            assert!(
192                Arc::ptr_eq(first, flag),
193                "all concurrent callers must observe the same shared flag"
194            );
195        }
196    }
197}