Files
cyberpunk/crates/city/src/app.rs
2026-10-12 17:33:18 +02:00

191 lines
5.2 KiB
Rust

//! Application state, the screen trait and the fixed-rate frame loop.
use crate::term::{TerminalGuard, install_panic_hook};
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;
use std::time::{Duration, Instant};
pub const MIN_W: u16 = 100;
pub const MIN_H: u16 = 30;
pub const FRAME: Duration = Duration::from_micros(33_333);
/// State shared by every screen.
pub struct AppState {
pub params: GenParams,
pub city: Option<City>,
pub tick: u64,
status: Option<(String, u64)>,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
AppState {
params: GenParams::default(),
city: None,
tick: 0,
status: None,
}
}
/// Shows a one-line message for `ttl` ticks.
pub fn set_status(&mut self, msg: impl Into<String>, ttl: u64) {
self.status = Some((msg.into(), self.tick + ttl));
}
pub fn status_text(&self) -> Option<&str> {
match &self.status {
Some((msg, until)) if *until > self.tick => Some(msg.as_str()),
_ => None,
}
}
}
/// 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) {}
}
/// What a screen asks the loop to do after an update.
pub enum ScreenCmd {
Stay,
Switch {
to: Box<dyn Screen>,
fx: TransitionKind,
},
Quit,
}
pub trait Screen {
fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd;
fn draw(&self, st: &AppState, f: &mut Frame);
}
/// Centered notice for terminals under the minimum size. Never panics, even at 1x1.
pub fn draw_too_small(f: &mut Frame) {
let area = f.area();
let msg = format!(
"resize to at least {MIN_W}x{MIN_H} (now {}x{})",
area.width, area.height
);
let y = area.height / 2;
let line = Rect::new(area.x, area.y + y, area.width, 1.min(area.height));
f.render_widget(
Paragraph::new(msg)
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Rgb(255, 80, 120))),
line,
);
}
/// The fixed-rate loop: poll input, tick, update, draw.
pub fn run(mut screen: Box<dyn Screen>, mut st: AppState) -> Result<()> {
install_panic_hook();
let (_guard, mut term) = TerminalGuard::enter()?;
let mut transition: Option<Transition> = None;
loop {
let start = Instant::now();
let mut keys = Vec::new();
while event::poll(Duration::ZERO)? {
if let Event::Key(k) = event::read()?
&& k.kind == KeyEventKind::Press
{
keys.push(k);
}
}
st.tick += 1;
if transition.as_ref().is_some_and(|t| t.active(st.tick)) && !keys.is_empty() {
transition = None;
}
match screen.update(&mut st, &keys) {
ScreenCmd::Stay => {}
ScreenCmd::Switch { to, fx } => {
let snapshot = term.current_buffer_mut().clone();
screen = to;
transition = Some(Transition::new(fx, snapshot, st.tick));
}
ScreenCmd::Quit => break,
}
term.draw(|f| {
if f.area().width < MIN_W || f.area().height < MIN_H {
draw_too_small(f);
return;
}
screen.draw(&st, f);
if let Some(t) = &transition {
t.apply(f, st.tick);
}
})?;
if let Some(rest) = FRAME.checked_sub(start.elapsed()) {
std::thread::sleep(rest);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn text(t: &Terminal<TestBackend>) -> String {
let b = t.backend().buffer();
let mut s = String::new();
for y in 0..b.area.height {
for x in 0..b.area.width {
s.push_str(b[(x, y)].symbol());
}
s.push('\n');
}
s
}
#[test]
fn too_small_message_never_panics() {
for (w, h) in [(1u16, 1u16), (99, 29), (10, 2)] {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(draw_too_small).unwrap();
}
let mut t = Terminal::new(TestBackend::new(99, 29)).unwrap();
t.draw(draw_too_small).unwrap();
assert!(text(&t).contains("100"));
}
#[test]
fn app_state_status_expires() {
let mut st = AppState::new();
st.set_status("hi", 10);
assert_eq!(st.status_text(), Some("hi"));
st.tick += 11;
assert_eq!(st.status_text(), None);
}
}