feat(city): logo, menu, form, panels and transitions
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ mod render;
|
||||
mod save;
|
||||
mod screens;
|
||||
mod term;
|
||||
mod ui;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
cli::run()
|
||||
|
||||
359
crates/city/src/ui/form.rs
Normal file
359
crates/city/src/ui/form.rs
Normal 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
138
crates/city/src/ui/logo.rs
Normal 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
144
crates/city/src/ui/menu.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
7
crates/city/src/ui/mod.rs
Normal file
7
crates/city/src/ui/mod.rs
Normal 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
205
crates/city/src/ui/panel.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
124
crates/city/src/ui/transition.rs
Normal file
124
crates/city/src/ui/transition.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user