retroglyph_core/app.rs
1//! The `App`-driven game loop.
2//!
3//! `App` is the update-side dual of [`Backend`](crate::Backend): where a
4//! backend is the output contract, an [`App`] is the per-frame update contract.
5//! A game implements [`App`] once and runs on every backend unchanged.
6//!
7//! The loop decomposes into three pieces:
8//!
9//! - the contract ([`App`], [`Flow`], [`Frame`]), here in the core;
10//! - the generic blocking driver ([`run_blocking`], `std` only), which covers
11//! `Crossterm` (in `retroglyph-crossterm`) and [`Headless`](crate::backend::Headless);
12//! - the inverted driver in the windowing layer (the software backend's
13//! `run_app`), which cannot be generic because winit owns the loop instead of
14//! handing control back to a shared driver function.
15//!
16//! Both drivers share [`step`] as the per-frame body. The low-level
17//! [`poll`](crate::Terminal::poll) / [`present`](crate::Terminal::present) API
18//! remains available for turn-based games and headless tests.
19
20use crate::backend::Backend;
21use crate::terminal::Terminal;
22use core::time::Duration;
23
24/// Whether the game loop should continue or stop after a frame.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26#[non_exhaustive]
27pub enum Flow {
28 /// Run another frame.
29 Continue,
30 /// Stop the loop. The driver returns and the terminal unwinds normally, so
31 /// backend `Drop` logic (for example crossterm's terminal restore) runs.
32 Exit,
33}
34
35/// Per-frame context handed to [`App::update`].
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Frame {
38 /// Wall-clock time elapsed since the previous frame, supplied by the driver.
39 pub delta: Duration,
40 /// Monotonic frame counter, starting at 0.
41 pub frame: u64,
42}
43
44/// The per-frame update contract for a game.
45///
46/// Implement this once, generically over the backend, to run everywhere:
47///
48/// ```
49/// use retroglyph_core::{App, Backend, Flow, Frame, Terminal};
50///
51/// struct MyGame;
52/// impl<B: Backend> App<B> for MyGame {
53/// fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
54/// term.put(0, 0, '@');
55/// term.present().ok(); // Required under `run_blocking`; automatic under the windowed drivers.
56/// Flow::Exit
57/// }
58/// }
59/// ```
60pub trait App<B: Backend> {
61 /// Advance and render one frame.
62 ///
63 /// Draw into `term`, read input via `term`, and return [`Flow::Exit`] to stop the loop.
64 ///
65 /// Whether you must call [`term.present()`](Terminal::present) yourself depends on the driver:
66 /// under [`run_blocking`], yes -- it owns a real blocking loop and never presents on your
67 /// behalf, so a forgotten `present()` call there is a silent no-render bug. Under
68 /// `retroglyph-window`'s windowed drivers (`run_windowed`/`run_app` and their `_with_proxy`
69 /// variants), presenting happens automatically right after this method returns each frame, so
70 /// calling `present()` yourself is optional (and harmless: the driver detects it already ran
71 /// and skips its own call).
72 fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow;
73}
74
75/// Run one frame: the per-frame body shared by every driver.
76///
77/// Calls [`App::update`]. Both [`run_blocking`] and the windowing layer's
78/// inverted driver call this function instead of `update` directly, so the
79/// two drivers cannot drift apart as the per-frame body grows.
80#[must_use]
81pub fn step<B: Backend, A: App<B>>(term: &mut Terminal<B>, app: &mut A, frame: &Frame) -> Flow {
82 app.update(term, frame)
83}
84
85/// Drive an [`App`] with a blocking loop until it returns [`Flow::Exit`].
86///
87/// Generic over the backend, so it powers every non-inverted backend
88/// (`Crossterm` in `retroglyph-crossterm`, [`Headless`](crate::backend::Headless))
89/// with no per-backend loop code.
90/// Inverted backends (software/winit) provide their own driver.
91///
92/// The terminal is owned and dropped when the loop exits, so backend teardown
93/// (for example crossterm's terminal restore) runs on the way out.
94///
95/// Unlike `retroglyph-window`'s windowed drivers, this function does **not** present
96/// automatically: it owns a real blocking loop rather than being redraw-event-driven, so
97/// [`App::update`] must call [`term.present()`](Terminal::present) itself every frame it wants
98/// rendered.
99#[cfg(feature = "std")]
100pub fn run_blocking<B, A>(mut term: Terminal<B>, mut app: A)
101where
102 B: Backend,
103 A: App<B>,
104{
105 let mut frame_count = 0u64;
106 let mut last = std::time::Instant::now();
107 loop {
108 let now = std::time::Instant::now();
109 let delta = now.duration_since(last);
110 last = now;
111 let frame = Frame {
112 delta,
113 frame: frame_count,
114 };
115 frame_count = frame_count.wrapping_add(1);
116 // `Flow` is `#[non_exhaustive]`; treat any variant other than `Flow::Exit` the same as
117 // `Flow::Continue` (keep looping) rather than exiting on an unknown future value.
118 if step(&mut term, &mut app, &frame) == Flow::Exit {
119 return;
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::backend::Headless;
128 use crate::event::{Event, KeyCode, KeyEvent, KeyModifiers};
129
130 struct Counter {
131 frames: u64,
132 }
133
134 impl App<Headless> for Counter {
135 fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
136 self.frames += 1;
137 term.put(0, 0, '#');
138 term.present().expect("present");
139 // Quit when a key is pending, or after a safety cap.
140 if term.has_input() || frame.frame >= 100 {
141 Flow::Exit
142 } else {
143 Flow::Continue
144 }
145 }
146 }
147
148 #[test]
149 fn run_blocking_exits_on_flow_exit() {
150 let mut backend = Headless::new(4, 1);
151 backend.push_event(Event::Key(KeyEvent::new(
152 KeyCode::Char('q'),
153 KeyModifiers::NONE,
154 )));
155 let term = Terminal::new(backend);
156 let app = Counter { frames: 0 };
157 // Runs until the queued key is observed. Reaching the next line proves
158 // the loop terminated on Flow::Exit rather than spinning forever.
159 run_blocking(term, app);
160 }
161
162 #[test]
163 fn step_forwards_to_update() {
164 let mut term = Terminal::new(Headless::new(2, 1));
165 let mut app = Counter { frames: 0 };
166 let frame = Frame {
167 delta: Duration::ZERO,
168 frame: 200,
169 };
170 let flow = step(&mut term, &mut app, &frame);
171 assert_eq!(flow, Flow::Exit); // frame >= 100
172 assert_eq!(app.frames, 1);
173 }
174}