//! Crossterm terminal backend (docs/04 §4.6, docs/05 §5.3, docs/01 §6–7). //! //! This is the ONLY module allowed to touch the real terminal: raw mode, //! the alternate screen, size queries, ANSI writes, and event reads all live //! here. Everything above this layer speaks in pre-encoded ANSI `&str` //! ([`Backend::flush_str`], produced by `hexwork-render`'s `ansi.rs`) and //! normalized [`Input`] (mapped by [`crate::keymap`]). //! //! Design trace: //! - docs/05 §5.3: capability probe at boot (truecolor? unicode? mouse? //! size?), detect-and-adapt with one log line per adaptation. //! - docs/04 §4.6: `term` normalizes terminals behind one surface; full //! keyboard play mandatory, every mouse action has a key twin. //! - docs/01 §7 (kindness): refusing a too-small terminal must be *kind* — //! [`refusal_message`] explains the floor and how to fix it, never a bare //! panic. //! //! # What is (and is not) unit-tested here //! //! Pure functions — [`refusal_message`], [`map_event`], [`probe_caps`] //! (env-driven) — are unit-tested below and need no TTY. The TTY methods //! ([`Backend::new`], [`Backend::with_keymap`], [`Backend::size`], //! [`Backend::flush_str`], [`Backend::poll_input`]) require a real terminal and are NOT unit-tested; //! the parent covers them in the manual slice playtest (see the //! `TTY-untested surface` list in this workstream's return notes). use crate::keymap::map_mouse_event; use crate::remap::{parse_keymap, Keymap, DEFAULT_KEYMAP_TOML}; use crate::{Capabilities, HeldMove, Input, MIN_H, MIN_W}; use crossterm::event::{self, Event, KeyEventKind}; use crossterm::event::{ DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, }; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use std::io::Write as _; use std::time::Duration; /// Crossterm-backed terminal session: raw mode + alternate screen. /// /// Owns the terminal from [`Backend::new`] until drop. The alternate screen /// preserves the user's scrollback; raw mode gives us key-by-key input. pub struct Backend { out: std::io::Stdout, keymap: Keymap, mouse_enabled: bool, /// Movement key currently held (key-repeat), cleared on release/focus loss. held_move: Option, } impl Backend { /// Enter raw mode + alternate screen with the embedded default keymap. /// /// This convenience performs no disk I/O. An invalid embedded asset is /// reported as `InvalidData` rather than panicking. pub fn new() -> std::io::Result { let keymap = parse_keymap(DEFAULT_KEYMAP_TOML) .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; Self::with_keymap(keymap) } /// Enter raw mode + alternate screen with one validated immutable keymap. pub fn with_keymap(keymap: Keymap) -> std::io::Result { Self::with_keymap_and_mouse(keymap, false) } /// Enter a session with optional best-effort mouse capture. Keyboard play /// remains available if capture fails. The session guard cleans up reporting. pub fn with_keymap_and_mouse(keymap: Keymap, mouse: bool) -> std::io::Result { terminal::enable_raw_mode()?; let mut backend = Backend { out: std::io::stdout(), keymap, mouse_enabled: false, held_move: None, }; crossterm::execute!(&mut backend.out, EnterAlternateScreen)?; let _ = crossterm::execute!(&mut backend.out, EnableFocusChange); if mouse { backend.mouse_enabled = crossterm::execute!(&mut backend.out, EnableMouseCapture).is_ok(); if !backend.mouse_enabled { let _ = crossterm::execute!(&mut backend.out, DisableMouseCapture); } } Ok(backend) } /// Whether capture was requested and its enable command succeeded. /// Terminal protocols do not acknowledge actual mouse support. pub fn mouse_enabled(&self) -> bool { self.mouse_enabled } /// The movement key currently held, from terminal key-repeat. /// /// `None` on terminals that never report repeats (the common case) or after /// a release / focus loss. The parent drives Action-paced travel from this /// plus [`Input::ToggleWalk`]; it never invents a release with a timer. pub fn held_move(&self) -> Option { self.held_move } /// The immutable physical keymap used by all input polling. pub fn keymap(&self) -> &Keymap { &self.keymap } /// Current terminal size in columns × rows. pub fn size(&self) -> std::io::Result<(u16, u16)> { terminal::size() } /// Write a pre-encoded ANSI frame (from `hexwork-render`'s `ansi.rs`). /// /// Takes `&str` deliberately: this layer has no knowledge of frames, /// cells, or diffs — it is a byte pipe with a flush. pub fn flush_str(&mut self, s: &str) -> std::io::Result<()> { self.out.write_all(s.as_bytes())?; self.out.flush() } /// Drain all pending input, waiting at most `timeout_ms` for the first /// event, then draining the rest without blocking. /// /// Resize dimensions and focus loss survive in order for the caller. /// Key releases and explicitly reported toggle repeats are skipped. /// Movement key repeats are consumed here instead of becoming a discrete /// Move: they set [`Backend::held_move`] (continuous Action-paced travel) /// and their matching release clears it. Ordinary presses are never guessed /// to be repeats, so a tap is still exactly one Move. pub fn poll_input(&mut self, timeout_ms: u64) -> std::io::Result> { let mut inputs = Vec::new(); if !event::poll(Duration::from_millis(timeout_ms))? { return Ok(inputs); } loop { let ev = event::read()?; let consumed_held = self.track_held(&ev); if !consumed_held { if let Some(input) = map_event(&self.keymap, ev) { inputs.push(input); } } if !event::poll(Duration::from_millis(0))? { break; } } Ok(inputs) } /// Update [`Backend::held_move`] from one raw event. Returns true when the /// event was consumed as held-movement state (a movement-key repeat) and /// must not also produce a discrete [`Input`]. fn track_held(&mut self, ev: &Event) -> bool { match ev { Event::Key(key) => match held_key_update(&self.keymap, key.kind, key.code, key.modifiers) { Some(update) => { self.held_move = update; true } None => false, }, Event::FocusLost => { // Losing transport stops new intent: no release event is coming. self.held_move = None; false } _ => false, } } /// Probe terminal capabilities at boot (docs/05 §5.3). /// /// - `truecolor`: `COLORTERM` contains `truecolor` or `24bit` /// (case-insensitive). Absent → the renderer takes the 256-color LUT /// path; `HEXWORK_FORCE_TC` / `HEXWORK_FORCE_256` / `HEXWORK_NO_COLOR` /// still override parent-side via `hexwork-render`'s `env.rs`. /// - `unicode`: the `LANG` / `LC_ALL` heuristic below, unless /// `HEXWORK_FORCE_ASCII` is set (matches `env.rs`'s `force_ascii`). /// - `mouse`: `false` in this static probe; use [`Backend::mouse_enabled`] /// after constructing a session with optional capture for actual status. /// - `size`: live `terminal::size()`, or the `(120, 36)` recommended /// default when there is no TTY (matches `Capabilities::default`). pub fn probe_caps() -> Capabilities { let truecolor = std::env::var("COLORTERM") .map(|v| { let lower = v.to_ascii_lowercase(); lower.contains("truecolor") || lower.contains("24bit") }) .unwrap_or(false); let unicode = !std::env::var("HEXWORK_FORCE_ASCII") .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) .unwrap_or(false) && locale_allows_unicode(); let size = terminal::size().unwrap_or((120, 36)); Capabilities { truecolor, unicode, mouse: false, size, } } } impl Drop for Backend { /// Best-effort terminal restore: leave the alternate screen, leave raw /// mode. Never panics — a destructor must not fail the unwind it may /// already be part of. fn drop(&mut self) { if self.mouse_enabled { let _ = crossterm::execute!(&mut self.out, DisableMouseCapture); } let _ = crossterm::execute!(&mut self.out, DisableFocusChange); let _ = crossterm::execute!(&mut self.out, LeaveAlternateScreen); let _ = terminal::disable_raw_mode(); } } /// Map one crossterm event to normalized input. /// /// Pure (no TTY): keys resolve through the stored map before repeat filtering. /// Releases, toggle repeats, focus gain and pasted text have no effect. /// Resize, focus loss and left-button coordinates are transported to the caller. /// Ordinary Press events are never guessed to be repeats. fn map_event(keymap: &Keymap, ev: Event) -> Option { match ev { Event::Key(k) => { if k.kind == KeyEventKind::Release { None } else { let input = keymap.map_key(k.code, k.modifiers); if k.kind == KeyEventKind::Repeat && matches!( input, Input::CameraToggle | Input::Map | Input::Look | Input::Inventory | Input::Contracts | Input::Chapel | Input::Quit ) { None } else { Some(input) } } } Event::Mouse(m) => Some(map_mouse_event(m)), Event::Resize(w, h) => Some(Input::Resize(w, h)), Event::FocusLost => Some(Input::FocusLost), Event::FocusGained | Event::Paste(_) => None, } } /// Pure held-movement decision for one key event. /// /// `Some(Some(held))` starts or extends a hold (a movement-key repeat), /// `Some(None)` clears it (a movement-key release), and `None` means the event /// is not held-movement state — a press (still a discrete Move), an unrelated /// key, or a shifted step-mode chord. No clock and no TTY, so it is unit-tested. fn held_key_update( keymap: &Keymap, kind: KeyEventKind, code: crossterm::event::KeyCode, modifiers: crossterm::event::KeyModifiers, ) -> Option> { let held = HeldMove::from_input(keymap.map_key(code, modifiers)); match kind { KeyEventKind::Repeat => held.map(Some), KeyEventKind::Release => held.map(|_| None), KeyEventKind::Press => None, } } /// Locale heuristic for unicode support (docs/05 §5.3 capability probe). /// /// `LC_ALL` wins over `LANG` when set and non-empty. A locale naming a UTF-8 /// encoding always allows unicode (even `C.UTF-8`, common on servers). /// Otherwise the bare `C` / `POSIX` locales suggest ASCII-only. Anything /// unrecognized defaults to unicode-capable — modern terminals almost always /// are, and `HEXWORK_FORCE_ASCII=1` remains the escape hatch. fn locale_allows_unicode() -> bool { let v = std::env::var("LC_ALL") .ok() .filter(|s| !s.is_empty()) .or_else(|| std::env::var("LANG").ok()) .unwrap_or_default(); let u = v.to_ascii_uppercase(); if u.contains("UTF-8") || u.contains("UTF8") { return true; } if u.contains("POSIX") { return false; } !(u == "C" || u.starts_with("C.") || u.starts_with("C:") || u.starts_with("C ")) } /// Kind refusal for terminals below [`MIN_W`]×[`MIN_H`] (docs/01 §7). /// /// Pure and tested. Kindness-layer rules: name the floor, name the actual /// size, say exactly how to fix it (resize/enlarge), no jargon, no shame — /// kindness removes friction, never consequence. pub fn refusal_message(w: u16, h: u16) -> String { format!( "HEXWORK needs a terminal of at least {MIN_W}×{MIN_H} (columns × rows) \ to draw the dungeon, but this terminal is {w}×{h}. \ Please resize the window — enlarge it or shrink the font — then start again. \ Your delver will wait; the Deep isn't going anywhere." ) } #[cfg(test)] mod tests { use super::*; use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use std::sync::Mutex; /// Probe reads process-global env; serialize those tests. static ENV_LOCK: Mutex<()> = Mutex::new(()); fn with_vars(vars: &[(&str, Option<&str>)], f: impl FnOnce()) { let saved: Vec<(&str, Option)> = vars .iter() .map(|(k, _)| (*k, std::env::var(k).ok())) .collect(); for (k, v) in vars { match v { Some(s) => std::env::set_var(k, s), None => std::env::remove_var(k), } } f(); for (k, v) in saved { match v { Some(s) => std::env::set_var(k, s), None => std::env::remove_var(k), } } } #[test] fn refusal_names_floor_actual_size_and_fix() { let m = refusal_message(60, 20); assert!(m.contains("80"), "must name the width floor"); assert!(m.contains("24"), "must name the height floor"); assert!(m.contains("60"), "must name the actual width"); assert!(m.contains("20"), "must name the actual height"); assert!( m.to_ascii_lowercase().contains("resize") || m.to_ascii_lowercase().contains("enlarge"), "must carry a resize hint" ); } #[test] fn refusal_floor_matches_lib_consts() { // If MIN_W/MIN_H ever change, the message follows automatically — // this pins the coupling. assert_eq!((MIN_W, MIN_H), (80, 24)); let m = refusal_message(79, 24); assert!(m.contains("79")); } #[test] fn backend_loaded_keymap_maps_real_event_adapter() { let remapped = DEFAULT_KEYMAP_TOML .replace("left = [\"Left\", \"a\", \"h\"]", "left = [\"f\"]") .replace("quit = [\"Esc\", \"q\"]", "quit = [\"v\"]") .replace("chargen_pick_1 = [\"1\"]", "chargen_pick_1 = [\"7\"]") .replace("chargen_pick_2 = [\"2\"]", "chargen_pick_2 = [\"8\"]"); let keymap = parse_keymap(&remapped).unwrap(); for (code, expected) in [ (KeyCode::Char('f'), Input::Left), (KeyCode::Char('7'), Input::ChargenPick(0)), (KeyCode::Char('8'), Input::ChargenPick(1)), (KeyCode::Char('v'), Input::Quit), (KeyCode::Char('h'), Input::Unknown), (KeyCode::Char('1'), Input::Unknown), (KeyCode::Char('q'), Input::Unknown), ] { assert_eq!( map_event(&keymap, Event::Key(code.into())), Some(expected), "unexpected mapping for {code:?}" ); } } #[test] fn backend_default_keymap_matches_legacy_actions() { let keymap = parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(); let codes = [ KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right, KeyCode::Char('w'), KeyCode::Char('W'), KeyCode::Char('s'), KeyCode::Char('S'), KeyCode::Char('a'), KeyCode::Char('A'), KeyCode::Char('d'), KeyCode::Char('D'), KeyCode::Char('h'), KeyCode::Char('H'), KeyCode::Char('j'), KeyCode::Char('J'), KeyCode::Char('k'), KeyCode::Char('K'), KeyCode::Char('l'), KeyCode::Char('L'), KeyCode::Char('q'), KeyCode::Char('Q'), KeyCode::Char('e'), KeyCode::Char('E'), KeyCode::Char('z'), KeyCode::Char('Z'), KeyCode::Char('c'), KeyCode::Char('C'), KeyCode::Char('y'), KeyCode::Char('Y'), KeyCode::Char('u'), KeyCode::Char('U'), KeyCode::Char('b'), KeyCode::Char('B'), KeyCode::Char('n'), KeyCode::Char('N'), KeyCode::Char('x'), KeyCode::Char('X'), KeyCode::Char('i'), KeyCode::Char('I'), KeyCode::Char('m'), KeyCode::Char('M'), KeyCode::Char('p'), KeyCode::Char('P'), KeyCode::Tab, KeyCode::BackTab, KeyCode::Char(' '), KeyCode::Char('.'), KeyCode::Esc, KeyCode::Enter, KeyCode::Char('1'), KeyCode::Char('2'), KeyCode::Char('3'), KeyCode::Char('4'), KeyCode::Char('5'), KeyCode::Char('6'), KeyCode::Char('7'), KeyCode::Char('8'), KeyCode::Char('9'), KeyCode::Char('v'), KeyCode::F(1), KeyCode::Home, KeyCode::Delete, ]; let modifiers = [ KeyModifiers::NONE, KeyModifiers::SHIFT, KeyModifiers::CONTROL, KeyModifiers::ALT, KeyModifiers::SUPER, KeyModifiers::HYPER, ]; for code in codes { for modifier in modifiers { let expected = crate::keymap::map_key(code, modifier); assert_eq!( map_event( &keymap, Event::Key(crossterm::event::KeyEvent::new(code, modifier)) ), Some(expected), "default parity mismatch for {code:?} with {modifier:?}" ); } } } #[test] fn map_event_key_release_is_swallowed() { let mut k = crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::NONE); k.kind = KeyEventKind::Release; let keymap = parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(); assert_eq!(map_event(&keymap, Event::Key(k)), None); } #[test] fn mouse_coordinates_and_resize_focus_survive_backend() { let keymap = parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(); for (kind, expected) in [ ( MouseEventKind::Down(MouseButton::Left), Input::MouseDown(73, 19), ), ( MouseEventKind::Drag(MouseButton::Left), Input::MouseDrag(73, 19), ), (MouseEventKind::Up(MouseButton::Left), Input::MouseUp), (MouseEventKind::Drag(MouseButton::Right), Input::Unknown), ] { let m = MouseEvent { kind, column: 73, row: 19, modifiers: KeyModifiers::NONE, }; assert_eq!(map_event(&keymap, Event::Mouse(m)), Some(expected)); } assert_eq!( map_event(&keymap, Event::Resize(100, 40)), Some(Input::Resize(100, 40)) ); assert_eq!( map_event(&keymap, Event::Resize(0, 0)), Some(Input::Resize(0, 0)) ); assert_eq!(map_event(&keymap, Event::FocusGained), None); assert_eq!(map_event(&keymap, Event::FocusLost), Some(Input::FocusLost)); assert_eq!(map_event(&keymap, Event::Paste("qqww".into())), None); } #[test] fn repeats_filter_resolved_toggles_not_presses_or_movement() { let text = DEFAULT_KEYMAP_TOML.replace("camera_toggle = [\"Tab\"]", "camera_toggle = [\"f\"]"); let keymap = parse_keymap(&text).unwrap(); for code in [ KeyCode::Char('f'), KeyCode::Char('m'), KeyCode::Char('x'), KeyCode::Char('i'), KeyCode::Esc, ] { let mut key = crossterm::event::KeyEvent::new(code, KeyModifiers::NONE); assert_eq!( map_event(&keymap, Event::Key(key)), Some(keymap.map_key(code, KeyModifiers::NONE)) ); key.kind = KeyEventKind::Repeat; assert_eq!(map_event(&keymap, Event::Key(key)), None); } for (code, expected) in [ (KeyCode::Up, Input::Up), (KeyCode::Char('>'), Input::TiltUp), (KeyCode::PageDown, Input::SliceDown), ] { let mut key = crossterm::event::KeyEvent::new(code, KeyModifiers::NONE); key.kind = KeyEventKind::Repeat; assert_eq!(map_event(&keymap, Event::Key(key)), Some(expected)); } } #[test] fn movement_repeats_start_hold_and_releases_clear_it() { let keymap = parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(); assert_eq!( held_key_update(&keymap, KeyEventKind::Press, KeyCode::Up, KeyModifiers::NONE), None, "a press stays a discrete Move" ); assert_eq!( held_key_update(&keymap, KeyEventKind::Repeat, KeyCode::Up, KeyModifiers::NONE), Some(Some(HeldMove::Up)) ); assert_eq!( held_key_update( &keymap, KeyEventKind::Repeat, KeyCode::Char('d'), KeyModifiers::NONE ), Some(Some(HeldMove::Right)) ); assert_eq!( held_key_update(&keymap, KeyEventKind::Release, KeyCode::Up, KeyModifiers::NONE), Some(None) ); // Unrelated keys and shifted step chords are never held travel. assert_eq!( held_key_update(&keymap, KeyEventKind::Repeat, KeyCode::Char('x'), KeyModifiers::NONE), None ); assert_eq!( held_key_update(&keymap, KeyEventKind::Repeat, KeyCode::Up, KeyModifiers::SHIFT), None ); } #[test] fn probe_detects_truecolor_colorterm() { let _g = ENV_LOCK.lock().unwrap(); with_vars( &[ ("COLORTERM", Some("truecolor")), ("HEXWORK_FORCE_ASCII", None), ("LC_ALL", None), ("LANG", Some("en_US.UTF-8")), ], || { let caps = Backend::probe_caps(); assert!(caps.truecolor); assert!(caps.unicode); assert!(!caps.mouse); }, ); } #[test] fn probe_no_colorterm_means_no_truecolor() { let _g = ENV_LOCK.lock().unwrap(); with_vars( &[ ("COLORTERM", None), ("HEXWORK_FORCE_ASCII", None), ("LC_ALL", None), ("LANG", Some("en_US.UTF-8")), ], || { assert!(!Backend::probe_caps().truecolor); }, ); } #[test] fn probe_24bit_counts_as_truecolor() { let _g = ENV_LOCK.lock().unwrap(); with_vars( &[ ("COLORTERM", Some("24bit")), ("HEXWORK_FORCE_ASCII", None), ("LC_ALL", None), ("LANG", Some("en_US.UTF-8")), ], || { assert!(Backend::probe_caps().truecolor); }, ); } #[test] fn probe_c_locale_suggests_ascii_but_utf8_wins() { let _g = ENV_LOCK.lock().unwrap(); // Bare C locale → ASCII suggestion. with_vars( &[ ("COLORTERM", None), ("HEXWORK_FORCE_ASCII", None), ("LC_ALL", Some("C")), ("LANG", Some("en_US.UTF-8")), ], || { assert!(!Backend::probe_caps().unicode); }, ); // C.UTF-8 names UTF-8 → unicode-capable despite the C prefix. with_vars( &[ ("COLORTERM", None), ("HEXWORK_FORCE_ASCII", None), ("LC_ALL", Some("C.UTF-8")), ("LANG", Some("C")), ], || { assert!(Backend::probe_caps().unicode); }, ); } #[test] fn probe_force_ascii_beats_unicode_locale() { let _g = ENV_LOCK.lock().unwrap(); with_vars( &[ ("COLORTERM", None), ("HEXWORK_FORCE_ASCII", Some("1")), ("LC_ALL", None), ("LANG", Some("en_US.UTF-8")), ], || { assert!(!Backend::probe_caps().unicode); }, ); } }