feat(city): title, configure and files screens

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:49:49 +02:00
parent 73596eadd2
commit 997814e2f3
5 changed files with 829 additions and 10 deletions

View File

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

View 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 { .. }
));
}
}

View File

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

View 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 { .. }
));
}
}

View File

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