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