feat(city): title, configure and files screens
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
fn size_options(base: SizePreset) -> (Vec<String>, 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::<u64>().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);
|
||||
}
|
||||
}
|
||||
|
||||
334
crates/city/src/screens/files.rs
Normal file
334
crates/city/src/screens/files.rs
Normal file
@@ -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<CityFileInfo>,
|
||||
selected: usize,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// `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<Line> = 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 {}/<name>.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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
170
crates/city/src/screens/title.rs
Normal file
170
crates/city/src/screens/title.rs
Normal file
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user