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:
190
crates/city/src/app.rs
Normal file
190
crates/city/src/app.rs
Normal 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
132
crates/city/src/cli.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
fn main() {
|
//! `city`: generate, explore and walk a cyberpunk city in the terminal.
|
||||||
println!("city");
|
|
||||||
|
// 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
198
crates/city/src/save.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
48
crates/city/src/screens/mod.rs
Normal file
48
crates/city/src/screens/mod.rs
Normal 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
40
crates/city/src/term.rs
Normal 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);
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user