Skip to main content

aver_rt/
terminal.rs

1/// Terminal service — raw mode, cursor control, colored output, key input.
2///
3/// Built on `crossterm`. All public functions return `Result<_, String>` or `Option<String>`.
4use crossterm::{
5    cursor,
6    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
7    style::{self, Color, SetForegroundColor},
8    terminal,
9};
10use std::io::{self, BufWriter, Write};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::time::Duration;
13
14use crossterm::{QueueableCommand, execute};
15
16thread_local! {
17    static STDOUT_BUF: std::cell::RefCell<BufWriter<io::Stdout>> =
18        std::cell::RefCell::new(BufWriter::with_capacity(65536, io::stdout()));
19}
20
21/// Global flag: true while raw mode is active.
22static RAW_MODE_ACTIVE: AtomicBool = AtomicBool::new(false);
23
24pub fn enable_raw_mode() -> Result<(), String> {
25    terminal::enable_raw_mode().map_err(|e| e.to_string())?;
26    RAW_MODE_ACTIVE.store(true, Ordering::SeqCst);
27    Ok(())
28}
29
30pub fn disable_raw_mode() -> Result<(), String> {
31    RAW_MODE_ACTIVE.store(false, Ordering::SeqCst);
32    terminal::disable_raw_mode().map_err(|e| e.to_string())
33}
34
35/// Restore terminal state if raw mode is active.
36/// Call this from panic hooks or cleanup paths.
37pub fn restore_terminal() {
38    if RAW_MODE_ACTIVE.swap(false, Ordering::SeqCst) {
39        let _ = execute!(io::stdout(), cursor::Show);
40        let _ = execute!(io::stdout(), style::ResetColor);
41        let _ = terminal::disable_raw_mode();
42        let _ = io::stdout().write_all(b"\n");
43        let _ = io::stdout().flush();
44    }
45}
46
47pub fn clear() -> Result<(), String> {
48    STDOUT_BUF.with(|buf| {
49        buf.borrow_mut()
50            .queue(terminal::Clear(terminal::ClearType::All))
51            .map(|_| ())
52            .map_err(|e| e.to_string())
53    })
54}
55
56pub fn move_to(x: i64, y: i64) -> Result<(), String> {
57    let x = u16::try_from(x).map_err(|_| format!("Terminal.moveTo: x={} out of range", x))?;
58    let y = u16::try_from(y).map_err(|_| format!("Terminal.moveTo: y={} out of range", y))?;
59    STDOUT_BUF.with(|buf| {
60        buf.borrow_mut()
61            .queue(cursor::MoveTo(x, y))
62            .map(|_| ())
63            .map_err(|e| e.to_string())
64    })
65}
66
67pub fn print_at_cursor(s: &str) -> Result<(), String> {
68    STDOUT_BUF.with(|buf| {
69        buf.borrow_mut()
70            .write_all(s.as_bytes())
71            .map_err(|e| e.to_string())
72    })
73}
74
75pub fn set_color(color: &str) -> Result<(), String> {
76    let c = parse_color(color)?;
77    STDOUT_BUF.with(|buf| {
78        buf.borrow_mut()
79            .queue(SetForegroundColor(c))
80            .map(|_| ())
81            .map_err(|e| e.to_string())
82    })
83}
84
85pub fn reset_color() -> Result<(), String> {
86    STDOUT_BUF.with(|buf| {
87        buf.borrow_mut()
88            .queue(style::ResetColor)
89            .map(|_| ())
90            .map_err(|e| e.to_string())
91    })
92}
93
94/// Non-blocking key read. Returns `None` if no key is available.
95pub fn read_key() -> Option<String> {
96    if !event::poll(Duration::ZERO).ok()? {
97        return None;
98    }
99    let event = event::read().ok()?;
100    match event {
101        Event::Key(KeyEvent {
102            code, modifiers, ..
103        }) => {
104            // Ctrl+C always produces "esc" so the game can exit
105            if modifiers.contains(KeyModifiers::CONTROL) && code == KeyCode::Char('c') {
106                return Some("esc".to_string());
107            }
108            match code {
109                KeyCode::Up => Some("up".to_string()),
110                KeyCode::Down => Some("down".to_string()),
111                KeyCode::Left => Some("left".to_string()),
112                KeyCode::Right => Some("right".to_string()),
113                KeyCode::Esc => Some("esc".to_string()),
114                KeyCode::Enter => Some("enter".to_string()),
115                KeyCode::Char(c) => Some(c.to_string()),
116                _ => None,
117            }
118        }
119        _ => None,
120    }
121}
122
123pub fn size() -> Result<(i64, i64), String> {
124    let (w, h) = terminal::size().map_err(|e| e.to_string())?;
125    Ok((w as i64, h as i64))
126}
127
128pub fn hide_cursor() -> Result<(), String> {
129    STDOUT_BUF.with(|buf| {
130        buf.borrow_mut()
131            .queue(cursor::Hide)
132            .map(|_| ())
133            .map_err(|e| e.to_string())
134    })
135}
136
137pub fn show_cursor() -> Result<(), String> {
138    STDOUT_BUF.with(|buf| {
139        buf.borrow_mut()
140            .queue(cursor::Show)
141            .map(|_| ())
142            .map_err(|e| e.to_string())
143    })
144}
145
146pub fn flush() -> Result<(), String> {
147    STDOUT_BUF.with(|buf| buf.borrow_mut().flush().map_err(|e| e.to_string()))
148}
149
150/// RAII guard that restores the terminal on drop.
151/// Lives on the stack in `cmd_run`; cleanup fires even on panic or early exit.
152#[derive(Default)]
153pub struct TerminalGuard;
154
155impl TerminalGuard {
156    pub fn new() -> Self {
157        Self
158    }
159}
160
161impl Drop for TerminalGuard {
162    fn drop(&mut self) {
163        restore_terminal();
164    }
165}
166
167fn parse_color(s: &str) -> Result<Color, String> {
168    match s {
169        "red" => Ok(Color::Red),
170        "green" => Ok(Color::Green),
171        "yellow" => Ok(Color::Yellow),
172        "blue" => Ok(Color::Blue),
173        "white" => Ok(Color::White),
174        "cyan" => Ok(Color::Cyan),
175        "magenta" => Ok(Color::Magenta),
176        "black" => Ok(Color::Black),
177        _ => Err(format!(
178            "Terminal.setColor: unknown color '{}' (expected: red, green, yellow, blue, white, cyan, magenta, black)",
179            s
180        )),
181    }
182}