feat(city): logo, menu, form, panels and transitions

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:45:16 +02:00
parent 741231c25b
commit acd0286bab
8 changed files with 979 additions and 21 deletions

View File

@@ -6,7 +6,6 @@ use anyhow::Result;
use citygen::{City, GenParams}; use citygen::{City, GenParams};
use crossterm::event::{self, Event, KeyEvent, KeyEventKind}; use crossterm::event::{self, Event, KeyEvent, KeyEventKind};
use ratatui::Frame; use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect}; use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
@@ -55,26 +54,7 @@ impl AppState {
} }
} }
/// How a screen change is animated. Replaced by `ui::transition` later. pub use crate::ui::transition::{Transition, TransitionKind};
#[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. /// What a screen asks the loop to do after an update.
pub enum ScreenCmd { pub enum ScreenCmd {

View File

@@ -10,6 +10,7 @@ mod render;
mod save; mod save;
mod screens; mod screens;
mod term; mod term;
mod ui;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
cli::run() cli::run()

359
crates/city/src/ui/form.rs Normal file
View File

@@ -0,0 +1,359 @@
//! A keyboard-driven form of numbers, sliders, choices, text and buttons.
use crate::anim::effects::pulse;
use crate::render::Rgb;
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Widget;
#[derive(Clone, Debug, PartialEq)]
pub enum Field {
Number {
label: String,
value: i64,
min: i64,
max: i64,
step: i64,
},
Slider {
label: String,
value: f32,
min: f32,
max: f32,
step: f32,
},
Choice {
label: String,
options: Vec<String>,
idx: usize,
},
Text {
label: String,
value: String,
max: usize,
},
Button {
label: String,
},
}
impl Field {
pub fn label(&self) -> &str {
match self {
Field::Number { label, .. }
| Field::Slider { label, .. }
| Field::Choice { label, .. }
| Field::Text { label, .. }
| Field::Button { label } => label,
}
}
fn adjust(&mut self, dir: i32) {
match self {
Field::Number {
value,
min,
max,
step,
..
} => {
*value = (*value + dir as i64 * *step).clamp(*min, *max);
}
Field::Slider {
value,
min,
max,
step,
..
} => {
*value = (*value + dir as f32 * *step).clamp(*min, *max);
*value = (*value * 1000.0).round() / 1000.0;
}
Field::Choice { options, idx, .. } => {
let n = options.len().max(1);
*idx = (*idx as i64 + dir as i64).rem_euclid(n as i64) as usize;
}
_ => {}
}
}
}
#[derive(Clone, Debug)]
pub struct Form {
pub fields: Vec<Field>,
pub selected: usize,
}
impl Form {
/// Applies a key; returns the index of a button if one was pressed.
pub fn handle(&mut self, key: KeyCode) -> Option<usize> {
let n = self.fields.len();
if n == 0 {
return None;
}
match key {
KeyCode::Up | KeyCode::BackTab => {
self.selected = (self.selected + n - 1) % n;
}
KeyCode::Down | KeyCode::Tab => {
self.selected = (self.selected + 1) % n;
}
KeyCode::Left => self.fields[self.selected].adjust(-1),
KeyCode::Right => self.fields[self.selected].adjust(1),
KeyCode::Enter => {
if matches!(self.fields[self.selected], Field::Button { .. }) {
return Some(self.selected);
}
self.selected = (self.selected + 1) % n;
}
KeyCode::Backspace => {
if let Field::Text { value, .. } = &mut self.fields[self.selected] {
value.pop();
}
}
KeyCode::Char(c) => {
if let Field::Text { value, max, .. } = &mut self.fields[self.selected]
&& c.is_ascii_digit()
&& value.chars().count() < *max
{
value.push(c);
}
}
_ => {}
}
None
}
pub fn text(&self, label: &str) -> Option<&str> {
self.fields.iter().find_map(|f| match f {
Field::Text {
label: l, value, ..
} if l == label => Some(value.as_str()),
_ => None,
})
}
pub fn number(&self, label: &str) -> Option<i64> {
self.fields.iter().find_map(|f| match f {
Field::Number {
label: l, value, ..
} if l == label => Some(*value),
_ => None,
})
}
pub fn slider(&self, label: &str) -> Option<f32> {
self.fields.iter().find_map(|f| match f {
Field::Slider {
label: l, value, ..
} if l == label => Some(*value),
_ => None,
})
}
pub fn choice(&self, label: &str) -> Option<&str> {
self.fields.iter().find_map(|f| match f {
Field::Choice {
label: l,
options,
idx,
} if l == label => options.get(*idx).map(String::as_str),
_ => None,
})
}
pub fn set_text(&mut self, label: &str, new: String) {
for f in &mut self.fields {
if let Field::Text {
label: l, value, ..
} = f
&& l == label
{
*value = new;
return;
}
}
}
}
const LABEL_W: usize = 14;
const SLIDER_W: usize = 20;
fn render_value(field: &Field, selected: bool, tick: u64) -> String {
match field {
Field::Number { value, .. } => format!("◂ {value:>4} ▸"),
Field::Slider {
value, min, max, ..
} => {
let t = if max > min {
(value - min) / (max - min)
} else {
0.0
};
let knob = (t * (SLIDER_W as f32 - 1.0)).round() as usize;
let bar: String = (0..SLIDER_W)
.map(|i| if i == knob { '◉' } else { '━' })
.collect();
format!("{bar} {value:.2}")
}
Field::Choice { options, idx, .. } => {
format!(
"◂ {:^8} ▸",
options.get(*idx).map(String::as_str).unwrap_or("")
)
}
Field::Text { value, .. } => {
let cursor = if selected && (tick / 15).is_multiple_of(2) {
"▏"
} else {
" "
};
if value.is_empty() {
format!("(random){cursor}")
} else {
format!("{value}{cursor}")
}
}
Field::Button { label } => format!("[ {label} ]"),
}
}
pub struct FormView<'a> {
pub form: &'a Form,
pub tick: u64,
pub accent: Rgb,
}
impl Widget for FormView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
for (i, field) in self.form.fields.iter().enumerate() {
let y = area.y + i as u16;
if y >= area.bottom() {
break;
}
let selected = i == self.form.selected;
let is_button = matches!(field, Field::Button { .. });
let line = if is_button {
format!(
"{}{:>w$}",
if selected { "▸ " } else { " " },
render_value(field, selected, self.tick),
w = LABEL_W + 2
)
} else {
format!(
"{}{:<w$} {}",
if selected { "▸ " } else { " " },
field.label(),
render_value(field, selected, self.tick),
w = LABEL_W
)
};
let style = if selected {
let glow = 0.7 + 0.3 * pulse(self.tick, 30);
Style::default()
.fg(self.accent.scale(glow).to_color())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Rgb(175, 185, 200))
};
for (j, ch) in line.chars().enumerate() {
let x = area.x + j as u16;
if x >= area.right() {
break;
}
buf[(x, y)].set_char(ch).set_style(style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::Rgb;
use crossterm::event::KeyCode;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::Widget;
#[test]
fn form_adjusts_and_fires_button() {
let mut f = Form {
fields: vec![
Field::Number {
label: "Districts".into(),
value: 7,
min: 4,
max: 12,
step: 1,
},
Field::Slider {
label: "Density".into(),
value: 0.6,
min: 0.0,
max: 1.0,
step: 0.05,
},
Field::Choice {
label: "Water".into(),
options: vec!["none".into(), "bay".into(), "river".into()],
idx: 1,
},
Field::Text {
label: "Seed".into(),
value: "12".into(),
max: 5,
},
Field::Button {
label: "GENERATE".into(),
},
],
selected: 0,
};
assert_eq!(f.handle(KeyCode::Right), None);
assert!(matches!(f.fields[0], Field::Number { value: 8, .. }));
for _ in 0..10 {
f.handle(KeyCode::Right);
}
assert!(matches!(f.fields[0], Field::Number { value: 12, .. }));
f.handle(KeyCode::Down);
f.handle(KeyCode::Right);
assert!(matches!(f.fields[1], Field::Slider { value, .. } if (value - 0.65).abs() < 1e-4));
f.handle(KeyCode::Down);
f.handle(KeyCode::Right);
f.handle(KeyCode::Right);
assert!(matches!(f.fields[2], Field::Choice { idx: 0, .. }));
f.handle(KeyCode::Down);
f.handle(KeyCode::Char('3'));
f.handle(KeyCode::Char('x'));
assert!(matches!(&f.fields[3], Field::Text { value, .. } if value == "123"));
f.handle(KeyCode::Backspace);
assert!(matches!(&f.fields[3], Field::Text { value, .. } if value == "12"));
f.handle(KeyCode::Down);
assert_eq!(f.handle(KeyCode::Enter), Some(4));
f.handle(KeyCode::Down);
assert_eq!(f.selected, 0);
let mut t = Terminal::new(TestBackend::new(60, 10)).unwrap();
t.draw(|fr| {
FormView {
form: &f,
tick: 3,
accent: Rgb(0, 255, 255),
}
.render(fr.area(), fr.buffer_mut())
})
.unwrap();
let text: String = (0..10)
.map(|y| {
(0..60)
.map(|x| t.backend().buffer()[(x, y)].symbol().to_string())
.collect::<String>()
+ "\n"
})
.collect();
assert!(text.contains("Districts") && text.contains("GENERATE") && text.contains("◉"));
}
}

138
crates/city/src/ui/logo.rs Normal file
View File

@@ -0,0 +1,138 @@
//! Block-letter logo with neon flicker and a scanline sweep.
use crate::anim::effects::flicker;
use crate::render::Rgb;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
/// Columns per letter including the gap.
pub const CELL_W: u16 = 6;
pub const ROWS: u16 = 5;
/// Five rows of up to five cells for a letter; shorter rows are padded with spaces.
pub fn glyph(c: char) -> [&'static str; 5] {
match c.to_ascii_uppercase() {
'A' => ["▄▀▀▄", "█ █", "█▀▀█", "█ █", "▀ ▀"],
'B' => ["█▀▀▄", "█ █", "█▀▀▄", "█ █", "▀▀▀ "],
'C' => ["▄▀▀▀", "█ ", "█ ", "█ ", "▀▀▀▀"],
'D' => ["█▀▀▄", "█ █", "█ █", "█ █", "▀▀▀ "],
'E' => ["█▀▀▀", "█ ", "█▀▀ ", "█ ", "▀▀▀▀"],
'F' => ["█▀▀▀", "█ ", "█▀▀ ", "█ ", "▀ "],
'G' => ["▄▀▀▀", "█ ", "█ ▀█", "█ █", "▀▀▀▀"],
'H' => ["█ █", "█ █", "█▀▀█", "█ █", "▀ ▀"],
'I' => ["▀█▀ ", " █ ", " █ ", " █ ", "▀▀▀ "],
'J' => [" ▀█", " █", " █", "█ █", "▀▀▀ "],
'K' => ["█ █", "█ █ ", "██ ", "█ █ ", "▀ ▀"],
'L' => ["█ ", "█ ", "█ ", "█ ", "▀▀▀▀"],
'M' => ["█▄ ▄█", "█ ▀ █", "█ █", "█ █", "▀ ▀"],
'N' => ["█▄ █", "█ █ █", "█ ██", "█ █", "▀ ▀"],
'O' => ["▄▀▀▄", "█ █", "█ █", "█ █", "▀▀▀▀"],
'P' => ["█▀▀▄", "█ █", "█▀▀ ", "█ ", "▀ "],
'Q' => ["▄▀▀▄ ", "█ █ ", "█ █ ", "█ ▄█ ", "▀▀▀▀▄"],
'R' => ["█▀▀▄", "█ █", "█▀▀▄", "█ █", "▀ ▀"],
'S' => ["▄▀▀▀", "█ ", "▀▀▀▄", " █", "▀▀▀ "],
'T' => ["▀▀█▀▀", " █ ", " █ ", " █ ", " ▀ "],
'U' => ["█ █", "█ █", "█ █", "█ █", "▀▀▀▀"],
'V' => ["█ █", "█ █", "█ █", " █ █ ", " ▀ "],
'W' => ["█ █", "█ █", "█ ▄ █", "█▀ ▀█", "▀ ▀"],
'X' => ["█ █", " █ █ ", " █ ", " █ █ ", "▀ ▀"],
'Y' => ["█ █", " █ █ ", " █ ", " █ ", " ▀ "],
'Z' => ["▀▀▀▀█", " █ ", " █ ", " █ ", "█▀▀▀▀"],
'0' => ["▄▀▀▄", "█ ▄█", "█▀ █", "█ █", "▀▀▀▀"],
'1' => [" ▄█ ", " █ ", " █ ", " █ ", " ▀▀▀"],
'2' => ["▀▀▀▄", " █", "▄▀▀▀", "█ ", "▀▀▀▀"],
'3' => ["▀▀▀▄", " █", " ▀▀█", " █", "▀▀▀ "],
'4' => ["█ █", "█ █", "▀▀▀█", " █", " ▀"],
'5' => ["█▀▀▀", "█ ", "▀▀▀▄", " █", "▀▀▀ "],
'6' => ["▄▀▀▀", "█ ", "█▀▀▄", "█ █", "▀▀▀▀"],
'7' => ["▀▀▀█", " █", " █ ", " █ ", " ▀ "],
'8' => ["▄▀▀▄", "█ █", "▄▀▀▄", "█ █", "▀▀▀▀"],
'9' => ["▄▀▀▄", "█ █", "▀▀▀█", " █", "▀▀▀ "],
_ => [" ", " ", " ", " ", " "],
}
}
/// Rendered width of `text` in cells.
pub fn width(text: &str) -> u16 {
text.chars().count() as u16 * CELL_W
}
pub struct Logo<'a> {
pub text: &'a str,
pub tick: u64,
pub accent: Rgb,
}
impl Widget for Logo<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let scan = ((self.tick / 2) % (ROWS as u64 + 10)) as u16;
for (i, c) in self.text.chars().enumerate() {
let broken = i % 7 == 3;
let f = flicker(self.tick, i as u8, broken);
let color = self.accent.scale(f);
let rows = glyph(c);
for (r, row) in rows.iter().enumerate() {
let y = area.y + r as u16;
if y >= area.bottom() {
break;
}
let bright = r as u16 == scan;
let fg = if bright {
color.mix(Rgb(255, 255, 255), 0.5)
} else {
color
};
for (col, ch) in row.chars().enumerate() {
if ch == ' ' {
continue;
}
let x = area.x + i as u16 * CELL_W + col as u16;
if x >= area.right() {
break;
}
buf[(x, y)].set_char(ch).set_fg(fg.to_color());
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::Rgb;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::Widget;
#[test]
fn logo_width_and_render() {
assert_eq!(width("AB"), 12);
for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ".chars() {
let g = glyph(c);
assert!(g.iter().all(|r| r.chars().count() <= 5), "{c}");
}
let mut t = Terminal::new(TestBackend::new(60, 7)).unwrap();
t.draw(|f| {
Logo {
text: "CITY 9",
tick: 3,
accent: Rgb(255, 0, 200),
}
.render(f.area(), f.buffer_mut())
})
.unwrap();
assert!((0..60).any(|x| t.backend().buffer()[(x, 0)].symbol() == "█"));
let mut tiny = Terminal::new(TestBackend::new(3, 2)).unwrap();
tiny.draw(|f| {
Logo {
text: "CITY",
tick: 3,
accent: Rgb(255, 0, 200),
}
.render(f.area(), f.buffer_mut())
})
.unwrap();
}
}

144
crates/city/src/ui/menu.rs Normal file
View File

@@ -0,0 +1,144 @@
//! An animated vertical menu: items slide in with a typewriter reveal.
use crate::anim::effects::{pulse, slide_x, typewriter};
use crate::anim::{Easing, Tween};
use crate::render::Rgb;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Widget;
pub struct Menu {
pub items: Vec<String>,
pub selected: usize,
/// Tick the menu appeared; drives the slide-in.
pub opened: u64,
}
impl Menu {
pub fn new(items: Vec<String>, opened: u64) -> Self {
Menu {
items,
selected: 0,
opened,
}
}
pub fn up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn down(&mut self) {
if self.selected + 1 < self.items.len() {
self.selected += 1;
}
}
/// Progress of item `i`'s entrance at `tick`.
pub fn entrance(&self, i: usize, tick: u64) -> f32 {
Tween::new(self.opened + i as u64 * 4, 8, Easing::OutCubic).progress(tick)
}
pub fn settled(&self, tick: u64) -> bool {
self.items
.iter()
.enumerate()
.all(|(i, _)| self.entrance(i, tick) >= 1.0)
}
}
pub struct MenuView<'a> {
pub menu: &'a Menu,
pub tick: u64,
pub accent: Rgb,
}
impl Widget for MenuView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
for (i, item) in self.menu.items.iter().enumerate() {
let y = area.y + i as u16;
if y >= area.bottom() {
break;
}
let p = self.menu.entrance(i, self.tick);
if p <= 0.0 {
continue;
}
let selected = i == self.menu.selected;
let prefix = if selected { "▸ " } else { " " };
let text = format!("{prefix}{}", typewriter(item, p));
let offset = slide_x(item.chars().count() as u16 + 2, p);
let style = if selected {
let glow = 0.6 + 0.4 * pulse(self.tick, 30);
Style::default()
.fg(self.accent.scale(glow).to_color())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Rgb(170, 180, 195))
};
for (j, ch) in text.chars().enumerate() {
let x = area.x as i32 + offset + j as i32;
if x < area.x as i32 || x >= area.right() as i32 {
continue;
}
buf[(x as u16, y)].set_char(ch).set_style(style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::Rgb;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
fn row(t: &Terminal<TestBackend>, y: u16) -> String {
(0..40)
.map(|x| t.backend().buffer()[(x, y)].symbol().to_string())
.collect()
}
#[test]
fn menu_slides_in_and_selects() {
let mut m = Menu::new(
vec!["NEW CITY".into(), "LOAD CITY".into(), "QUIT".into()],
0,
);
let mut t = Terminal::new(TestBackend::new(40, 10)).unwrap();
t.draw(|f| {
MenuView {
menu: &m,
tick: 0,
accent: Rgb(0, 255, 255),
}
.render(Rect::new(2, 2, 30, 6), f.buffer_mut())
})
.unwrap();
assert!(!row(&t, 2).contains("NEW CITY"));
t.draw(|f| {
MenuView {
menu: &m,
tick: 40,
accent: Rgb(0, 255, 255),
}
.render(Rect::new(2, 2, 30, 6), f.buffer_mut())
})
.unwrap();
assert!(row(&t, 2).contains("▸ NEW CITY"), "{}", row(&t, 2));
assert!(row(&t, 3).contains("LOAD CITY"));
assert!(row(&t, 4).contains("QUIT"));
m.down();
m.down();
m.down();
assert_eq!(m.selected, 2);
m.up();
assert_eq!(m.selected, 1);
m.up();
m.up();
assert_eq!(m.selected, 0);
}
}

View File

@@ -0,0 +1,7 @@
//! Widgets built on the animation primitives: logo, menu, form, panels, transitions.
pub mod form;
pub mod logo;
pub mod menu;
pub mod panel;
pub mod transition;

205
crates/city/src/ui/panel.rs Normal file
View File

@@ -0,0 +1,205 @@
//! Side panels: tile inspector, legend, strata selector, status bar.
use crate::anim::effects::pulse;
use crate::render::tileset::legend_entries;
use citygen::{City, Node, Pos, StratumKind, Terrain};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders};
const DIM: Color = Color::Rgb(120, 130, 145);
const TEXT: Color = Color::Rgb(205, 212, 222);
const ACCENT: Color = Color::Rgb(0, 229, 255);
fn kv(key: &str, value: impl Into<String>) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{key:<10}"), Style::default().fg(DIM)),
Span::styled(value.into(), Style::default().fg(TEXT)),
])
}
/// A bordered panel with a titled top edge.
pub fn boxed(title: &str) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(70, 90, 110)))
.title(Span::styled(
format!(" {title} "),
Style::default().fg(ACCENT),
))
}
/// Everything known about one tile, for the side panel.
pub fn inspector_lines(
city: &City,
stratum: StratumKind,
p: Pos,
node: Option<&Node>,
) -> Vec<Line<'static>> {
let mut out = vec![kv("pos", format!("{},{} {}", p.x, p.y, stratum.label()))];
let Some(t) = city.tile(stratum, p) else {
out.push(kv("terrain", "outside the map"));
return out;
};
out.push(kv("terrain", format!("{:?}", t.terrain).to_lowercase()));
if let Some(f) = t.feature {
out.push(kv("feature", format!("{f:?}").to_lowercase()));
}
if let Some(d) = t.district {
let d = city.district(d);
out.push(kv("district", d.name.clone()));
out.push(kv("theme", d.theme.label()));
out.push(kv(
"wealth",
format!("{:.0}% decay {:.0}%", d.wealth * 100.0, d.decay * 100.0),
));
}
if let Some(b) = t.building {
let b = city.building(b);
out.push(kv(
"building",
b.name.clone().unwrap_or_else(|| b.kind.label().to_string()),
));
out.push(kv(
"kind",
format!("{} {}x{}", b.kind.label(), b.footprint.w, b.footprint.h),
));
out.push(kv(
"floors",
format!("{} → {}", b.span.lo.label(), b.span.hi.label()),
));
}
for c in city.connectors_at(p) {
if !c.serves.contains(&stratum) {
continue;
}
let served: Vec<&str> = c.serves.iter().map(|k| k.label()).collect();
out.push(kv(c.kind.label(), served.join(" · ")));
if c.kind == citygen::ConnectorKind::Lift {
for k in &c.serves {
let marker = if *k == stratum { "▸" } else { " " };
out.push(Line::from(Span::styled(
format!(" {marker} {}", k.label()),
Style::default().fg(if *k == stratum { ACCENT } else { DIM }),
)));
}
}
}
if let Some(n) = node {
let sn = city.net.subnet(n.subnet);
out.push(Line::from(Span::styled(
"── net ──",
Style::default().fg(Color::Rgb(0, 200, 160)),
)));
out.push(kv("node", n.kind.label()));
let owner = match sn.owner {
citygen::SubnetOwner::Building(b) => city
.building(b)
.name
.clone()
.unwrap_or_else(|| city.building(b).kind.label().to_string()),
citygen::SubnetOwner::District(d) => city.district(d).name.clone(),
citygen::SubnetOwner::City => city.name.clone(),
};
out.push(kv("subnet", owner));
out.push(kv("security", format!("{}", sn.security)));
}
if t.terrain == Terrain::Water {
out.push(kv("note", "deep water"));
}
out
}
pub fn legend(stratum: StratumKind) -> Vec<Line<'static>> {
legend_entries(stratum)
.into_iter()
.map(|(g, label)| {
Line::from(vec![
Span::styled(format!(" {g} "), Style::default().fg(ACCENT)),
Span::styled(label.to_string(), Style::default().fg(TEXT)),
])
})
.collect()
}
/// The four physical strata, top first, with the current one marked.
pub fn strata_selector(current: StratumKind, tick: u64) -> Vec<Line<'static>> {
StratumKind::PHYSICAL
.iter()
.rev()
.map(|k| {
let here = *k == current;
let marker = if here { "▌" } else { " " };
let style = if here {
let g = (160.0 + 95.0 * pulse(tick, 40)) as u8;
Style::default()
.fg(Color::Rgb(0, g, 255))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(DIM)
};
Line::from(Span::styled(format!("{marker} {}", k.label()), style))
})
.collect()
}
pub fn status_bar(
city: Option<&City>,
stratum: StratumKind,
fps: f32,
hints: &str,
) -> Line<'static> {
let mut spans = Vec::new();
if let Some(c) = city {
spans.push(Span::styled(
format!(" {} ", c.name),
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("seed {} ", c.seed),
Style::default().fg(DIM),
));
}
spans.push(Span::styled(
format!("· {} ", stratum.label()),
Style::default().fg(TEXT),
));
spans.push(Span::styled(
format!("· {fps:.0} fps "),
Style::default().fg(DIM),
));
spans.push(Span::styled(format!("· {hints}"), Style::default().fg(DIM)));
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use citygen::{GenParams, Generator, Pos, SizePreset, StratumKind};
#[test]
fn inspector_and_selector_have_content() {
let c = Generator::run(GenParams {
seed: 3,
size: SizePreset::Custom { w: 80, h: 40 },
districts: 4,
..Default::default()
})
.unwrap();
let b = &c.buildings[0];
let p = b.footprint.center();
let lines = inspector_lines(&c, StratumKind::Street, p, None);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains(&format!("{},{}", p.x, p.y)));
assert!(text.contains(b.kind.label()));
let off = inspector_lines(&c, StratumKind::Street, Pos::new(-5, -5), None);
assert!(!off.is_empty());
let sel = strata_selector(StratumKind::Skyways, 0);
let text: String = sel.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("Skyways") && text.contains("▌"));
let bar = status_bar(Some(&c), StratumKind::Street, 30.0, "q quit");
assert!(bar.to_string().contains(&c.name));
assert!(!legend(StratumKind::Undercity).is_empty());
}
}

View File

@@ -0,0 +1,124 @@
//! Screen transitions: a glitch wipe or a vertical slide, a few ticks long.
use crate::anim::effects::glitch_cells;
use citygen::rng::hash64;
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::style::Color;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransitionKind {
None,
Glitch,
SlideUp,
}
/// A transition from a snapshot of the previous frame into the new screen.
pub struct Transition {
pub kind: TransitionKind,
pub from: Buffer,
pub start: u64,
pub dur: u64,
}
impl Transition {
pub fn new(kind: TransitionKind, from: Buffer, start: u64) -> Self {
let dur = match kind {
TransitionKind::None => 0,
TransitionKind::Glitch => 6,
TransitionKind::SlideUp => 8,
};
Transition {
kind,
from,
start,
dur,
}
}
pub fn active(&self, tick: u64) -> bool {
self.dur > 0 && tick >= self.start && tick < self.start + self.dur
}
fn progress(&self, tick: u64) -> f32 {
if self.dur == 0 {
return 1.0;
}
((tick.saturating_sub(self.start)) as f32 / self.dur as f32).clamp(0.0, 1.0)
}
/// Draws the effect over the already-rendered new frame.
pub fn apply(&self, f: &mut Frame, tick: u64) {
if !self.active(tick) {
return;
}
let p = self.progress(tick);
let area = f.area();
let buf = f.buffer_mut();
let same_size = self.from.area.width == area.width && self.from.area.height == area.height;
match self.kind {
TransitionKind::None => {}
TransitionKind::Glitch => {
if same_size && p < 0.5 {
for y in 0..area.height {
if hash64(tick, y as u64).is_multiple_of(3) {
for x in 0..area.width {
let src = &self.from[(x, y)];
buf[(area.x + x, area.y + y)] = src.clone();
}
}
}
}
for (x, y, ch) in glitch_cells(area, tick, p) {
let tint = if hash64(x as u64, y as u64).is_multiple_of(2) {
Color::Rgb(0, 255, 230)
} else {
Color::Rgb(255, 0, 200)
};
buf[(x, y)].set_char(ch).set_fg(tint);
}
}
TransitionKind::SlideUp => {
if !same_size {
return;
}
let shift = (p * area.height as f32).round() as u16;
for y in 0..area.height.saturating_sub(shift) {
for x in 0..area.width {
let src = &self.from[(x, y + shift)];
buf[(area.x + x, area.y + y)] = src.clone();
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
#[test]
fn glitch_transition_ends_and_applies() {
let from = Buffer::empty(Rect::new(0, 0, 20, 5));
let tr = Transition::new(TransitionKind::Glitch, from.clone(), 10);
assert!(tr.active(12));
assert!(!tr.active(17));
assert!(!Transition::new(TransitionKind::None, from.clone(), 10).active(10));
let mut t = Terminal::new(TestBackend::new(20, 5)).unwrap();
for kind in [TransitionKind::Glitch, TransitionKind::SlideUp] {
let tr = Transition::new(kind, from.clone(), 10);
for tick in 10..20 {
t.draw(|f| tr.apply(f, tick)).unwrap();
}
}
// A snapshot of a different size is ignored, not indexed out of bounds.
let big = Buffer::empty(Rect::new(0, 0, 80, 30));
let tr = Transition::new(TransitionKind::SlideUp, big, 10);
t.draw(|f| tr.apply(f, 12)).unwrap();
}
}