feat(city): app loop, terminal guard, save format, gen CLI

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:33:18 +02:00
parent 0d459e7058
commit 68be96a8b2
6 changed files with 621 additions and 2 deletions

190
crates/city/src/app.rs Normal file
View File

@@ -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<City>,
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<String>, 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<dyn Screen>,
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<dyn Screen>, mut st: AppState) -> Result<()> {
install_panic_hook();
let (_guard, mut term) = TerminalGuard::enter()?;
let mut transition: Option<Transition> = 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<TestBackend>) -> 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);
}
}

132
crates/city/src/cli.rs Normal file
View File

@@ -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<Cmd>,
}
#[derive(Subcommand)]
pub enum Cmd {
/// Generate a city headlessly and write it to a file.
Gen {
/// Seed; random when omitted.
#[arg(long)]
seed: Option<u64>,
/// small, medium, large or WxH.
#[arg(long, default_value = "medium")]
size: String,
#[arg(long)]
districts: Option<u8>,
/// none, bay or river.
#[arg(long)]
water: Option<String>,
#[arg(long)]
density: Option<f32>,
/// 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<SizePreset> {
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<WaterMode> {
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(())
}
}
}

View File

@@ -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()
}

198
crates/city/src/save.rs Normal file
View File

@@ -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<City> {
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<Vec<CityFileInfo>> {
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());
}
}

View File

@@ -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
}
}

40
crates/city/src/term.rs Normal file
View File

@@ -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<CrosstermBackend<Stdout>>)> {
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);
}));
}