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.
39 kB · 1048 lines
Rust
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049//! Bounded, presentation-only inventory browser layout and rendering.//!//! The parent owns modal state, input routing, terminal clearing, and redraws.//! This module only projects a caller-supplied snapshot into plain, fitted rows.//! Wrapping and fitting use the crate's existing scalar/cell-width model; this is//! not a claim of full grapheme or terminal certification.
use crate::logfmt::format_log;use crate::look::Tooltip;use crate::text::fit_terminal_cells;use unicode_width::UnicodeWidthChar;
const MAX_WIDTH: usize = 512;const MAX_HEIGHT: usize = 256;const ITEM_SLOTS: usize = 6;const DETAIL_CHROME_ROWS: usize = 4;const TEXT_BUDGET_BYTES: usize = 16 * 1024;const BINDING_BUDGET_BYTES: usize = 64;const CLIPPED_NOTICE: &str = "[Text clipped to the inventory display budget.]";
struct BoundedText { text: String, consumed: usize, clipped: bool,}
/// Which inventory-browser presentation is active.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum BrowserMode { List, Detail,}
/// Caller-projected service facts; eligibility and knowledge remain runtime-owned.#[derive(Clone, Debug, PartialEq, Eq)]pub struct TinkerBrowserContent { pub fee_cu: u32, pub wallet_cu: u32, pub eligible_total: usize, pub selected_item_id: Option<u64>, pub selected_known: bool, pub selected_eligible: bool,}
/// Presentation-only inventory data supplied by the caller.#[derive(Clone, Debug, PartialEq, Eq)]pub struct BrowserContent { pub mode: BrowserMode, pub detail_returns_to_list: bool, pub total: usize, pub selected: Option<usize>, /// The wielded item slot on this list page, if it is a real visible item. pub wielded_slot: Option<usize>, /// Whether the actual selected detail is currently wielded. pub selected_wielded: bool, /// Whether the actual selected detail may request Wield. pub can_wield_selected: bool, pub has_before: bool, pub has_after: bool, pub titles: Vec<String>, pub detail: Option<Tooltip>, pub detail_offset: usize, pub clipped: bool, pub tinker: Option<TinkerBrowserContent>,}
/// Already-loaded, player-facing key labels supplied by the caller.#[derive(Clone, Debug, PartialEq, Eq)]pub struct BrowserBindings { pub up: String, pub down: String, pub left: String, pub right: String, pub inspect: String, pub back: String, pub close: String, pub quit: String, /// Loaded Confirm label, supplied only for an eligible detail selection. pub wield: Option<String>, /// Loaded Confirm label for the separate identification confirmation. pub identify: Option<String>, pub picks: [String; ITEM_SLOTS],}
/// Bounded dimensions and the single retained detail-wrap result.#[derive(Clone, Debug, PartialEq, Eq)]pub struct BrowserLayout { pub width: usize, pub height: usize, pub body_height: usize, pub max_detail_offset: usize, pub detail_rows: Vec<String>, pub viewport_capped: bool,}
/// Calculate bounded browser dimensions and wrap detail text exactly once.#[must_use]pub fn layout_browser(content: &BrowserContent, width: usize, height: usize) -> BrowserLayout { let viewport_capped = width > MAX_WIDTH || height > MAX_HEIGHT; let width = width.min(MAX_WIDTH); let height = height.min(MAX_HEIGHT); let body_height = height.saturating_sub(if content.tinker.is_some() { 9 } else { DETAIL_CHROME_ROWS });
let (bounded_lines, projection_clipped) = bounded_detail(content.detail.as_ref()); let mut detail_rows = Vec::new(); if width > 0 { for line in bounded_lines { detail_rows.extend(format_log(&[line], width, false)); } if content.clipped || projection_clipped { detail_rows.extend(format_log(&[CLIPPED_NOTICE.to_owned()], width, false)); } } let max_detail_offset = detail_rows.len().saturating_sub(body_height);
BrowserLayout { width, height, body_height, max_detail_offset, detail_rows, viewport_capped, }}
/// Render exactly `layout.height` plain rows, each fitted to `layout.width` cells.#[must_use]pub fn render_browser( content: &BrowserContent, bindings: &BrowserBindings, layout: &BrowserLayout,) -> Vec<String> { let width = layout.width.min(MAX_WIDTH); let height = layout.height.min(MAX_HEIGHT); let viewport_capped = layout.viewport_capped || layout.width > MAX_WIDTH || layout.height > MAX_HEIGHT; let mut rows = if let Some(tinker) = &content.tinker { render_tinker(content, tinker, bindings, layout, viewport_capped) } else { match content.mode { BrowserMode::List => render_list(content, bindings, width, viewport_capped), BrowserMode::Detail => render_detail(content, bindings, layout, viewport_capped), } }; rows.truncate(height); while rows.len() < height { rows.push(String::new()); } rows.into_iter() .map(|row| fit_terminal_cells(&row, width)) .collect()}
fn render_tinker( content: &BrowserContent, tinker: &TinkerBrowserContent, bindings: &BrowserBindings, layout: &BrowserLayout, viewport_capped: bool,) -> Vec<String> { let selected_id = tinker.selected_item_id.filter(|id| *id != 0); let knowledge = if tinker.selected_known { "Known" } else { "Unidentified" }; let eligibility = if tinker.selected_known || !tinker.selected_eligible { "ineligible" } else { "eligible" }; let mut rows = vec![ header("Tinker: identification", viewport_capped), format!("Fee: {}cu; Wallet: {}cu", tinker.fee_cu, tinker.wallet_cu), match selected_id { Some(id) => format!("Item ID: {id}; {knowledge}; {eligibility}"), None => "No item selected".to_owned(), }, if content.total == 0 { "Inventory empty; no eligible items".to_owned() } else if tinker.eligible_total == 0 { "No eligible items".to_owned() } else { format!( "{} eligible items; {} carried", tinker.eligible_total, content.total ) }, ]; let affordable = tinker.wallet_cu >= tinker.fee_cu; let action = if content.mode == BrowserMode::List { format!("Inspect: {}", bounded_label(&bindings.inspect)) } else if selected_id.is_some() && has_actual_selected_detail(content) && tinker.selected_eligible && !tinker.selected_known { if !affordable { "Cannot identify: insufficient copper".to_owned() } else { bindings.identify.as_deref().map_or_else( || "Identification unavailable".to_owned(), |label| format!("Identify: {}", bounded_label(label)), ) } } else { "Cannot identify this selection".to_owned() }; rows.push(action); rows.push(format!("Quit: {}", bounded_label(&bindings.quit))); if content.mode == BrowserMode::List { let (titles, clipped) = bounded_titles(&content.titles); for slot in 0..ITEM_SLOTS { let marker = if content.selected == Some(slot) && slot < titles.len() { '>' } else { ' ' }; rows.push(format!( "{marker} [{}] {}", bounded_label(&bindings.picks[slot]), titles.get(slot).map(String::as_str).unwrap_or("(empty)") )); } rows.push(format!( "Select: {}/{}; Page: {}/{}", bounded_label(&bindings.up), bounded_label(&bindings.down), bounded_label(&bindings.left), bounded_label(&bindings.right) )); rows.push(format!( "Before: {}; After: {}{}", content.has_before, content.has_after, if clipped || content.clipped || content.titles.len() > ITEM_SLOTS { "; Text/list clipped" } else { "" } )); } else { let capacity = layout .body_height .min(layout.height.min(MAX_HEIGHT).saturating_sub(9)); let offset = content .detail_offset .min(layout.detail_rows.len().saturating_sub(capacity)); let end = offset .saturating_add(capacity) .min(layout.detail_rows.len()); let (body, clipped) = bounded_visible_rows(&layout.detail_rows[offset..end]); rows.extend(body); rows.resize(6 + capacity, String::new()); rows.push(format!( "Rows {}-{} of {}{}", if layout.detail_rows.is_empty() { 0 } else { offset + 1 }, end, layout.detail_rows.len(), if clipped || content.clipped { "; Text clipped" } else { "" } )); rows.push(format!( "Scroll: {}/{}; Page: {}/{}", bounded_label(&bindings.up), bounded_label(&bindings.down), bounded_label(&bindings.left), bounded_label(&bindings.right) )); rows.push(format!( "Back: {}; Close: {}", bounded_label(&bindings.back), bounded_label(&bindings.close) )); } rows}
fn render_list( content: &BrowserContent, bindings: &BrowserBindings, width: usize, viewport_capped: bool,) -> Vec<String> { let mut rows = vec![header("Inventory", viewport_capped)]; let visible = content.titles.len().min(ITEM_SLOTS); let selected = content.selected.filter(|selected| *selected < visible); let (titles, source_clipped) = bounded_titles(&content.titles[..visible]); let picks: [String; ITEM_SLOTS] = std::array::from_fn(|slot| bounded_label(&bindings.picks[slot])); let width_clipped = titles.iter().enumerate().any(|(slot, title)| { let marker = if selected == Some(slot) { '>' } else { ' ' }; let wielded = if is_wielded_visible_slot(content, slot, visible) { "[wielded] " } else { "" }; let prefix = format!("{marker} [{}] {wielded}", picks[slot]); text_cells(title) > width.saturating_sub(text_cells(&prefix)) }); let extra_titles = content.titles.len() > ITEM_SLOTS;
let page = match (content.has_before, content.has_after) { (false, false) => "only page", (false, true) => "first page", (true, false) => "last page", (true, true) => "middle page", }; let mut status = format!("{} carried; {} shown; {page}", content.total, visible); if content.total == 0 { status.push_str("; inventory empty"); } if content.selected.is_some() && selected.is_none() { status.push_str("; selection unavailable"); } if content.clipped || source_clipped || width_clipped || extra_titles { status.insert_str(0, "Text/list clipped; "); } rows.push(status);
for (slot, pick) in picks.iter().enumerate() { let marker = if selected == Some(slot) { '>' } else { ' ' }; let wielded = if is_wielded_visible_slot(content, slot, visible) { "[wielded] " } else { "" }; let title = titles.get(slot).map(String::as_str).unwrap_or("(empty)"); rows.push(format!("{marker} [{pick}] {wielded}{title}")); }
let up = bounded_label(&bindings.up); let down = bounded_label(&bindings.down); let left = bounded_label(&bindings.left); let right = bounded_label(&bindings.right); let inspect = bounded_label(&bindings.inspect); let back = bounded_label(&bindings.back); let close = bounded_label(&bindings.close); let quit = bounded_label(&bindings.quit); rows.push(format!("Navigate: {up}/{down} select; {left}/{right} page")); rows.push(format!("Inspect: {inspect}/{back}; Close: {close}/{quit}")); rows}
fn is_wielded_visible_slot(content: &BrowserContent, slot: usize, visible: usize) -> bool { content.wielded_slot == Some(slot) && slot < visible && matches!(content.titles.get(slot), Some(title) if !title.is_empty())}
fn has_actual_selected_detail(content: &BrowserContent) -> bool { content.detail.is_some() && content.selected.is_some_and(|slot| { slot < ITEM_SLOTS && matches!(content.titles.get(slot), Some(title) if !title.is_empty()) })}
fn render_detail( content: &BrowserContent, bindings: &BrowserBindings, layout: &BrowserLayout, viewport_capped: bool,) -> Vec<String> { let mut rows = vec![header("Inventory detail", viewport_capped)]; let body_height = layout.body_height.min( layout .height .min(MAX_HEIGHT) .saturating_sub(DETAIL_CHROME_ROWS), ); let max_offset = layout.detail_rows.len().saturating_sub(body_height); let offset = content.detail_offset.min(max_offset); let body_end = offset .saturating_add(body_height) .min(layout.detail_rows.len()); let (body_rows, forged_rows_clipped) = bounded_visible_rows(&layout.detail_rows[offset..body_end]);
let mut status = format!("{} carried", content.total); let actual_detail = has_actual_selected_detail(content); if !actual_detail { status.push_str("; no item selected"); } else if content.selected_wielded { status.push_str("; wielded"); } if content.clipped || forged_rows_clipped { status.push_str("; source text clipped"); } rows.push(status); rows.extend(body_rows); while rows.len() < 2 + body_height { rows.push(String::new()); }
let end = offset .saturating_add(body_height) .min(layout.detail_rows.len()); rows.push(format!( "Rows {}-{} of {}", if layout.detail_rows.is_empty() { 0 } else { offset + 1 }, end, layout.detail_rows.len() ));
let up = bounded_label(&bindings.up); let down = bounded_label(&bindings.down); let left = bounded_label(&bindings.left); let right = bounded_label(&bindings.right); let back = bounded_label(&bindings.back); let close = bounded_label(&bindings.close); let quit = bounded_label(&bindings.quit); let exit_hint = if content.detail_returns_to_list { format!("Back to list: {back}/{quit}; Close: {close}") } else { format!("Close: {back}/{quit}/{close}") }; let wield_hint = if actual_detail && content.can_wield_selected && !content.selected_wielded { bindings .wield .as_deref() .map(|label| format!("; Wield: {}", bounded_label(label))) .unwrap_or_default() } else { String::new() }; rows.push(format!( "Scroll: {up}/{down}, {left}/{right} page; {exit_hint}{wield_hint}" )); rows}
fn header(title: &str, viewport_capped: bool) -> String { if viewport_capped { format!("{title} [viewport capped]") } else { title.to_owned() }}
fn bounded_titles(titles: &[String]) -> (Vec<String>, bool) { let mut remaining = TEXT_BUDGET_BYTES; let mut clipped = false; let mut bounded = Vec::with_capacity(titles.len().min(ITEM_SLOTS)); for title in titles.iter().take(ITEM_SLOTS) { let bounded_title = bounded_text(title, remaining); remaining = remaining.saturating_sub(bounded_title.consumed); clipped |= bounded_title.clipped; bounded.push(bounded_title.text); } (bounded, clipped)}
fn bounded_visible_rows(rows: &[String]) -> (Vec<String>, bool) { let mut remaining = TEXT_BUDGET_BYTES; let mut clipped = false; let mut bounded = Vec::with_capacity(rows.len()); for row in rows { if remaining == 0 { clipped = true; break; } let bounded_row = bounded_text(row, remaining); remaining = remaining.saturating_sub(bounded_row.consumed); clipped |= bounded_row.clipped; bounded.push(bounded_row.text); } if bounded.len() < rows.len() { clipped = true; bounded.resize_with(rows.len(), String::new); } (bounded, clipped)}
fn bounded_detail(detail: Option<&Tooltip>) -> (Vec<String>, bool) { let Some(detail) = detail else { return (Vec::new(), false); }; let mut remaining = TEXT_BUDGET_BYTES; let mut clipped = false; let source_count = detail.lines.len().saturating_add(1); let mut lines = Vec::with_capacity(source_count.min(TEXT_BUDGET_BYTES));
for source in std::iter::once(&detail.title).chain(detail.lines.iter()) { if remaining == 0 { clipped = true; break; } // A logical separator is part of the combined title/body text budget, // including for empty source lines. remaining -= 1; let bounded_source = bounded_text(source, remaining); remaining = remaining.saturating_sub(bounded_source.consumed); clipped |= bounded_source.clipped; lines.push(bounded_source.text); } clipped |= lines.len() < source_count; (lines, clipped)}
fn bounded_label(label: &str) -> String { bounded_text(label, BINDING_BUDGET_BYTES).text}
fn bounded_text(text: &str, byte_budget: usize) -> BoundedText { let mut end = text.len().min(byte_budget); while !text.is_char_boundary(end) { end -= 1; } BoundedText { text: text[..end] .chars() .map(|ch| if ch.is_control() { ' ' } else { ch }) .collect(), consumed: end, clipped: end < text.len(), }}
fn text_cells(text: &str) -> usize { text.chars() .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0)) .sum()}
#[cfg(test)]mod tests { use super::*; use crate::look::item_tooltip; use unicode_width::UnicodeWidthStr;
fn bindings() -> BrowserBindings { BrowserBindings { up: "Up".to_owned(), down: "Down".to_owned(), left: "Left".to_owned(), right: "Right".to_owned(), inspect: "Enter".to_owned(), back: "L".to_owned(), close: "I".to_owned(), quit: "Q".to_owned(), wield: None, identify: None, picks: std::array::from_fn(|index| (index + 1).to_string()), } }
fn list_content() -> BrowserContent { BrowserContent { mode: BrowserMode::List, detail_returns_to_list: true, total: 8, selected: Some(1), wielded_slot: None, selected_wielded: false, can_wield_selected: false, has_before: false, has_after: true, titles: vec!["Lamp oil".to_owned(), "Chalk".to_owned()], detail: None, detail_offset: 0, clipped: false, tinker: None, } }
fn assert_fitted(rows: &[String], width: usize, height: usize) { assert_eq!(rows.len(), height); for row in rows { assert_eq!(UnicodeWidthStr::width(row.as_str()), width, "{row:?}"); } }
#[test] fn tinker_browser_bounded_render() { let mut content = list_content(); content.titles = (0..8).map(|i| format!("carried item {i}")).collect(); content.selected = Some(1); content.tinker = Some(TinkerBrowserContent { fee_cu: 15, wallet_cu: 25, eligible_total: 2, selected_item_id: Some(u64::MAX), selected_known: false, selected_eligible: true, }); content.can_wield_selected = true; content.selected_wielded = true; content.wielded_slot = Some(1); let mut keys = bindings(); keys.inspect = "C".repeat(64); keys.identify = Some(keys.inspect.clone()); keys.quit = "Q".repeat(64); keys.wield = Some("FORBIDDEN_WIELD".to_owned()); let rows = render_browser(&content, &keys, &layout_browser(&content, 80, 16)); assert_fitted(&rows, 80, 16); let visible = rows.join("\n"); for expected in [ "Fee: 15cu; Wallet: 25cu", "18446744073709551615", "Unidentified; eligible", "carried item 5", &keys.inspect, &keys.quit, ] { assert!(visible.contains(expected), "missing {expected}"); } assert!(!visible.contains("carried item 6")); assert!(!visible.contains("carried item 7")); assert!(!visible.contains("wield")); content.mode = BrowserMode::Detail; content.detail = Some(Tooltip { title: "Exact selected name".to_owned(), lines: vec!["body ".repeat(1000)], }); let layout = layout_browser(&content, 80, 16); assert_eq!(layout.body_height, 7); let rows = render_browser(&content, &keys, &layout); assert_fitted(&rows, 80, 16); let visible = rows.join("\n"); assert!(visible.contains("Exact selected name")); assert!(visible.contains(&format!("Identify: {}", keys.inspect))); assert!(!visible.contains("Wield")); content.tinker.as_mut().unwrap().wallet_cu = 14; let visible = render_browser(&content, &keys, &layout).join("\n"); assert!(visible.contains("Unidentified; eligible")); assert!(visible.contains("insufficient copper")); assert!(!visible.contains("Identify:")); let tinker = content.tinker.as_mut().unwrap(); // Ineligibility wins over affordability for a known or empty selection. tinker.wallet_cu = 0; tinker.selected_known = true; tinker.eligible_total = 0; let visible = render_browser(&content, &keys, &layout).join("\n"); assert!(visible.contains("Known; ineligible")); assert!(!visible.contains("insufficient copper")); assert!(visible.contains("Cannot identify this selection")); assert!(visible.contains("No eligible items")); assert!(!visible.contains("Identify:")); content.total = 0; content.titles.clear(); content.selected = None; content.tinker.as_mut().unwrap().selected_item_id = None; let visible = render_browser(&content, &keys, &layout).join("\n"); assert!(visible.contains("Inventory empty; no eligible items")); assert!(visible.contains("No item selected")); assert!(!visible.contains("insufficient copper")); assert!(visible.contains("Cannot identify this selection")); assert!(visible.contains(&keys.quit)); for (width, height) in [(0, 0), (1, 1), (80, 24), (usize::MAX, usize::MAX)] { let layout = layout_browser(&content, width, height); assert_fitted( &render_browser(&content, &keys, &layout), width.min(MAX_WIDTH), height.min(MAX_HEIGHT), ); } }
#[test] fn inventory_list_and_detail_fit_80x24_and_clear_stale_rows() { // Entry supplies the actual 80x16 browser viewport below its eight chrome rows. let content = list_content(); let layout = layout_browser(&content, 80, 16); let rows = render_browser(&content, &bindings(), &layout); assert_fitted(&rows, 80, 16); assert!(rows[0].starts_with("Inventory")); assert!(rows[1].contains("8 carried; 2 shown; first page")); assert!(rows[2].contains("[1] Lamp oil")); assert!(rows[3].contains("> [2] Chalk")); assert!(rows[4].contains("[3] (empty)")); assert!(rows[8].contains("Navigate: Up/Down select; Left/Right page")); assert!(rows[9].contains("Inspect: Enter/L; Close: I/Q")); assert!(rows[10..].iter().all(|row| row.trim().is_empty()));
let mut detail = content; detail.mode = BrowserMode::Detail; detail.detail = Some(item_tooltip("Lamp oil", true, &["burn for light"], "GLASS")); let layout = layout_browser(&detail, 80, 16); let rows = render_browser(&detail, &bindings(), &layout); assert_fitted(&rows, 80, 16); assert!(rows[0].starts_with("Inventory detail")); assert_eq!(rows[2].trim(), "Lamp oil"); assert!(rows.iter().any(|row| row.contains("Made of GLASS."))); assert!(rows.iter().any(|row| row.contains("Rows 1-4 of 4"))); assert!( rows[6..14].iter().all(|row| row.trim().is_empty()), "unused detail body rows clear stale text" ); assert!(rows[15].contains("Scroll: Up/Down")); }
#[test] fn inventory_tooltip_scroll_reaches_all_retained_rows() { let mut content = list_content(); content.mode = BrowserMode::Detail; content.detail = Some(Tooltip { title: "The retained title".to_owned(), lines: vec![(1..=20) .map(|n| format!("word{n}")) .collect::<Vec<_>>() .join(" ")], }); let layout = layout_browser(&content, 12, 8); assert_eq!(layout.body_height, 4); assert!(layout.detail_rows.len() > layout.body_height); assert_eq!( layout.max_detail_offset, layout.detail_rows.len() - layout.body_height ); assert_eq!(layout.detail_rows[0], "The retained"); assert_eq!(layout.detail_rows[1], "title");
let mut seen = Vec::new(); for offset in 0..=layout.max_detail_offset { content.detail_offset = offset; let rows = render_browser(&content, &bindings(), &layout); seen.extend( rows[2..2 + layout.body_height] .iter() .map(|row| row.trim().to_owned()), ); } for retained in &layout.detail_rows { assert!( seen.contains(retained), "retained row was unreachable: {retained:?}" ); }
content.detail_offset = usize::MAX; let rows = render_browser(&content, &bindings(), &layout); let last = layout.detail_rows.last().expect("retained rows"); assert!(rows[2..6].iter().any(|row| row.trim() == last)); }
#[test] fn inventory_render_hides_raw_ids_and_unknown_uses() { let secret_use = "SECRET_USE_DO_NOT_SHOW"; let raw_id = "18446744073709551614"; let mut content = list_content(); content.mode = BrowserMode::Detail; content.titles = vec!["Phial of green".to_owned()]; content.detail = Some(item_tooltip( "Phial of green", false, &[secret_use, raw_id], "GLASS", )); let layout = layout_browser(&content, 80, 16); let visible = render_browser(&content, &bindings(), &layout).join("\n"); assert!(visible.contains("Phial of green (unidentified)")); assert!(visible.contains("you haven't learned this yet")); assert!(visible.contains("Made of GLASS.")); assert!(!visible.contains(secret_use)); assert!(!visible.contains(raw_id)); }
#[test] fn inventory_hints_follow_loaded_keymap() { let custom = BrowserBindings { up: "K".to_owned(), down: "J".to_owned(), left: "H".to_owned(), right: "N".to_owned(), inspect: "V".to_owned(), back: "Q".to_owned(), close: "B".to_owned(), quit: "Z".to_owned(), wield: Some("V".to_owned()), identify: None, picks: std::array::from_fn(|index| format!("F{}", index + 1)), }; let content = list_content(); let layout = layout_browser(&content, 80, 16); let rows = render_browser(&content, &custom, &layout); assert!(rows[2].contains("[F1] Lamp oil")); assert!(rows[3].contains("[F2] Chalk")); assert!(rows[8].contains("Navigate: K/J select; H/N page")); assert!(rows[9].contains("Inspect: V/Q; Close: B/Z"));
let mut detail = content; detail.mode = BrowserMode::Detail; detail.detail = Some(item_tooltip("Chalk", true, &["mark a route"], "STONE")); let layout = layout_browser(&detail, 80, 16); let visible = render_browser(&detail, &custom, &layout).join("\n"); assert!(visible.contains("Scroll: K/J, H/N page; Back to list: Q/Z; Close: B")); assert!(!visible.contains("List: V"));
detail.detail_returns_to_list = false; let layout = layout_browser(&detail, 80, 16); let visible = render_browser(&detail, &custom, &layout).join("\n"); assert!(visible.contains("Scroll: K/J, H/N page; Close: Q/Z/B")); assert!(!visible.contains("List:") && !visible.contains("Back to list:")); assert!(!visible.contains("V")); }
#[test] fn inventory_layout_clamps_oversized_and_handles_zero_tiny_dimensions() { let mut content = list_content(); content.mode = BrowserMode::Detail; content.clipped = true; content.detail = Some(Tooltip { title: "火".repeat(TEXT_BUDGET_BYTES), lines: vec!["body".to_owned()], });
let zero = layout_browser(&content, 0, 0); assert_eq!((zero.width, zero.height, zero.body_height), (0, 0, 0)); assert!(zero.detail_rows.is_empty()); assert!(render_browser(&content, &bindings(), &zero).is_empty());
let tiny = layout_browser(&content, 1, 1); let tiny_rows = render_browser(&content, &bindings(), &tiny); assert_fitted(&tiny_rows, 1, 1); assert_eq!(tiny.body_height, 0);
let capped = layout_browser(&content, 513, 257); assert_eq!((capped.width, capped.height), (512, 256)); assert_eq!(capped.body_height, 252); assert!(capped.viewport_capped); assert!(capped .detail_rows .iter() .any(|row| row.contains("Text clipped"))); assert!(capped.detail_rows.iter().map(String::len).sum::<usize>() < 20_000); let rows = render_browser(&content, &bindings(), &capped); assert_fitted(&rows, 512, 256); assert!(rows[0].contains("viewport capped"));
let mut malformed = list_content(); malformed.selected = Some(99); malformed.titles = (0..8).map(|index| format!("title {index}")).collect(); let layout = layout_browser(&malformed, 80, 16); let visible = render_browser(&malformed, &bindings(), &layout).join("\n"); assert!(visible.contains("selection unavailable")); assert!(visible.contains("Text/list clipped")); assert!(!visible.contains("title 6")); assert!(!visible.contains("title 7")); assert!(!visible.lines().any(|row| row.starts_with('>')));
let mut hostile_bindings = bindings(); hostile_bindings.up = "U".repeat(1_000_000); let fabricated = BrowserLayout { width: usize::MAX, height: usize::MAX, body_height: usize::MAX, max_detail_offset: usize::MAX, detail_rows: vec!["row".to_owned()], viewport_capped: false, }; let rows = render_browser(&malformed, &hostile_bindings, &fabricated); assert_fitted(&rows, 512, 256); assert!(rows[0].contains("viewport capped")); assert!(rows[8].contains(&"U".repeat(BINDING_BUDGET_BYTES))); assert!(!rows[8].contains(&"U".repeat(BINDING_BUDGET_BYTES + 1))); }
#[test] fn inventory_forged_detail_rows_bound_zero_width_and_aggregate_source() { let mut content = list_content(); content.mode = BrowserMode::Detail; content.detail = Some(Tooltip::default()); let combining = "\u{301}".repeat(1_000_000); let forged = BrowserLayout { width: MAX_WIDTH, height: 8, body_height: 4, max_detail_offset: 0, detail_rows: vec![combining.clone(), combining], viewport_capped: false, }; let rows = render_browser(&content, &bindings(), &forged); assert_fitted(&rows, MAX_WIDTH, 8); assert!(rows[1].starts_with("8 carried; source text clipped")); let non_padding: usize = rows.iter().map(|row| row.trim_end_matches(' ').len()).sum(); assert!(non_padding < TEXT_BUDGET_BYTES + 1_024, "{non_padding}");
let forged = BrowserLayout { width: MAX_WIDTH, height: MAX_HEIGHT, body_height: MAX_HEIGHT - DETAIL_CHROME_ROWS, max_detail_offset: 0, detail_rows: (0..MAX_HEIGHT - DETAIL_CHROME_ROWS) .map(|_| "x".repeat(1_000)) .collect(), viewport_capped: false, }; let rows = render_browser(&content, &bindings(), &forged); assert!(rows[1].starts_with("8 carried; source text clipped")); let body_payload: usize = rows[2..MAX_HEIGHT - 2] .iter() .map(|row| row.trim_end_matches(' ').len()) .sum(); assert!(body_payload <= TEXT_BUDGET_BYTES); assert!( rows[20].trim().is_empty(), "aggregate budget exhausts before row 20" ); }
#[test] fn inventory_list_discloses_width_only_title_clipping_after_actual_prefix() { let mut content = list_content(); content.titles = vec!["ordinary title wider than its available row".to_owned()]; content.selected = Some(0); let mut custom = bindings(); custom.picks[0] = "LONG-PICK".to_owned(); let layout = layout_browser(&content, 24, 16); let rows = render_browser(&content, &custom, &layout); assert!(rows[1].starts_with("Text/list clipped"), "{:?}", rows[1]); assert!(rows[2].starts_with("> [LONG-PICK] ordinary")); assert!(!rows[2].contains("available row")); }
#[test] fn inventory_sanitizes_c1_controls_on_all_public_text_surfaces() { let controls = "\u{85}\u{9b}"; let mut content = list_content(); content.titles = vec![format!("title{controls}safe")]; let mut custom = bindings(); custom.picks[0] = format!("P{controls}1"); custom.up = format!("U{controls}P"); let layout = layout_browser(&content, 80, 16); let visible = render_browser(&content, &custom, &layout).join("\n"); assert!(!visible.contains('\u{85}')); assert!(!visible.contains('\u{9b}')); assert!(visible.contains("title safe")); assert!(visible.contains("[P 1]")); assert!(visible.contains("Navigate: U P/Down"));
content.mode = BrowserMode::Detail; content.detail = Some(Tooltip { title: format!("detail{controls}title"), lines: vec![format!("body{controls}line")], }); let layout = layout_browser(&content, 80, 16); assert!(layout .detail_rows .iter() .all(|row| !row.contains(['\u{85}', '\u{9b}']))); assert!(layout .detail_rows .iter() .any(|row| row.contains("detail title")));
let forged = BrowserLayout { width: 80, height: 8, body_height: 4, max_detail_offset: 0, detail_rows: vec![format!("forged{controls}row")], viewport_capped: false, }; let visible = render_browser(&content, &custom, &forged).join("\n"); assert!(!visible.contains('\u{85}')); assert!(!visible.contains('\u{9b}')); assert!(visible.contains("forged row")); }
#[test] fn wielded_inventory_marker_is_bounded_and_tracks_current_slot() { let controls = "\u{85}\u{9b}"; let mut content = list_content(); content.total = ITEM_SLOTS; content.titles = (0..ITEM_SLOTS) .map(|slot| format!("item {slot}{controls}{}", "火".repeat(2_000))) .collect(); content.selected = Some(1); content.wielded_slot = Some(1); let layout = layout_browser(&content, 80, 16); let rows = render_browser(&content, &bindings(), &layout); assert!(rows[3].contains("[wielded] item 1")); assert!(!rows.join("\\n").contains('\u{85}')); assert!(!rows.join("\\n").contains('\u{9b}'));
content.wielded_slot = Some(99); let rows = render_browser(&content, &bindings(), &layout_browser(&content, 80, 16)); assert!(!rows.join("\\n").contains("[wielded]")); content.wielded_slot = Some(6); let rows = render_browser(&content, &bindings(), &layout_browser(&content, 80, 16)); assert!(!rows.join("\\n").contains("[wielded]")); content.titles[1].clear(); content.wielded_slot = Some(1); let rows = render_browser(&content, &bindings(), &layout_browser(&content, 80, 16)); assert!(!rows.join("\\n").contains("[wielded]"));
content.titles[1] = "actual weapon".to_owned(); content.mode = BrowserMode::Detail; content.detail = Some(item_tooltip("actual weapon", true, &[], "STEEL")); content.selected = Some(1); content.selected_wielded = true; content.can_wield_selected = true; let mut detail_bindings = bindings(); detail_bindings.wield = Some("Confirm".to_owned()); let visible = render_browser( &content, &detail_bindings, &layout_browser(&content, 80, 16), ) .join("\\n"); assert!(visible.contains("; wielded")); assert!(!visible.contains("Wield: Confirm"));
content.selected_wielded = false; let visible = render_browser( &content, &detail_bindings, &layout_browser(&content, 80, 16), ) .join("\\n"); assert!(visible.contains("Wield: Confirm")); assert!(!visible.chars().any(char::is_control));
content.selected = None; let visible = render_browser( &content, &detail_bindings, &layout_browser(&content, 80, 16), ) .join("\\n"); assert!(visible.contains("no item selected")); assert!(!visible.contains("Wield: Confirm"));
let empty = layout_browser(&content, 0, 0); assert!(render_browser(&content, &detail_bindings, &empty).is_empty()); let tiny = layout_browser(&content, 1, 1); assert_fitted(&render_browser(&content, &detail_bindings, &tiny), 1, 1); let extreme = layout_browser(&content, usize::MAX, usize::MAX); assert_eq!((extreme.width, extreme.height), (MAX_WIDTH, MAX_HEIGHT)); assert!(extreme.viewport_capped); }}