From acd0286bab3c96385e4a071679f1e04270b948df Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 17:45:16 +0200 Subject: [PATCH] feat(city): logo, menu, form, panels and transitions Co-Authored-By: Claude Fable 5.1 --- crates/city/src/app.rs | 22 +- crates/city/src/main.rs | 1 + crates/city/src/ui/form.rs | 359 +++++++++++++++++++++++++++++++ crates/city/src/ui/logo.rs | 138 ++++++++++++ crates/city/src/ui/menu.rs | 144 +++++++++++++ crates/city/src/ui/mod.rs | 7 + crates/city/src/ui/panel.rs | 205 ++++++++++++++++++ crates/city/src/ui/transition.rs | 124 +++++++++++ 8 files changed, 979 insertions(+), 21 deletions(-) create mode 100644 crates/city/src/ui/form.rs create mode 100644 crates/city/src/ui/logo.rs create mode 100644 crates/city/src/ui/menu.rs create mode 100644 crates/city/src/ui/mod.rs create mode 100644 crates/city/src/ui/panel.rs create mode 100644 crates/city/src/ui/transition.rs diff --git a/crates/city/src/app.rs b/crates/city/src/app.rs index de12c3f..991d442 100644 --- a/crates/city/src/app.rs +++ b/crates/city/src/app.rs @@ -6,7 +6,6 @@ use anyhow::Result; use citygen::{City, GenParams}; use crossterm::event::{self, Event, KeyEvent, KeyEventKind}; use ratatui::Frame; -use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Rect}; use ratatui::style::{Color, Style}; use ratatui::widgets::Paragraph; @@ -55,26 +54,7 @@ impl AppState { } } -/// How a screen change is animated. Replaced by `ui::transition` later. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TransitionKind { - None, - Glitch, - SlideUp, -} - -/// Placeholder transition: inactive until the ui module lands. -pub struct Transition; - -impl Transition { - pub fn new(_kind: TransitionKind, _from: Buffer, _start: u64) -> Self { - Transition - } - pub fn active(&self, _tick: u64) -> bool { - false - } - pub fn apply(&self, _f: &mut Frame, _tick: u64) {} -} +pub use crate::ui::transition::{Transition, TransitionKind}; /// What a screen asks the loop to do after an update. pub enum ScreenCmd { diff --git a/crates/city/src/main.rs b/crates/city/src/main.rs index 022e279..e5deff5 100644 --- a/crates/city/src/main.rs +++ b/crates/city/src/main.rs @@ -10,6 +10,7 @@ mod render; mod save; mod screens; mod term; +mod ui; fn main() -> anyhow::Result<()> { cli::run() diff --git a/crates/city/src/ui/form.rs b/crates/city/src/ui/form.rs new file mode 100644 index 0000000..6d616f3 --- /dev/null +++ b/crates/city/src/ui/form.rs @@ -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, + 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, + 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 { + 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 { + 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 { + 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!( + "{}{:= 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::() + + "\n" + }) + .collect(); + assert!(text.contains("Districts") && text.contains("GENERATE") && text.contains("◉")); + } +} diff --git a/crates/city/src/ui/logo.rs b/crates/city/src/ui/logo.rs new file mode 100644 index 0000000..1815084 --- /dev/null +++ b/crates/city/src/ui/logo.rs @@ -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(); + } +} diff --git a/crates/city/src/ui/menu.rs b/crates/city/src/ui/menu.rs new file mode 100644 index 0000000..f156da7 --- /dev/null +++ b/crates/city/src/ui/menu.rs @@ -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, + pub selected: usize, + /// Tick the menu appeared; drives the slide-in. + pub opened: u64, +} + +impl Menu { + pub fn new(items: Vec, 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, 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); + } +} diff --git a/crates/city/src/ui/mod.rs b/crates/city/src/ui/mod.rs new file mode 100644 index 0000000..620bc0e --- /dev/null +++ b/crates/city/src/ui/mod.rs @@ -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; diff --git a/crates/city/src/ui/panel.rs b/crates/city/src/ui/panel.rs new file mode 100644 index 0000000..f569cbc --- /dev/null +++ b/crates/city/src/ui/panel.rs @@ -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) -> 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> { + 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> { + 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> { + 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()); + } +} diff --git a/crates/city/src/ui/transition.rs b/crates/city/src/ui/transition.rs new file mode 100644 index 0000000..be3fb25 --- /dev/null +++ b/crates/city/src/ui/transition.rs @@ -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(); + } +}