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.
16 kB · 452 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453//! Dyslexia-friendly log layout (docs/01 §7, docs/05 §5.5).//!//! The terminal font belongs to the user, so "font rendering" here means//! layout, not glyphs (docs/05 §5.5 "Dyslexia/log options")://!//! - normal mode: word-wrap each entry at width `w`;//! - dyslexia mode (the `dyslexia` flag on the accessibility `Settings`)://! `min(w, 50)` (shorter lines), emit a BLANK line between entries//! (spacing), and strip `*` / `_` emphasis characters (no italics in//! critical warnings — *"no wrong answer"* must read as plain words).//!//! Stripping is meaning-preserving: emphasis chars are removed, every other//! positive-width character (including order) survives, pinned by test. C0//! controls and DEL are normalized to spaces so they cannot affect terminal//! state. Width `0` yields an empty vec — never panic, whatever the terminal//! reports.//!//! Wrapping uses Unicode display-cell widths. A zero-width scalar remains//! attached to a preceding positive-width scalar, while an orphan at the start//! of a word is dropped. At the impossible one-cell width, a width-two scalar//! is represented by `?` rather than silently disappearing. This is scalar//! width handling, not a claim of full grapheme or terminal certification.//!//! Pure string logic: the parent owns the log render path (feeds `Log`//! lines in, blits the returned rows out).
use unicode_width::UnicodeWidthChar;
/// Cap for dyslexia-mode line width (docs/05 §5.5 "shorter lines").pub const DYSLEXIA_MAX_WIDTH: usize = 50;
const RECENT_ENTRY_LIMIT: usize = 50;const RECENT_ENTRY_SCALAR_LIMIT: usize = 512;
/// A bounded, presentation-only view of the newest log entries.#[derive(Clone, Debug, Default, Eq, PartialEq)]pub struct RecentLogWindow { /// Wrapped rows, with selected entry groups restored to chronological order. pub rows: Vec<String>, /// Source entries not selected, including entries older than the inspection cap. pub omitted_entries: usize, /// Whether the source cap, scalar cap, or row budget clipped this view. pub clipped: bool,}
/// Lay out log `lines` for a `w`-wide view.////// - `dyslexia = false`: each entry word-wrapped at `w`./// - `dyslexia = true`: each entry stripped of `*`/`_` and wrapped at/// `min(w, 50)`, with a blank `""` row between entries./// - `w == 0` (or empty input): empty vec, never a panic.#[must_use]pub fn format_log(lines: &[String], w: usize, dyslexia: bool) -> Vec<String> { if w == 0 || lines.is_empty() { return Vec::new(); } let width = if dyslexia { w.min(DYSLEXIA_MAX_WIDTH) } else { w }; let mut out = Vec::new(); for (i, line) in lines.iter().enumerate() { if dyslexia && i > 0 { out.push(String::new()); } let text = if dyslexia { strip_emphasis(line) } else { line.clone() }; out.extend(wrap(&text, width)); } out}
/// Select a bounded recent window without formatting the complete source.////// `lines` are ordered oldest-to-newest. Only the newest 50 entries are/// inspected, and each inspected entry is capped at 512 Unicode scalars before/// wrapping. Newest entries consume the row budget first, but selected entry/// groups are returned oldest-to-newest. If only a prefix of the next entry/// fits, that prefix is kept and [`RecentLogWindow::clipped`] is set.#[must_use]pub fn recent_log_window(lines: &[String], width: usize, row_budget: usize) -> RecentLogWindow { let inspect_start = lines.len().saturating_sub(RECENT_ENTRY_LIMIT); let mut window = RecentLogWindow { rows: Vec::new(), omitted_entries: inspect_start, clipped: inspect_start > 0, };
if lines.is_empty() { return window; } if width == 0 || row_budget == 0 { window.omitted_entries = lines.len(); window.clipped = true; return window; }
let inspected = &lines[inspect_start..]; let mut remaining_rows = row_budget; let mut groups: Vec<Vec<String>> = Vec::new();
for (reverse_index, line) in inspected.iter().rev().enumerate() { if remaining_rows == 0 { window.omitted_entries += inspected.len() - reverse_index; window.clipped = true; break; }
let (bounded, scalar_clipped) = scalar_prefix(line, RECENT_ENTRY_SCALAR_LIMIT); let mut rows = wrap(&bounded, width); let take = rows.len().min(remaining_rows); let row_clipped = take < rows.len(); rows.truncate(take); groups.push(rows); remaining_rows -= take; window.clipped |= scalar_clipped || row_clipped;
if row_clipped { window.omitted_entries += inspected.len() - reverse_index - 1; break; } }
let selected_rows = groups.iter().map(Vec::len).sum(); window.rows.reserve(selected_rows); for group in groups.into_iter().rev() { window.rows.extend(group); } window}
/// Remove `*` / `_` emphasis characters, preserving everything else in order.fn strip_emphasis(s: &str) -> String { s.chars().filter(|&c| c != '*' && c != '_').collect()}
/// Return at most `limit` scalars without splitting UTF-8.fn scalar_prefix(text: &str, limit: usize) -> (String, bool) { let end = text .char_indices() .take(limit) .map(|(index, ch)| index + ch.len_utf8()) .last() .unwrap_or(0); (text[..end].to_owned(), end < text.len())}
/// Convert a word into positive-width display units with attached zero-width marks.fn display_units(word: &str, width: usize) -> Vec<(String, usize)> { let mut units: Vec<(String, usize)> = Vec::new(); for ch in word.chars() { let cells = UnicodeWidthChar::width(ch).unwrap_or(0); if cells == 0 { if let Some((unit, _)) = units.last_mut() { unit.push(ch); } } else if cells > width { units.push(("?".to_owned(), 1)); } else { units.push((ch.to_string(), cells)); } } units}
/// Greedy word-wrap at `width` display cells (never byte-split)./// Overlong words are hard-split. `width == 0` yields nothing.fn wrap(text: &str, width: usize) -> Vec<String> { if width == 0 { return Vec::new(); } let sanitized: String = text .chars() .map(|ch| if ch.is_ascii_control() { ' ' } else { ch }) .collect(); let mut out: Vec<String> = Vec::new(); let mut cur = String::new(); let mut cur_width: usize = 0; for word in sanitized.split(' ') { if word.is_empty() { continue; // collapse runs of spaces; breaks re-emit single gaps } let units = display_units(word, width); if units.is_empty() { continue; } let word_width: usize = units.iter().map(|(_, cells)| cells).sum(); if word_width > width { if !cur.is_empty() { out.push(std::mem::take(&mut cur)); cur_width = 0; } let mut chunk = String::new(); let mut chunk_width = 0; for (unit, cells) in units { if chunk_width + cells > width { out.push(std::mem::take(&mut chunk)); chunk_width = 0; } chunk.push_str(&unit); chunk_width += cells; } if !chunk.is_empty() { out.push(chunk); } } else if cur_width == 0 { for (unit, _) in units { cur.push_str(&unit); } cur_width = word_width; } else if cur_width + 1 + word_width <= width { cur.push(' '); for (unit, _) in units { cur.push_str(&unit); } cur_width += 1 + word_width; } else { out.push(std::mem::take(&mut cur)); for (unit, _) in units { cur.push_str(&unit); } cur_width = word_width; } } if !cur.is_empty() || out.is_empty() { out.push(cur); } out}
#[cfg(test)]mod tests { use super::*;
fn lines(v: &[&str]) -> Vec<String> { v.iter().map(|s| (*s).to_owned()).collect() }
#[test] fn normal_wraps_at_width() { let out = format_log(&lines(&["aaa bbb ccc"]), 7, false); assert_eq!(out, vec!["aaa bbb".to_owned(), "ccc".to_owned()]); for row in &out { assert!(row.chars().count() <= 7); } }
#[test] fn dyslexia_wraps_narrower_than_requested() { let long = "word ".repeat(30); let out = format_log(&lines(&[long.as_str()]), 80, true); assert!( out.iter() .filter(|l| !l.is_empty()) .all(|l| l.chars().count() <= DYSLEXIA_MAX_WIDTH), "dyslexia caps at min(w, 50): {out:?}" ); assert_eq!(DYSLEXIA_MAX_WIDTH, 50); }
#[test] fn dyslexia_inserts_blank_separators() { let out = format_log(&lines(&["first entry", "second entry"]), 40, true); assert_eq!( out, vec![ "first entry".to_owned(), String::new(), "second entry".to_owned() ] ); }
#[test] fn normal_has_no_blank_separators() { let out = format_log(&lines(&["first entry", "second entry"]), 40, false); assert_eq!( out, vec!["first entry".to_owned(), "second entry".to_owned()] ); }
#[test] fn emphasis_stripped_in_dyslexia() { let out = format_log(&lines(&["*danger* _do not_ drink"]), 40, true); let joined = out.join("\n"); assert!(!joined.contains('*'), "no italics: {joined:?}"); assert!(!joined.contains('_'), "no italics: {joined:?}"); assert!(joined.contains("danger")); assert!(joined.contains("do not")); }
#[test] fn emphasis_kept_in_normal_mode() { let out = format_log(&lines(&["*danger*"]), 40, false); assert_eq!(out, vec!["*danger*".to_owned()]); }
#[test] fn empty_input_is_empty() { assert!(format_log(&[], 80, false).is_empty()); assert!(format_log(&[], 80, true).is_empty()); }
#[test] fn zero_width_never_panics() { assert!(format_log(&lines(&["something"]), 0, false).is_empty()); assert!(format_log(&lines(&["something"]), 0, true).is_empty()); }
#[test] fn meaning_preserved_after_strip_and_wrap() { // Every non-emphasis char survives in order: char-stream equality // modulo whitespace re-flow (wrapping only moves spaces). let entries = lines(&["*Copper* wire _hums_ faintly by the dark grate"]); let out = format_log(&entries, 12, true); let stripped: String = strip_emphasis(&entries[0]); let no_space = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>(); let got: String = no_space(&out.join("")); assert_eq!(got, no_space(&stripped), "chars preserved: {out:?}"); }
#[test] fn overlong_word_hard_splits() { let out = format_log(&lines(&["abcdefghij"]), 4, false); assert_eq!( out, vec!["abcd".to_owned(), "efgh".to_owned(), "ij".to_owned()] ); }
#[test] fn unicode_is_split_only_on_scalar_boundaries() { let out = format_log(&lines(&["火竜🦀é"]), 2, false); assert_eq!( out, vec![ "火".to_owned(), "竜".to_owned(), "🦀".to_owned(), "é".to_owned() ] ); assert_eq!(out.concat(), "火竜🦀é"); }
#[test] fn unicode_emphasis_strip_preserves_non_markup() { let out = format_log(&lines(&["*火竜* _café_ 🦀"]), 4, true); let joined = out.join(""); assert_eq!(joined.replace(' ', ""), "火竜café🦀"); }
#[test] fn blank_entries_still_receive_dyslexia_spacing() { let out = format_log(&lines(&["", "warning"]), 20, true); assert_eq!( out, vec![String::new(), String::new(), "warning".to_owned()] ); }
#[test] fn log_wrap_preserves_wide_and_combining_text() { use unicode_width::UnicodeWidthStr;
let out = format_log(&lines(&["火a e\u{301}竜"]), 3, false); assert_eq!(out, vec!["火a".to_owned(), "e\u{301}竜".to_owned()]); assert!(out.iter().all(|row| row.width() <= 3)); assert_eq!(out.concat().replace(' ', ""), "火ae\u{301}竜");
let narrow = format_log(&lines(&["火"]), 1, false); assert_eq!(narrow, vec!["?".to_owned()]); }
#[test] fn controls_are_spaces_and_utf8_is_never_split() { let out = format_log(&lines(&["ab\u{7}火\u{7f}café"]), 4, false); assert_eq!( out, vec!["ab".to_owned(), "火".to_owned(), "café".to_owned()] ); assert!(out.iter().all(|row| !row.chars().any(|ch| ch.is_control())));
let combining = format_log(&lines(&["\u{301}a\u{301}b"]), 1, false); assert_eq!(combining, vec!["a\u{301}".to_owned(), "b".to_owned()]); }
#[test] fn recent_window_retains_newest_prefixes_in_chronological_order() { let input = lines(&["old", "middle wraps here", "new wraps"]); let window = recent_log_window(&input, 6, 3); assert_eq!( window, RecentLogWindow { rows: vec!["middle".to_owned(), "new".to_owned(), "wraps".to_owned()], omitted_entries: 1, clipped: true, } ); }
#[test] fn recent_window_reports_omission_and_clipping() { let input: Vec<String> = (0..52).map(|i| format!("entry-{i}")).collect(); let window = recent_log_window(&input, 80, 50); assert_eq!(window.rows.len(), 50); assert_eq!(window.rows.first().map(String::as_str), Some("entry-2")); assert_eq!(window.rows.last().map(String::as_str), Some("entry-51")); assert_eq!(window.omitted_entries, 2); assert!(window.clipped);
let long = "é".repeat(513); let capped = recent_log_window(&[long], usize::MAX, usize::MAX); assert_eq!(capped.rows.len(), 1); assert_eq!(capped.rows[0].chars().count(), RECENT_ENTRY_SCALAR_LIMIT); assert!(capped.clipped); assert_eq!(capped.omitted_entries, 0); }
#[test] fn recent_window_empty_tiny_and_large_requests_are_bounded() { let exact = recent_log_window(&lines(&["older", "newer"]), 5, 2); assert_eq!(exact.rows, lines(&["older", "newer"])); assert_eq!(exact.omitted_entries, 0); assert!(!exact.clipped);
assert_eq!(recent_log_window(&[], 10, 10), RecentLogWindow::default()); for window in [ recent_log_window(&lines(&["one"]), 0, 1), recent_log_window(&lines(&["one"]), 1, 0), ] { assert!(window.rows.is_empty()); assert_eq!(window.omitted_entries, 1); assert!(window.clipped); }
let huge: Vec<String> = (0..1_000).map(|_| "x".repeat(1_000)).collect(); let bounded = recent_log_window(&huge, usize::MAX, usize::MAX); assert_eq!(bounded.rows.len(), RECENT_ENTRY_LIMIT); assert!(bounded .rows .iter() .all(|row| row.chars().count() == RECENT_ENTRY_SCALAR_LIMIT)); assert_eq!(bounded.omitted_entries, 950); assert!(bounded.clipped); }}