sim_restaurant/tui/
ui.rs

1// Copyright (c) 2026 Graphcore Ltd. All rights reserved.
2
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Paragraph, Wrap};
7use ratatui::{Frame, symbols};
8
9use crate::tui::app::{App, PlotConfig};
10
11const PLOT_TIME_LABEL_COUNT: usize = 8;
12
13pub fn render(app: &mut App, frame: &mut Frame) {
14    let vertical = Layout::default()
15        .direction(Direction::Vertical)
16        .constraints([
17            Constraint::Length(3),
18            Constraint::Min(10),
19            Constraint::Length(3),
20        ])
21        .split(frame.area());
22
23    render_header(app, frame, vertical[0]);
24    render_body(app, frame, vertical[1]);
25    render_footer(frame, vertical[2]);
26}
27
28fn render_header(app: &App, frame: &mut Frame, area: Rect) {
29    let title = format!(
30        "Staff {} | Tick {} / {} | Frame {} / {} | {} | Window {} ticks",
31        app.recording.staffing,
32        app.current_tick(),
33        app.recording.summary.finish_tick,
34        app.frame_index + 1,
35        app.recording.timeline.len(),
36        if app.playing {
37            format!("playing {}", app.speed_label())
38        } else {
39            format!("paused {}", app.speed_label())
40        },
41        app.window_size_ticks()
42    );
43
44    let summary = &app.recording.summary;
45    let lines = vec![
46        Line::from(Span::styled(
47            title,
48            Style::default().add_modifier(Modifier::BOLD),
49        )),
50        Line::from(format!(
51            "Arrivals {} | Served {} | Balked {} | GaveUp {} | Abandoned {} | Profit {:.2}",
52            summary.arrivals,
53            summary.served,
54            summary.balked,
55            summary.gave_up_queue,
56            summary.abandoned,
57            summary.profit
58        )),
59    ];
60
61    frame.render_widget(
62        Paragraph::new(lines)
63            .block(Block::default().borders(Borders::ALL).title("Playback"))
64            .wrap(Wrap { trim: true }),
65        area,
66    );
67}
68
69fn render_body(app: &App, frame: &mut Frame, area: Rect) {
70    let horizontal = Layout::default()
71        .direction(Direction::Horizontal)
72        .constraints([Constraint::Percentage(36), Constraint::Percentage(64)])
73        .split(area);
74
75    render_state_panels(app, frame, horizontal[0]);
76    render_plots(app, frame, horizontal[1]);
77}
78
79fn render_state_panels(app: &App, frame: &mut Frame, area: Rect) {
80    let sections = Layout::default()
81        .direction(Direction::Vertical)
82        .constraints([
83            Constraint::Length(9),
84            Constraint::Length(10),
85            Constraint::Min(8),
86        ])
87        .split(area);
88
89    render_system_state(app, frame, sections[0]);
90    render_customer_states(app, frame, sections[1]);
91    render_recent_events(app, frame, sections[2]);
92}
93
94fn render_system_state(app: &App, frame: &mut Frame, area: Rect) {
95    let snapshot = app.current_snapshot();
96    let metrics = &snapshot.metrics;
97    let staffing_cost = accrued_staffing_cost(app);
98    let overview = vec![
99        formatted_line(
100            vec![
101                ("Till queue", format!("{:>3}", snapshot.till_queue.len())),
102                (
103                    "Kitchen queue",
104                    format!("{:>3}", snapshot.kitchen_queue.len()),
105                ),
106            ],
107            "    ",
108        ),
109        formatted_line(
110            vec![
111                ("Busy till", format!("{:>3}", snapshot.active_till_workers)),
112                (
113                    "Busy kitchen",
114                    format!("{:>3}", snapshot.active_kitchen_workers),
115                ),
116            ],
117            "     ",
118        ),
119        formatted_line(
120            vec![
121                ("Revenue", format!("{:>8.2}", metrics.revenue)),
122                (
123                    "Ingredient cost",
124                    format!("{:>8.2}", metrics.ingredient_cost),
125                ),
126            ],
127            "   ",
128        ),
129        formatted_line(
130            vec![("Staffing cost", format!("{staffing_cost:>8.2}"))],
131            "   ",
132        ),
133        formatted_line(
134            vec![
135                ("Orders started", format!("{:>3}", metrics.orders_started)),
136                ("served", format!("{:>3}", metrics.orders_served)),
137                ("abandoned", format!("{:>3}", metrics.orders_abandoned)),
138            ],
139            "   ",
140        ),
141        formatted_line(
142            vec![
143                ("Closed", snapshot.closed.to_string()),
144                ("Arrivals complete", snapshot.arrivals_complete.to_string()),
145            ],
146            "   ",
147        ),
148    ];
149
150    frame.render_widget(
151        Paragraph::new(overview)
152            .block(Block::default().borders(Borders::ALL).title("System State"))
153            .wrap(Wrap { trim: true }),
154        area,
155    );
156}
157
158fn render_customer_states(app: &App, frame: &mut Frame, area: Rect) {
159    let snapshot = app.current_snapshot();
160    let counts = &snapshot.customer_counts;
161    let customer_lines = vec![
162        formatted_line(
163            vec![
164                ("Planned", format!("{:>3}", counts.planned)),
165                ("Balked", format!("{:>3}", counts.balked)),
166            ],
167            "    ",
168        ),
169        formatted_line(
170            vec![
171                ("Waiting till", format!("{:>3}", counts.waiting_till)),
172                ("At till", format!("{:>3}", counts.at_till)),
173            ],
174            "   ",
175        ),
176        formatted_line(
177            vec![
178                ("Waiting kitchen", format!("{:>3}", counts.waiting_kitchen)),
179                ("Preparing", format!("{:>3}", counts.preparing_food)),
180            ],
181            "   ",
182        ),
183        formatted_line(
184            vec![
185                ("Collecting", format!("{:>3}", counts.collecting_food)),
186                ("Served", format!("{:>3}", counts.served)),
187            ],
188            "   ",
189        ),
190        formatted_line(
191            vec![
192                ("Gave up", format!("{:>3}", counts.gave_up_queue)),
193                ("Abandoned", format!("{:>3}", counts.abandoned)),
194            ],
195            "   ",
196        ),
197    ];
198    frame.render_widget(
199        Paragraph::new(customer_lines)
200            .block(
201                Block::default()
202                    .borders(Borders::ALL)
203                    .title("Customer States"),
204            )
205            .wrap(Wrap { trim: true }),
206        area,
207    );
208}
209
210fn render_recent_events(app: &App, frame: &mut Frame, area: Rect) {
211    let visible_event_lines = usize::from(area.height.saturating_sub(2)).max(1);
212    let mut event_lines = Vec::new();
213    for event in app.recent_events(visible_event_lines).into_iter().rev() {
214        event_lines.push(Line::from(format!("[{:>5}] {}", event.tick, event.message)));
215    }
216    if event_lines.is_empty() {
217        event_lines.push(Line::from("No events yet"));
218    }
219    frame.render_widget(
220        Paragraph::new(event_lines)
221            .block(
222                Block::default()
223                    .borders(Borders::ALL)
224                    .title("Recent Events"),
225            )
226            .wrap(Wrap { trim: false }),
227        area,
228    );
229}
230
231fn render_plots(app: &App, frame: &mut Frame, area: Rect) {
232    let plot_areas = Layout::default()
233        .direction(Direction::Vertical)
234        .constraints([
235            Constraint::Percentage(25),
236            Constraint::Percentage(25),
237            Constraint::Percentage(25),
238            Constraint::Percentage(25),
239        ])
240        .split(area);
241
242    for (index, plot_area) in plot_areas.iter().enumerate() {
243        render_plot(app, frame, *plot_area, index, app.plots[index]);
244    }
245}
246
247fn render_plot(app: &App, frame: &mut Frame, area: Rect, index: usize, plot: PlotConfig) {
248    let salary_cost = app.recording.summary.salary_cost;
249    let day_ticks = app.recording.day_ticks;
250    let window_ticks = app.window_size_ticks();
251    let data: Vec<(f64, f64)> = app
252        .recording
253        .timeline
254        .iter()
255        .enumerate()
256        .take(app.frame_index + 1)
257        .map(|(point_index, point)| {
258            (
259                point.tick as f64,
260                plot_value(
261                    &app.recording.timeline,
262                    point_index,
263                    plot,
264                    day_ticks,
265                    salary_cost,
266                    window_ticks,
267                ),
268            )
269        })
270        .collect();
271
272    let max_x = app.recording.summary.finish_tick.max(1) as f64;
273    let min_y = data
274        .iter()
275        .map(|(_, y)| *y)
276        .fold(0.0_f64, f64::min)
277        .min(0.0);
278    let max_y = data
279        .iter()
280        .map(|(_, y)| *y)
281        .fold(0.0_f64, f64::max)
282        .max(1.0);
283    let title = format!(
284        "Plot {}: {}{}",
285        index + 1,
286        plot.stat.title(plot.windowed, window_ticks),
287        if app.selected_plot == index {
288            " <selected>"
289        } else {
290            ""
291        }
292    );
293
294    let dataset = Dataset::default()
295        .marker(symbols::Marker::Braille)
296        .style(Style::default().fg(match index {
297            0 => Color::Cyan,
298            1 => Color::Yellow,
299            _ => Color::Green,
300        }))
301        .graph_type(ratatui::widgets::GraphType::Line)
302        .data(&data);
303
304    let mut datasets = Vec::new();
305    let zero_line = [(0.0, 0.0), (max_x, 0.0)];
306    if min_y < 0.0 && max_y > 0.0 {
307        datasets.push(
308            Dataset::default()
309                .style(Style::default().fg(Color::DarkGray))
310                .graph_type(ratatui::widgets::GraphType::Line)
311                .data(&zero_line),
312        );
313    }
314    datasets.push(dataset);
315
316    let chart = Chart::new(datasets)
317        .block(
318            Block::default()
319                .borders(Borders::ALL)
320                .title(title)
321                .border_style(if app.selected_plot == index {
322                    Style::default().fg(Color::Blue)
323                } else {
324                    Style::default()
325                }),
326        )
327        .x_axis(
328            Axis::default()
329                .bounds([0.0, max_x])
330                .labels(plot_time_labels(app, max_x)),
331        )
332        .y_axis(Axis::default().bounds([min_y, max_y]).labels(vec![
333            Span::raw(format!("{min_y:.1}")),
334            Span::raw(format!("{:.1}", f64::midpoint(min_y, max_y))),
335            Span::raw(format!("{max_y:.1}")),
336        ]));
337
338    frame.render_widget(chart, area);
339}
340
341fn plot_time_labels(app: &App, max_x: f64) -> Vec<Span<'static>> {
342    if PLOT_TIME_LABEL_COUNT <= 2 {
343        return vec![time_label(app, 0), time_label(app, max_x.round() as u64)];
344    }
345
346    (0..PLOT_TIME_LABEL_COUNT)
347        .map(|index| {
348            let position = max_x * index as f64 / (PLOT_TIME_LABEL_COUNT - 1) as f64;
349
350            time_label(app, position.round() as u64)
351        })
352        .collect()
353}
354
355fn time_label(app: &App, offset_ticks: u64) -> Span<'static> {
356    Span::raw(
357        app.recording
358            .opening_time
359            .add_ticks(offset_ticks)
360            .round_to_nearest_quarter_hour()
361            .to_string(),
362    )
363}
364
365fn render_footer(frame: &mut Frame, area: Rect) {
366    let key_style = Style::default().add_modifier(Modifier::BOLD);
367    let help = Line::from(vec![
368        Span::styled("q", key_style),
369        Span::raw(" quit | "),
370        Span::styled("space", key_style),
371        Span::raw(" play/pause | "),
372        Span::styled("g/G", key_style),
373        Span::raw(" start/end | "),
374        Span::styled("h/l", key_style),
375        Span::raw(" or "),
376        Span::styled("left/right", key_style),
377        Span::raw(" step | "),
378        Span::styled("[ ]", key_style),
379        Span::raw(" speed | "),
380        Span::styled("{ }", key_style),
381        Span::raw(" window | "),
382        Span::styled("w", key_style),
383        Span::raw(" toggle windowed | "),
384        Span::styled("1-4", key_style),
385        Span::raw(" select plot | "),
386        Span::styled("j/k", key_style),
387        Span::raw(" or "),
388        Span::styled("up/down", key_style),
389        Span::raw(" change stat"),
390    ]);
391    frame.render_widget(
392        Paragraph::new(help).block(Block::default().borders(Borders::ALL).title("Controls")),
393        area,
394    );
395}
396
397fn formatted_line(items: Vec<(&str, String)>, separator: &str) -> Line<'static> {
398    let label_style = Style::default().add_modifier(Modifier::BOLD);
399    let mut spans = Vec::new();
400
401    for (index, (label, value)) in items.into_iter().enumerate() {
402        if index > 0 {
403            spans.push(Span::raw(separator.to_string()));
404        }
405        spans.push(Span::styled(format!("{label}:"), label_style));
406        spans.push(Span::raw(format!(" {value}")));
407    }
408
409    Line::from(spans)
410}
411
412fn plot_value(
413    timeline: &[crate::recording::TimelinePoint],
414    point_index: usize,
415    plot: PlotConfig,
416    day_ticks: u64,
417    salary_cost: f64,
418    window_ticks: u64,
419) -> f64 {
420    let point = &timeline[point_index];
421    let current = plot
422        .stat
423        .base_value(&point.snapshot, point.tick, salary_cost, day_ticks);
424    if !(plot.windowed && plot.stat.supports_windowing()) {
425        return current;
426    }
427
428    let window_start_tick = point.tick.saturating_sub(window_ticks);
429    let base_index = timeline[..=point_index]
430        .iter()
431        .rposition(|timeline_point| timeline_point.tick <= window_start_tick);
432
433    match base_index {
434        Some(index) => {
435            let prior = plot.stat.base_value(
436                &timeline[index].snapshot,
437                timeline[index].tick,
438                salary_cost,
439                day_ticks,
440            );
441            current - prior
442        }
443        None => current,
444    }
445}
446
447fn accrued_staffing_cost(app: &App) -> f64 {
448    let total_salary_cost = app.recording.summary.salary_cost;
449    let day_ticks = app.recording.day_ticks;
450    if day_ticks == 0 {
451        return 0.0;
452    }
453
454    total_salary_cost * (app.current_tick().min(day_ticks) as f64 / day_ticks as f64)
455}