use ratatui::Frame;
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use crate::app::{App, LiveScreen, Mode, ReplayScreen};
use crate::ui::theme::Theme;
use crate::ui::view::ViewState;
pub mod chain;
pub mod depth;
pub mod driver;
pub mod graph;
pub mod payoff;
pub mod replay;
pub mod surface;
pub mod theme;
pub mod view;
#[cfg(test)]
pub(crate) mod golden;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RootLayout {
pub status: Rect,
pub body: Rect,
pub hint: Rect,
}
#[must_use]
pub fn layout_root(area: Rect) -> RootLayout {
let [status, body, hint] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(area);
RootLayout { status, body, hint }
}
pub fn render(app: &App, view: &ViewState, frame: &mut Frame) {
let area = frame.area();
let theme = Theme::resolve(app.theme, app.no_color);
if theme::is_too_small(area) {
theme::draw_too_small(frame, area, theme);
return;
}
let root = layout_root(area);
theme::draw_status(app, frame, root.status, theme);
match &app.mode {
Mode::Live(state) => match state.screen {
LiveScreen::Chain => {
chain::draw(state, frame, root.body, theme, app.tick_count, app.now)
}
LiveScreen::Depth => depth::draw(state, frame, root.body, theme, app.tick_count),
LiveScreen::Surface => surface::draw(
state,
view.surface(),
frame,
root.body,
theme,
app.tick_count,
),
LiveScreen::Payoff => payoff::draw(state, view.payoff(), frame, root.body, theme),
},
Mode::Replay(state) => match state.screen {
ReplayScreen::Replay => replay::draw(
state,
view.replay(),
frame,
root.body,
theme,
app.tick_count,
),
ReplayScreen::Payoff => {
payoff::draw_replay(state, view.replay_payoff(), frame, root.body, theme)
}
},
}
theme::draw_hint(app, frame, root.hint, theme);
if app.help_open {
theme::draw_help_overlay(app, frame, root.body, theme);
}
}
pub(crate) fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
let [row] = Layout::vertical([Constraint::Length(height)])
.flex(Flex::Center)
.areas(area);
let [cell] = Layout::horizontal([Constraint::Length(width)])
.flex(Flex::Center)
.areas(row);
cell
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use super::{layout_root, render};
use crate::app::tests_support::{
live_app_on, ready_replay_app, ready_replay_app_with_fills, replay_app_on,
};
use crate::app::{LiveScreen, Mode, ReplayScreen, ScreenLoad};
use crate::ui::view::ViewState;
#[track_caller]
fn terminal(width: u16, height: u16) -> Terminal<TestBackend> {
match Terminal::new(TestBackend::new(width, height)) {
Ok(t) => t,
Err(e) => panic!("TestBackend terminal construction failed: {e}"),
}
}
#[test]
fn test_layout_root_splits_status_body_hint() {
let root = layout_root(Rect::new(0, 0, 80, 24));
assert_eq!(root.status.height, 1, "the status bar is one line");
assert_eq!(root.hint.height, 1, "the hint line is one line");
assert_eq!(root.body.height, 22, "the body takes the remaining rows");
assert_eq!(root.status.y, 0);
assert_eq!(root.body.y, 1);
assert_eq!(root.hint.y, 23);
}
#[test]
fn test_layout_root_zero_area_yields_zero_regions_without_panic() {
let root = layout_root(Rect::new(0, 0, 0, 0));
assert_eq!(root.status.width, 0);
assert_eq!(root.body.height, 0);
}
#[test]
fn test_render_is_pure_does_not_mutate_app() {
let app = live_app_on(LiveScreen::Payoff, ScreenLoad::Loading, true);
let mut view = ViewState::new();
view.sync(&app);
let before_projection = view.payoff().clone();
let before = (app.dirty, app.help_open, app.should_quit);
let before_screen = match &app.mode {
Mode::Live(s) => s.screen,
Mode::Replay(_) => panic!("expected a live app"),
};
let mut terminal = terminal(80, 24);
match terminal.draw(|frame| render(&app, &view, frame)) {
Ok(_) => {}
Err(e) => panic!("draw failed: {e}"),
}
let after = (app.dirty, app.help_open, app.should_quit);
let after_screen = match &app.mode {
Mode::Live(s) => s.screen,
Mode::Replay(_) => panic!("expected a live app"),
};
assert_eq!(before, after, "render must not mutate flags");
assert_eq!(
before_screen, after_screen,
"render must not switch screens"
);
assert_eq!(
&before_projection,
view.payoff(),
"render must not rebuild or mutate the payoff projection",
);
}
#[test]
fn test_render_help_overlay_open_and_closed_never_panics() {
for help in [false, true] {
let app = live_app_on(LiveScreen::Chain, ScreenLoad::Loading, help);
let mut view = ViewState::new();
view.sync(&app);
let mut terminal = terminal(100, 30);
match terminal.draw(|frame| render(&app, &view, frame)) {
Ok(_) => {}
Err(e) => panic!("draw failed (help={help}): {e}"),
}
}
}
#[test]
fn test_render_every_reachable_live_screen_never_panics() {
let screens = [
LiveScreen::Chain,
LiveScreen::Depth,
LiveScreen::Surface,
LiveScreen::Payoff,
];
let loads = [
ScreenLoad::Loading,
ScreenLoad::Ready,
ScreenLoad::Error {
message: "provider unreachable".to_owned(),
},
];
for screen in screens {
for load in &loads {
let app = live_app_on(screen, load.clone(), false);
let mut view = ViewState::new();
view.sync(&app);
let mut terminal = terminal(120, 40);
match terminal.draw(|frame| render(&app, &view, frame)) {
Ok(_) => {}
Err(e) => panic!("draw failed ({screen:?}/{load:?}): {e}"),
}
}
}
}
#[test]
fn test_render_every_reachable_replay_screen_never_panics() {
for screen in [ReplayScreen::Replay, ReplayScreen::Payoff] {
let app = replay_app_on(screen, false);
let mut view = ViewState::new();
view.sync(&app);
let mut terminal = terminal(120, 40);
match terminal.draw(|frame| render(&app, &view, frame)) {
Ok(_) => {}
Err(e) => panic!("draw failed ({screen:?}): {e}"),
}
}
}
#[test]
fn test_render_ready_replay_screen_never_panics() {
let (with_fills, _rx1) = ready_replay_app_with_fills(6);
let (empty_run, _rx2) = ready_replay_app(0);
for app in [&with_fills, &empty_run] {
let mut view = ViewState::new();
view.sync(app);
for (w, h) in [(1u16, 1u16), (40, 8), (120, 40)] {
let mut terminal = terminal(w, h);
match terminal.draw(|frame| render(app, &view, frame)) {
Ok(_) => {}
Err(e) => panic!("ready replay render failed at {w}x{h}: {e}"),
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, max_shrink_iters: 50_000, ..ProptestConfig::default() })]
#[test]
fn prop_render_never_panics(
mode_idx in 0u8..2,
live_screen_idx in 0u8..4,
replay_screen_idx in 0u8..2,
load_idx in 0u8..3,
help in any::<bool>(),
width in 1u16..160,
height in 1u16..60,
) {
let app = if mode_idx == 0 {
let screen = match live_screen_idx {
0 => LiveScreen::Chain,
1 => LiveScreen::Depth,
2 => LiveScreen::Surface,
_ => LiveScreen::Payoff,
};
let load = match load_idx {
0 => ScreenLoad::Loading,
1 => ScreenLoad::Ready,
_ => ScreenLoad::Error {
message: "provider unreachable".to_owned(),
},
};
live_app_on(screen, load, help)
} else {
let screen = if replay_screen_idx == 0 {
ReplayScreen::Replay
} else {
ReplayScreen::Payoff
};
replay_app_on(screen, help)
};
let mut view = ViewState::new();
view.sync(&app);
let mut terminal = terminal(width, height);
match terminal.draw(|frame| render(&app, &view, frame)) {
Ok(_) => {}
Err(e) => prop_assert!(false, "draw failed at {width}x{height}: {e}"),
}
}
}
}