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.
|
//! The configure screen: a form over `GenParams` with a live preview card.
|
||||||
|
|
||||||
use crate::app::{AppState, Screen, ScreenCmd};
|
use crate::app::{AppState, Screen, ScreenCmd, TransitionKind};
|
||||||
use citygen::GenParams;
|
use crate::render::Rgb;
|
||||||
use crossterm::event::KeyEvent;
|
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::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 {
|
pub struct Configure {
|
||||||
params: GenParams,
|
pub form: Form,
|
||||||
|
base: GenParams,
|
||||||
opened: u64,
|
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 {
|
impl Configure {
|
||||||
pub fn new(params: GenParams, tick: u64) -> Self {
|
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 {
|
Configure {
|
||||||
params,
|
form,
|
||||||
|
base: params,
|
||||||
opened: tick,
|
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 {
|
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
|
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;
|
use ratatui::Frame;
|
||||||
|
|
||||||
pub mod configure;
|
pub mod configure;
|
||||||
|
pub mod files;
|
||||||
pub mod generating;
|
pub mod generating;
|
||||||
|
pub mod title;
|
||||||
|
|
||||||
/// Draws nothing; stands in until the title screen lands.
|
/// Draws nothing; stands in until the title screen lands.
|
||||||
pub struct Blank;
|
pub struct Blank;
|
||||||
@@ -26,7 +28,7 @@ impl Screen for Blank {
|
|||||||
|
|
||||||
/// The screen the app starts on.
|
/// The screen the app starts on.
|
||||||
pub fn initial() -> impl Screen {
|
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.
|
/// 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,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
max: usize,
|
max: usize,
|
||||||
|
/// Only digits are accepted when set.
|
||||||
|
numeric: bool,
|
||||||
},
|
},
|
||||||
Button {
|
Button {
|
||||||
label: String,
|
label: String,
|
||||||
@@ -114,8 +116,17 @@ impl Form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
if let Field::Text { value, max, .. } = &mut self.fields[self.selected]
|
if let Field::Text {
|
||||||
&& c.is_ascii_digit()
|
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.chars().count() < *max
|
||||||
{
|
{
|
||||||
value.push(c);
|
value.push(c);
|
||||||
@@ -306,6 +317,7 @@ mod tests {
|
|||||||
label: "Seed".into(),
|
label: "Seed".into(),
|
||||||
value: "12".into(),
|
value: "12".into(),
|
||||||
max: 5,
|
max: 5,
|
||||||
|
numeric: true,
|
||||||
},
|
},
|
||||||
Field::Button {
|
Field::Button {
|
||||||
label: "GENERATE".into(),
|
label: "GENERATE".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user