From 997814e2f38d001dd6120c7c8afc07cf7047b8c0 Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 17:49:49 +0200 Subject: [PATCH] feat(city): title, configure and files screens Co-Authored-By: Claude Fable 5.1 --- crates/city/src/screens/configure.rs | 315 ++++++++++++++++++++++++- crates/city/src/screens/files.rs | 334 +++++++++++++++++++++++++++ crates/city/src/screens/mod.rs | 4 +- crates/city/src/screens/title.rs | 170 ++++++++++++++ crates/city/src/ui/form.rs | 16 +- 5 files changed, 829 insertions(+), 10 deletions(-) create mode 100644 crates/city/src/screens/files.rs create mode 100644 crates/city/src/screens/title.rs diff --git a/crates/city/src/screens/configure.rs b/crates/city/src/screens/configure.rs index 7150445..b21ee67 100644 --- a/crates/city/src/screens/configure.rs +++ b/crates/city/src/screens/configure.rs @@ -1,27 +1,328 @@ //! The configure screen: a form over `GenParams` with a live preview card. -use crate::app::{AppState, Screen, ScreenCmd}; -use citygen::GenParams; -use crossterm::event::KeyEvent; +use crate::app::{AppState, Screen, ScreenCmd, TransitionKind}; +use crate::render::Rgb; +use crate::screens::generating::Generating; +use crate::screens::title::Title; +use crate::ui::form::{Field, Form, FormView}; +use crate::ui::panel::boxed; +use citygen::rng::hash64; +use citygen::{GenParams, SizePreset, WaterMode}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; + +const BTN_RANDOM: usize = 8; +const BTN_GENERATE: usize = 9; pub struct Configure { - params: GenParams, + pub form: Form, + base: GenParams, opened: u64, + error: Option, +} + +fn size_options(base: SizePreset) -> (Vec, usize) { + let mut opts = vec![ + "small".to_string(), + "medium".to_string(), + "large".to_string(), + ]; + let idx = match base { + SizePreset::Small => 0, + SizePreset::Medium => 1, + SizePreset::Large => 2, + SizePreset::Custom { w, h } => { + opts.push(format!("{w}x{h}")); + 3 + } + }; + (opts, idx) } impl Configure { pub fn new(params: GenParams, tick: u64) -> Self { + let (size_opts, size_idx) = size_options(params.size); + let water_idx = match params.water { + WaterMode::None => 0, + WaterMode::Bay => 1, + WaterMode::River => 2, + }; + let form = Form { + fields: vec![ + Field::Text { + label: "Seed".into(), + value: if params.seed == 0 { + String::new() + } else { + params.seed.to_string() + }, + max: 20, + numeric: true, + }, + Field::Choice { + label: "Size".into(), + options: size_opts, + idx: size_idx, + }, + Field::Number { + label: "Districts".into(), + value: params.districts as i64, + min: 4, + max: 12, + step: 1, + }, + Field::Slider { + label: "Density".into(), + value: params.density, + min: 0.0, + max: 1.0, + step: 0.05, + }, + Field::Slider { + label: "Wealth skew".into(), + value: params.wealth_skew, + min: -1.0, + max: 1.0, + step: 0.1, + }, + Field::Slider { + label: "Decay".into(), + value: params.decay, + min: 0.0, + max: 1.0, + step: 0.05, + }, + Field::Choice { + label: "Water".into(), + options: vec!["none".into(), "bay".into(), "river".into()], + idx: water_idx, + }, + Field::Choice { + label: "Flavor".into(), + options: vec!["default".into()], + idx: 0, + }, + Field::Button { + label: "RANDOM SEED".into(), + }, + Field::Button { + label: "GENERATE".into(), + }, + ], + selected: 0, + }; Configure { - params, + form, + base: params, opened: tick, + error: None, + } + } + + /// The parameters the form currently describes. An empty seed becomes a fresh one. + pub fn params(&self) -> GenParams { + let seed = self + .form + .text("Seed") + .and_then(|t| t.parse::().ok()) + .unwrap_or_else(|| hash64(self.opened, 0xC0FFEE)); + let size = match self.form.choice("Size") { + Some("small") => SizePreset::Small, + Some("medium") => SizePreset::Medium, + Some("large") => SizePreset::Large, + _ => self.base.size, + }; + let water = match self.form.choice("Water") { + Some("none") => WaterMode::None, + Some("river") => WaterMode::River, + _ => WaterMode::Bay, + }; + GenParams { + seed, + size, + districts: self.form.number("Districts").unwrap_or(7) as u8, + density: self.form.slider("Density").unwrap_or(0.6), + wealth_skew: self.form.slider("Wealth skew").unwrap_or(0.0), + decay: self.form.slider("Decay").unwrap_or(0.4), + water, + flavor: self.form.choice("Flavor").unwrap_or("default").to_string(), } } } impl Screen for Configure { - fn update(&mut self, _st: &mut AppState, _keys: &[KeyEvent]) -> ScreenCmd { + fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd { + for k in keys { + if k.code == KeyCode::Esc { + return ScreenCmd::Switch { + to: Box::new(Title::new(st.tick)), + fx: TransitionKind::SlideUp, + }; + } + match self.form.handle(k.code) { + Some(BTN_RANDOM) => { + let seed = hash64(st.tick, 0xC0FFEE) % 1_000_000_007; + self.form.set_text("Seed", seed.to_string()); + self.error = None; + } + Some(BTN_GENERATE) => { + let params = self.params(); + if let Err(e) = params.validate() { + self.error = Some(e.to_string()); + st.set_status(e.to_string(), 90); + continue; + } + match Generating::new(params.clone(), st.tick) { + Ok(next) => { + st.params = params; + return ScreenCmd::Switch { + to: Box::new(next), + fx: TransitionKind::Glitch, + }; + } + Err(e) => { + self.error = Some(e.to_string()); + st.set_status(e.to_string(), 90); + } + } + } + _ => {} + } + } ScreenCmd::Stay } - fn draw(&self, _st: &AppState, _f: &mut Frame) {} + + fn draw(&self, st: &AppState, f: &mut Frame) { + let area = f.area(); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(56), Constraint::Min(20)]) + .split(area); + let left = boxed("CONFIGURE"); + let inner = left.inner(cols[0]); + f.render_widget(left, cols[0]); + let form_area = Rect::new( + inner.x + 1, + inner.y + 1, + inner.width.saturating_sub(2), + inner.height.saturating_sub(2), + ); + FormView { + form: &self.form, + tick: st.tick, + accent: Rgb(0, 255, 230), + } + .render(form_area, f.buffer_mut()); + + let right = boxed("PREVIEW"); + let pinner = right.inner(cols[1]); + f.render_widget(right, cols[1]); + let params = self.params(); + let size = params.size.size(); + let est = (size.area() as f32 / 150.0 * (params.density / 0.6)).round() as u32; + let dim = Style::default().fg(Color::Rgb(120, 130, 145)); + let txt = Style::default().fg(Color::Rgb(205, 212, 222)); + let mut lines = vec![ + Line::from(vec![ + Span::styled(" map ", dim), + Span::styled(format!("{}x{} tiles", size.w, size.h), txt), + ]), + Line::from(vec![ + Span::styled(" districts ", dim), + Span::styled(format!("up to {}", params.districts), txt), + ]), + Line::from(vec![ + Span::styled(" buildings ", dim), + Span::styled(format!("≈ {est}"), txt), + ]), + Line::from(vec![ + Span::styled(" water ", dim), + Span::styled(params.water.label(), txt), + ]), + Line::from(vec![ + Span::styled(" seed ", dim), + Span::styled(params.seed.to_string(), txt), + ]), + Line::from(""), + Line::from(Span::styled( + " ↑↓ field · ←→ adjust · enter · esc back", + dim, + )), + ]; + if let Some(e) = &self.error { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" {e}"), + Style::default().fg(Color::Rgb(255, 80, 100)), + ))); + } + f.render_widget( + Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }), + pinner, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{AppState, Screen, ScreenCmd}; + use crate::screens::testutil::{buffer_text, key}; + use citygen::{GenParams, SizePreset}; + use crossterm::event::KeyCode; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + #[test] + fn configure_generate_switches_screens() { + let mut st = AppState::new(); + let mut cfg = Configure::new( + GenParams { + size: SizePreset::Custom { w: 80, h: 40 }, + districts: 4, + ..Default::default() + }, + 0, + ); + let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap(); + t.draw(|f| cfg.draw(&st, f)).unwrap(); + let text = buffer_text(t.backend().buffer()); + assert!( + text.contains("GENERATE") && text.contains("Districts") && text.contains("80x40"), + "{text}" + ); + for _ in 0..9 { + cfg.update(&mut st, &[key(KeyCode::Down)]); + } + assert!(matches!( + cfg.update(&mut st, &[key(KeyCode::Enter)]), + ScreenCmd::Switch { .. } + )); + assert_eq!(st.params.districts, 4); + } + + #[test] + fn configure_rejects_bad_params_and_random_seed_works() { + let mut st = AppState::new(); + let mut cfg = Configure::new(GenParams::default(), 0); + // Districts up to 12 on a small map is fine; make it custom-small to force rejection. + cfg.form.set_text("Seed", "7".into()); + for _ in 0..8 { + cfg.update(&mut st, &[key(KeyCode::Down)]); + } + assert!(matches!( + cfg.update(&mut st, &[key(KeyCode::Enter)]), + ScreenCmd::Stay + )); // RANDOM SEED + assert_ne!(cfg.form.text("Seed"), Some("7")); + assert!(matches!( + cfg.update(&mut st, &[key(KeyCode::Esc)]), + ScreenCmd::Switch { .. } + )); + assert_eq!(cfg.params().districts, 7); + } } diff --git a/crates/city/src/screens/files.rs b/crates/city/src/screens/files.rs new file mode 100644 index 0000000..80a44b6 --- /dev/null +++ b/crates/city/src/screens/files.rs @@ -0,0 +1,334 @@ +//! Load and save screens over the `./cities` directory. + +use crate::app::{AppState, Screen, ScreenCmd, TransitionKind}; +use crate::render::Rgb; +use crate::save::{CityFileInfo, default_dir, list_cities, load_city, save_city}; +use crate::screens::title::Title; +use crate::ui::form::{Field, Form, FormView}; +use crate::ui::panel::boxed; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +enum Mode { + Load, + Save(Form), +} + +pub struct Files { + dir: PathBuf, + mode: Mode, + entries: Vec, + selected: usize, + error: Option, +} + +/// `YYYY-MM-DD HH:MM` in UTC, without a date crate (civil-from-days algorithm). +fn fmt_time(t: SystemTime) -> String { + let secs = t + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as i64; + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!( + "{y:04}-{m:02}-{d:02} {:02}:{:02}", + rem / 3600, + (rem % 3600) / 60 + ) +} + +impl Files { + pub fn load(_tick: u64) -> Self { + Self::load_in(default_dir(), _tick) + } + + pub fn save(tick: u64, name: &str) -> Self { + Self::save_in(default_dir(), tick, name) + } + + pub fn load_in(dir: PathBuf, _tick: u64) -> Self { + let entries = list_cities(&dir).unwrap_or_default(); + Files { + dir, + mode: Mode::Load, + entries, + selected: 0, + error: None, + } + } + + pub fn save_in(dir: PathBuf, _tick: u64, name: &str) -> Self { + let form = Form { + fields: vec![Field::Text { + label: "Name".into(), + value: name.to_string(), + max: 40, + numeric: false, + }], + selected: 0, + }; + Files { + dir, + mode: Mode::Save(form), + entries: Vec::new(), + selected: 0, + error: None, + } + } + + fn back_to_map(st: &AppState) -> ScreenCmd { + ScreenCmd::Switch { + to: crate::screens::explore_screen(st.tick), + fx: TransitionKind::None, + } + } +} + +impl Screen for Files { + fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd { + for k in keys { + match &mut self.mode { + Mode::Load => match k.code { + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1) + } + KeyCode::Down | KeyCode::Char('j') => { + if self.selected + 1 < self.entries.len() { + self.selected += 1; + } + } + KeyCode::Esc | KeyCode::Char('q') => { + return ScreenCmd::Switch { + to: Box::new(Title::new(st.tick)), + fx: TransitionKind::SlideUp, + }; + } + KeyCode::Enter => { + let Some(entry) = self.entries.get(self.selected) else { + continue; + }; + match load_city(&entry.path) { + Ok(city) => { + st.params = city.params.clone(); + st.city = Some(city); + st.player = None; + st.set_status(format!("loaded {}", entry.path.display()), 90); + return ScreenCmd::Switch { + to: crate::screens::explore_screen(st.tick), + fx: TransitionKind::Glitch, + }; + } + Err(e) => self.error = Some(e.to_string()), + } + } + _ => {} + }, + Mode::Save(form) => { + if k.code == KeyCode::Esc { + return Self::back_to_map(st); + } + if k.code == KeyCode::Enter { + let name = form.text("Name").unwrap_or("").trim().to_string(); + let name = if name.is_empty() { + "city".to_string() + } else { + name + }; + let Some(city) = st.city.as_ref() else { + return Self::back_to_map(st); + }; + let path = self.dir.join(format!("{name}.city")); + match save_city(&path, city) { + Ok(()) => { + st.set_status(format!("saved {}", path.display()), 120); + return Self::back_to_map(st); + } + Err(e) => self.error = Some(e.to_string()), + } + continue; + } + form.handle(k.code); + } + } + } + ScreenCmd::Stay + } + + fn draw(&self, st: &AppState, f: &mut Frame) { + let area = f.area(); + let w = area.width.min(80); + let h = area.height.min(24); + let panel = Rect::new( + area.x + (area.width - w) / 2, + area.y + (area.height - h) / 2, + w, + h, + ); + let dim = Style::default().fg(Color::Rgb(120, 130, 145)); + match &self.mode { + Mode::Load => { + let block = boxed("LOAD CITY"); + let inner = block.inner(panel); + f.render_widget(block, panel); + let mut lines: Vec = Vec::new(); + if self.entries.is_empty() { + lines.push(Line::from(Span::styled( + format!(" no saved cities in {}", self.dir.display()), + dim, + ))); + } + for (i, e) in self.entries.iter().enumerate() { + let sel = i == self.selected; + let style = if sel { + Style::default() + .fg(Color::Rgb(0, 255, 230)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Rgb(205, 212, 222)) + }; + lines.push(Line::from(Span::styled( + format!( + "{} {:<24} seed {:<12} {}x{:<5} {}", + if sel { "▸" } else { " " }, + e.name, + e.seed, + e.size.w, + e.size.h, + fmt_time(e.modified) + ), + style, + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " ↑↓ select · enter load · esc back", + dim, + ))); + if let Some(e) = &self.error { + lines.push(Line::from(Span::styled( + format!(" {e}"), + Style::default().fg(Color::Rgb(255, 80, 100)), + ))); + } + f.render_widget(Paragraph::new(lines), inner); + } + Mode::Save(form) => { + let block = boxed("SAVE CITY"); + let inner = block.inner(panel); + f.render_widget(block, panel); + FormView { + form, + tick: st.tick, + accent: Rgb(0, 255, 230), + } + .render( + Rect::new(inner.x + 1, inner.y + 1, inner.width.saturating_sub(2), 1), + f.buffer_mut(), + ); + let mut lines = vec![ + Line::from(Span::styled( + format!(" saves to {}/.city", self.dir.display()), + dim, + )), + Line::from(Span::styled(" enter save · esc cancel", dim)), + ]; + if let Some(e) = &self.error { + lines.push(Line::from(Span::styled( + format!(" {e}"), + Style::default().fg(Color::Rgb(255, 80, 100)), + ))); + } + f.render_widget( + Paragraph::new(lines), + Rect::new( + inner.x, + inner.y + 3, + inner.width, + inner.height.saturating_sub(3), + ), + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{AppState, Screen, ScreenCmd}; + use crate::screens::testutil::{buffer_text, key}; + use citygen::{GenParams, Generator, SizePreset}; + use crossterm::event::KeyCode; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("city-files-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn save_then_load_round_trip_through_screens() { + let dir = tmp("rt"); + let mut st = AppState::new(); + st.city = Some( + Generator::run(GenParams { + seed: 9, + size: SizePreset::Custom { w: 80, h: 40 }, + districts: 4, + ..Default::default() + }) + .unwrap(), + ); + let name = st.city.as_ref().unwrap().name.clone(); + let mut save = Files::save_in(dir.clone(), 0, &name); + let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap(); + t.draw(|f| save.draw(&st, f)).unwrap(); + assert!(buffer_text(t.backend().buffer()).contains("SAVE")); + assert!(matches!( + save.update(&mut st, &[key(KeyCode::Enter)]), + ScreenCmd::Switch { .. } + )); + assert!(dir.join(format!("{name}.city")).exists()); + + st.city = None; + let mut load = Files::load_in(dir.clone(), 0); + t.draw(|f| load.draw(&st, f)).unwrap(); + let text = buffer_text(t.backend().buffer()); + assert!(text.contains(&name) && text.contains("seed 9"), "{text}"); + assert!(matches!( + load.update(&mut st, &[key(KeyCode::Enter)]), + ScreenCmd::Switch { .. } + )); + assert_eq!(st.city.as_ref().map(|c| c.seed), Some(9)); + let mut empty = Files::load_in(tmp("empty"), 0); + t.draw(|f| empty.draw(&st, f)).unwrap(); + assert!(buffer_text(t.backend().buffer()).contains("no saved cities")); + assert!(matches!( + empty.update(&mut st, &[key(KeyCode::Enter)]), + ScreenCmd::Stay + )); + assert!(matches!( + empty.update(&mut st, &[key(KeyCode::Esc)]), + ScreenCmd::Switch { .. } + )); + } +} diff --git a/crates/city/src/screens/mod.rs b/crates/city/src/screens/mod.rs index d08479f..7ba5eb5 100644 --- a/crates/city/src/screens/mod.rs +++ b/crates/city/src/screens/mod.rs @@ -5,7 +5,9 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::Frame; pub mod configure; +pub mod files; pub mod generating; +pub mod title; /// Draws nothing; stands in until the title screen lands. pub struct Blank; @@ -26,7 +28,7 @@ impl Screen for Blank { /// The screen the app starts on. pub fn initial() -> impl Screen { - Blank + title::Title::new(0) } /// The screen shown once a city exists (explore mode). Wired to the map screen later. diff --git a/crates/city/src/screens/title.rs b/crates/city/src/screens/title.rs new file mode 100644 index 0000000..ac991b5 --- /dev/null +++ b/crates/city/src/screens/title.rs @@ -0,0 +1,170 @@ +//! The title screen: block-letter logo, animated menu. + +use crate::anim::effects::typewriter; +use crate::app::{AppState, Screen, ScreenCmd, TransitionKind}; +use crate::render::Rgb; +use crate::screens::configure::Configure; +use crate::screens::files::Files; +use crate::ui::logo::{self, Logo}; +use crate::ui::menu::{Menu, MenuView}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Paragraph, Widget}; + +pub const TITLE: &str = "CYBERPUNK"; +const SUBTITLE: &str = "c i t y g e n e r a t o r"; + +pub struct Title { + menu: Menu, + opened: u64, +} + +impl Title { + pub fn new(tick: u64) -> Self { + Title { + menu: Menu::new( + vec!["NEW CITY".into(), "LOAD CITY".into(), "QUIT".into()], + tick + 20, + ), + opened: tick, + } + } +} + +impl Screen for Title { + fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd { + for k in keys { + match k.code { + KeyCode::Up | KeyCode::Char('k') => self.menu.up(), + KeyCode::Down | KeyCode::Char('j') => self.menu.down(), + KeyCode::Char('q') | KeyCode::Esc => return ScreenCmd::Quit, + KeyCode::Enter => { + return match self.menu.selected { + 0 => ScreenCmd::Switch { + to: Box::new(Configure::new(st.params.clone(), st.tick)), + fx: TransitionKind::Glitch, + }, + 1 => ScreenCmd::Switch { + to: Box::new(Files::load(st.tick)), + fx: TransitionKind::SlideUp, + }, + _ => ScreenCmd::Quit, + }; + } + _ => {} + } + } + ScreenCmd::Stay + } + + fn draw(&self, st: &AppState, f: &mut Frame) { + let area = f.area(); + let w = logo::width(TITLE); + let x = area.x + area.width.saturating_sub(w) / 2; + let y = area.y + area.height / 6; + Logo { + text: TITLE, + tick: st.tick, + accent: Rgb(255, 0, 200), + } + .render( + Rect::new(x, y, w.min(area.width), logo::ROWS), + f.buffer_mut(), + ); + + let p = ((st.tick.saturating_sub(self.opened)) as f32 / 30.0).clamp(0.0, 1.0); + let sub = typewriter(SUBTITLE, p); + let sx = area.x + area.width.saturating_sub(SUBTITLE.chars().count() as u16) / 2; + f.render_widget( + Paragraph::new(Span::styled( + sub, + Style::default().fg(Color::Rgb(0, 229, 255)), + )), + Rect::new( + sx, + y + logo::ROWS + 1, + area.width.saturating_sub(sx - area.x), + 1, + ), + ); + + let menu_y = y + logo::ROWS + 4; + let menu_x = area.x + area.width / 2 - 6; + MenuView { + menu: &self.menu, + tick: st.tick, + accent: Rgb(0, 255, 230), + } + .render( + Rect::new( + menu_x, + menu_y, + area.width.saturating_sub(menu_x - area.x), + 3, + ), + f.buffer_mut(), + ); + + let hint = "↑↓ select · enter confirm · q quit"; + let hx = area.x + area.width.saturating_sub(hint.chars().count() as u16) / 2; + f.render_widget( + Paragraph::new(Span::styled( + hint, + Style::default().fg(Color::Rgb(100, 110, 125)), + )), + Rect::new( + hx, + area.bottom() - 2, + area.width.saturating_sub(hx - area.x), + 1, + ), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{AppState, Screen, ScreenCmd}; + use crate::screens::testutil::{buffer_text, key}; + use crossterm::event::KeyCode; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + #[test] + fn title_renders_logo_and_menu_and_quits() { + let mut st = AppState::new(); + let mut title = Title::new(0); + let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap(); + st.tick = 60; + t.draw(|f| title.draw(&st, f)).unwrap(); + let text = buffer_text(t.backend().buffer()); + assert!( + text.contains("NEW CITY") && text.contains("LOAD CITY") && text.contains("QUIT"), + "{text}" + ); + assert!(text.contains('█')); + assert!(matches!( + title.update(&mut st, &[key(KeyCode::Char('q'))]), + ScreenCmd::Quit + )); + title.update(&mut st, &[key(KeyCode::Down), key(KeyCode::Down)]); + assert!(matches!( + title.update(&mut st, &[key(KeyCode::Enter)]), + ScreenCmd::Quit + )); + title.update(&mut st, &[key(KeyCode::Up), key(KeyCode::Up)]); + assert!(matches!( + title.update(&mut st, &[key(KeyCode::Enter)]), + ScreenCmd::Switch { .. } + )); + title.update(&mut st, &[key(KeyCode::Char('j'))]); + assert!(matches!( + title.update(&mut st, &[key(KeyCode::Enter)]), + ScreenCmd::Switch { .. } + )); + } +} diff --git a/crates/city/src/ui/form.rs b/crates/city/src/ui/form.rs index 6d616f3..905c2be 100644 --- a/crates/city/src/ui/form.rs +++ b/crates/city/src/ui/form.rs @@ -33,6 +33,8 @@ pub enum Field { label: String, value: String, max: usize, + /// Only digits are accepted when set. + numeric: bool, }, Button { label: String, @@ -114,8 +116,17 @@ impl Form { } } KeyCode::Char(c) => { - if let Field::Text { value, max, .. } = &mut self.fields[self.selected] - && c.is_ascii_digit() + if let Field::Text { + value, + max, + numeric, + .. + } = &mut self.fields[self.selected] + && (if *numeric { + c.is_ascii_digit() + } else { + c.is_alphanumeric() || " -_".contains(c) + }) && value.chars().count() < *max { value.push(c); @@ -306,6 +317,7 @@ mod tests { label: "Seed".into(), value: "12".into(), max: 5, + numeric: true, }, Field::Button { label: "GENERATE".into(),