Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
25 kB · 689 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690//! 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<HeldMove>,}
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<Backend> { 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<Backend> { 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<Backend> { 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<HeldMove> { 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<Vec<Input>> { 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<Input> { 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<Option<HeldMove>> { 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<String>)> = 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); }, ); }}