From 68be96a8b2749d58a9c7da43bf296239b1724e48 Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 17:33:18 +0200 Subject: [PATCH] feat(city): app loop, terminal guard, save format, gen CLI Co-Authored-By: Claude Fable 5.1 --- crates/city/src/app.rs | 190 +++++++++++++++++++++++++++++++ crates/city/src/cli.rs | 132 ++++++++++++++++++++++ crates/city/src/main.rs | 15 ++- crates/city/src/save.rs | 198 +++++++++++++++++++++++++++++++++ crates/city/src/screens/mod.rs | 48 ++++++++ crates/city/src/term.rs | 40 +++++++ 6 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 crates/city/src/app.rs create mode 100644 crates/city/src/cli.rs create mode 100644 crates/city/src/save.rs create mode 100644 crates/city/src/screens/mod.rs create mode 100644 crates/city/src/term.rs diff --git a/crates/city/src/app.rs b/crates/city/src/app.rs new file mode 100644 index 0000000..214b109 --- /dev/null +++ b/crates/city/src/app.rs @@ -0,0 +1,190 @@ +//! Application state, the screen trait and the fixed-rate frame loop. + +use crate::term::{TerminalGuard, install_panic_hook}; +use anyhow::Result; +use citygen::{City, GenParams}; +use crossterm::event::{self, Event, KeyEvent, KeyEventKind}; +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::Paragraph; +use std::time::{Duration, Instant}; + +pub const MIN_W: u16 = 100; +pub const MIN_H: u16 = 30; +pub const FRAME: Duration = Duration::from_micros(33_333); + +/// State shared by every screen. +pub struct AppState { + pub params: GenParams, + pub city: Option, + pub tick: u64, + status: Option<(String, u64)>, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + pub fn new() -> Self { + AppState { + params: GenParams::default(), + city: None, + tick: 0, + status: None, + } + } + + /// Shows a one-line message for `ttl` ticks. + pub fn set_status(&mut self, msg: impl Into, ttl: u64) { + self.status = Some((msg.into(), self.tick + ttl)); + } + + pub fn status_text(&self) -> Option<&str> { + match &self.status { + Some((msg, until)) if *until > self.tick => Some(msg.as_str()), + _ => None, + } + } +} + +/// How a screen change is animated. Replaced by `ui::transition` later. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransitionKind { + None, + Glitch, + SlideUp, +} + +/// Placeholder transition: inactive until the ui module lands. +pub struct Transition; + +impl Transition { + pub fn new(_kind: TransitionKind, _from: Buffer, _start: u64) -> Self { + Transition + } + pub fn active(&self, _tick: u64) -> bool { + false + } + pub fn apply(&self, _f: &mut Frame, _tick: u64) {} +} + +/// What a screen asks the loop to do after an update. +pub enum ScreenCmd { + Stay, + Switch { + to: Box, + fx: TransitionKind, + }, + Quit, +} + +pub trait Screen { + fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd; + fn draw(&self, st: &AppState, f: &mut Frame); +} + +/// Centered notice for terminals under the minimum size. Never panics, even at 1x1. +pub fn draw_too_small(f: &mut Frame) { + let area = f.area(); + let msg = format!( + "resize to at least {MIN_W}x{MIN_H} (now {}x{})", + area.width, area.height + ); + let y = area.height / 2; + let line = Rect::new(area.x, area.y + y, area.width, 1.min(area.height)); + f.render_widget( + Paragraph::new(msg) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Rgb(255, 80, 120))), + line, + ); +} + +/// The fixed-rate loop: poll input, tick, update, draw. +pub fn run(mut screen: Box, mut st: AppState) -> Result<()> { + install_panic_hook(); + let (_guard, mut term) = TerminalGuard::enter()?; + let mut transition: Option = None; + loop { + let start = Instant::now(); + let mut keys = Vec::new(); + while event::poll(Duration::ZERO)? { + if let Event::Key(k) = event::read()? + && k.kind == KeyEventKind::Press + { + keys.push(k); + } + } + st.tick += 1; + if transition.as_ref().is_some_and(|t| t.active(st.tick)) && !keys.is_empty() { + transition = None; + } + match screen.update(&mut st, &keys) { + ScreenCmd::Stay => {} + ScreenCmd::Switch { to, fx } => { + let snapshot = term.current_buffer_mut().clone(); + screen = to; + transition = Some(Transition::new(fx, snapshot, st.tick)); + } + ScreenCmd::Quit => break, + } + term.draw(|f| { + if f.area().width < MIN_W || f.area().height < MIN_H { + draw_too_small(f); + return; + } + screen.draw(&st, f); + if let Some(t) = &transition { + t.apply(f, st.tick); + } + })?; + if let Some(rest) = FRAME.checked_sub(start.elapsed()) { + std::thread::sleep(rest); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn text(t: &Terminal) -> String { + let b = t.backend().buffer(); + let mut s = String::new(); + for y in 0..b.area.height { + for x in 0..b.area.width { + s.push_str(b[(x, y)].symbol()); + } + s.push('\n'); + } + s + } + + #[test] + fn too_small_message_never_panics() { + for (w, h) in [(1u16, 1u16), (99, 29), (10, 2)] { + let mut t = Terminal::new(TestBackend::new(w, h)).unwrap(); + t.draw(draw_too_small).unwrap(); + } + let mut t = Terminal::new(TestBackend::new(99, 29)).unwrap(); + t.draw(draw_too_small).unwrap(); + assert!(text(&t).contains("100")); + } + + #[test] + fn app_state_status_expires() { + let mut st = AppState::new(); + st.set_status("hi", 10); + assert_eq!(st.status_text(), Some("hi")); + st.tick += 11; + assert_eq!(st.status_text(), None); + } +} diff --git a/crates/city/src/cli.rs b/crates/city/src/cli.rs new file mode 100644 index 0000000..9ea6787 --- /dev/null +++ b/crates/city/src/cli.rs @@ -0,0 +1,132 @@ +//! Command line: interactive app by default, `gen` for headless generation. + +use crate::app::{self, AppState}; +use crate::save::save_city; +use anyhow::{Context, Result, bail}; +use citygen::{GenParams, Generator, SizePreset, WaterMode}; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command( + name = "city", + about = "Generate, explore and walk a cyberpunk city in the terminal" +)] +pub struct Cli { + #[command(subcommand)] + cmd: Option, +} + +#[derive(Subcommand)] +pub enum Cmd { + /// Generate a city headlessly and write it to a file. + Gen { + /// Seed; random when omitted. + #[arg(long)] + seed: Option, + /// small, medium, large or WxH. + #[arg(long, default_value = "medium")] + size: String, + #[arg(long)] + districts: Option, + /// none, bay or river. + #[arg(long)] + water: Option, + #[arg(long)] + density: Option, + /// Output path (.city, or .json with --json). + #[arg(long)] + out: PathBuf, + /// Write pretty JSON instead of the binary format. + #[arg(long)] + json: bool, + }, +} + +pub fn parse_size(s: &str) -> Result { + match s { + "small" => Ok(SizePreset::Small), + "medium" => Ok(SizePreset::Medium), + "large" => Ok(SizePreset::Large), + other => { + let (w, h) = other + .split_once('x') + .with_context(|| format!("size `{other}` is not small/medium/large or WxH"))?; + Ok(SizePreset::Custom { + w: w.parse().context("bad width")?, + h: h.parse().context("bad height")?, + }) + } + } +} + +pub fn parse_water(s: &str) -> Result { + match s { + "none" => Ok(WaterMode::None), + "bay" => Ok(WaterMode::Bay), + "river" => Ok(WaterMode::River), + other => bail!("water `{other}` is not none/bay/river"), + } +} + +fn random_seed() -> u64 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + citygen::rng::hash64(nanos, std::process::id() as u64) +} + +pub fn run() -> Result<()> { + let cli = Cli::parse(); + match cli.cmd { + None => app::run(Box::new(crate::screens::initial()), AppState::new()), + Some(Cmd::Gen { + seed, + size, + districts, + water, + density, + out, + json, + }) => { + let mut params = GenParams { + seed: seed.unwrap_or_else(random_seed), + size: parse_size(&size)?, + ..Default::default() + }; + if let Some(d) = districts { + params.districts = d; + } + if let Some(w) = water { + params.water = parse_water(&w)?; + } + if let Some(d) = density { + params.density = d; + } + params.validate()?; + let city = Generator::run(params)?; + if json { + let file = std::fs::File::create(&out) + .with_context(|| format!("creating {}", out.display()))?; + serde_json::to_writer_pretty(file, &city)?; + } else { + save_city(&out, &city)?; + } + let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); + println!( + "{} seed {} {}x{} districts {} buildings {} nodes {} -> {} ({} bytes)", + city.name, + city.seed, + city.size.w, + city.size.h, + city.districts.len(), + city.buildings.len(), + city.net.nodes.len(), + out.display(), + bytes + ); + Ok(()) + } + } +} diff --git a/crates/city/src/main.rs b/crates/city/src/main.rs index e6bedbe..64f9e34 100644 --- a/crates/city/src/main.rs +++ b/crates/city/src/main.rs @@ -1,3 +1,14 @@ -fn main() { - println!("city"); +//! `city`: generate, explore and walk a cyberpunk city in the terminal. + +// Screens, widgets and overlays land task by task; lifted once the app is wired up. +#![allow(dead_code)] + +mod app; +mod cli; +mod save; +mod screens; +mod term; + +fn main() -> anyhow::Result<()> { + cli::run() } diff --git a/crates/city/src/save.rs b/crates/city/src/save.rs new file mode 100644 index 0000000..9743bd4 --- /dev/null +++ b/crates/city/src/save.rs @@ -0,0 +1,198 @@ +//! The `.city` file format: magic, version, header, postcard body. + +use anyhow::{Context, Result, bail}; +use citygen::{City, Size}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +pub const MAGIC: &[u8; 4] = b"CITY"; +pub const FORMAT_VERSION: u16 = 1; + +#[derive(Debug, Serialize, Deserialize)] +struct Header { + name: String, + seed: u64, + w: u32, + h: u32, +} + +/// What the file list shows for one saved city. +#[derive(Clone, Debug)] +pub struct CityFileInfo { + pub path: PathBuf, + pub name: String, + pub seed: u64, + pub size: Size, + pub modified: SystemTime, +} + +/// Where cities are saved by default. +pub fn default_dir() -> PathBuf { + PathBuf::from("cities") +} + +pub fn save_city(path: &Path, city: &City) -> Result<()> { + let header = Header { + name: city.name.clone(), + seed: city.seed, + w: city.size.w, + h: city.size.h, + }; + let header_bytes = postcard::to_allocvec(&header)?; + let body = postcard::to_allocvec(city)?; + let mut out = Vec::with_capacity(10 + header_bytes.len() + body.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&FORMAT_VERSION.to_le_bytes()); + out.extend_from_slice(&(header_bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(&header_bytes); + out.extend_from_slice(&body); + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +/// Splits a file into (header, body) after checking magic and version. +fn split<'a>(bytes: &'a [u8], path: &Path) -> Result<(Header, &'a [u8])> { + if bytes.len() < 10 || &bytes[0..4] != MAGIC { + bail!("{} is not a .city file", path.display()); + } + let version = u16::from_le_bytes([bytes[4], bytes[5]]); + if version != FORMAT_VERSION { + bail!( + "{} is .city version {version}; this build reads version {FORMAT_VERSION}", + path.display() + ); + } + let header_len = u32::from_le_bytes([bytes[6], bytes[7], bytes[8], bytes[9]]) as usize; + let header_end = 10 + header_len; + if bytes.len() < header_end { + bail!("{} is truncated", path.display()); + } + let header: Header = postcard::from_bytes(&bytes[10..header_end]) + .with_context(|| format!("{}: corrupt header", path.display()))?; + Ok((header, &bytes[header_end..])) +} + +pub fn load_city(path: &Path) -> Result { + let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; + let (_, body) = split(&bytes, path)?; + let city: City = postcard::from_bytes(body) + .with_context(|| format!("{}: corrupt or truncated city data", path.display()))?; + Ok(city) +} + +/// Lists `.city` files in `dir`, newest first. A missing directory lists as empty. +pub fn list_cities(dir: &Path) -> Result> { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e).with_context(|| format!("listing {}", dir.display())), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("city") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok((header, _)) = split(&bytes, &path) else { + continue; + }; + let modified = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + out.push(CityFileInfo { + path, + name: header.name, + seed: header.seed, + size: Size { + w: header.w, + h: header.h, + }, + modified, + }); + } + out.sort_by(|a, b| b.modified.cmp(&a.modified).then(a.path.cmp(&b.path))); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use citygen::{GenParams, Generator, SizePreset}; + + fn tiny() -> citygen::City { + Generator::run(GenParams { + seed: 5, + size: SizePreset::Custom { w: 80, h: 40 }, + districts: 4, + ..Default::default() + }) + .unwrap() + } + + fn tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("city-test-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn roundtrip_and_header() { + let c = tiny(); + let dir = tmp("rt"); + let path = dir.join("a.city"); + save_city(&path, &c).unwrap(); + let back = load_city(&path).unwrap(); + assert_eq!( + postcard::to_allocvec(&c).unwrap(), + postcard::to_allocvec(&back).unwrap() + ); + let list = list_cities(&dir).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].seed, 5); + assert_eq!(list[0].name, c.name); + assert_eq!(list[0].size.w, 80); + assert!(list_cities(&dir.join("missing")).unwrap().is_empty()); + } + + #[test] + fn rejects_bad_magic_version_and_truncation() { + let dir = tmp("bad"); + let p = dir.join("x.city"); + std::fs::write(&p, b"NOPE\x01\x00").unwrap(); + assert!( + load_city(&p) + .unwrap_err() + .to_string() + .contains("not a .city file") + ); + let mut bytes = b"CITY".to_vec(); + bytes.extend_from_slice(&99u16.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + std::fs::write(&p, &bytes).unwrap(); + assert!( + load_city(&p) + .unwrap_err() + .to_string() + .contains("version 99") + ); + let c = tiny(); + save_city(&p, &c).unwrap(); + let full = std::fs::read(&p).unwrap(); + std::fs::write(&p, &full[..full.len() / 2]).unwrap(); + assert!(load_city(&p).is_err()); + std::fs::write(&p, b"CI").unwrap(); + assert!(load_city(&p).is_err()); + } +} diff --git a/crates/city/src/screens/mod.rs b/crates/city/src/screens/mod.rs new file mode 100644 index 0000000..f8f2ca0 --- /dev/null +++ b/crates/city/src/screens/mod.rs @@ -0,0 +1,48 @@ +//! Screens: one module per screen, plus shared test helpers. + +use crate::app::{AppState, Screen, ScreenCmd}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; + +/// Draws nothing; stands in until the title screen lands. +pub struct Blank; + +impl Screen for Blank { + fn update(&mut self, _st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd { + if keys + .iter() + .any(|k| matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)) + { + ScreenCmd::Quit + } else { + ScreenCmd::Stay + } + } + fn draw(&self, _st: &AppState, _f: &mut Frame) {} +} + +/// The screen the app starts on. +pub fn initial() -> impl Screen { + Blank +} + +#[cfg(test)] +pub(crate) mod testutil { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::buffer::Buffer; + + pub fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + pub fn buffer_text(b: &Buffer) -> String { + let mut s = String::new(); + for y in 0..b.area.height { + for x in 0..b.area.width { + s.push_str(b[(x, y)].symbol()); + } + s.push('\n'); + } + s + } +} diff --git a/crates/city/src/term.rs b/crates/city/src/term.rs new file mode 100644 index 0000000..73244a6 --- /dev/null +++ b/crates/city/src/term.rs @@ -0,0 +1,40 @@ +//! Raw-mode terminal setup that always restores the shell, even on panic. + +use anyhow::Result; +use crossterm::{cursor, execute, terminal}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use std::io::{Stdout, stdout}; + +/// Restores the terminal when dropped. +pub struct TerminalGuard; + +impl TerminalGuard { + pub fn enter() -> Result<(Self, Terminal>)> { + terminal::enable_raw_mode()?; + execute!(stdout(), terminal::EnterAlternateScreen, cursor::Hide)?; + let term = Terminal::new(CrosstermBackend::new(stdout()))?; + Ok((TerminalGuard, term)) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + restore(); + } +} + +/// Leaves raw mode and the alternate screen. Safe to call more than once. +pub fn restore() { + let _ = terminal::disable_raw_mode(); + let _ = execute!(stdout(), terminal::LeaveAlternateScreen, cursor::Show); +} + +/// Makes a panic restore the terminal before printing its message. +pub fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore(); + previous(info); + })); +}