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`]/[`run_blocking_with`], `std` only), which
11//! covers `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 and present automatically after `update`
17//! returns, skipping the present on [`Flow::Idle`] or when `update` already presented itself. The
18//! low-level [`poll`](crate::Terminal::poll) / [`present`](crate::Terminal::present) API remains
19//! available for turn-based games and headless tests.
20
21use crate::backend::Backend;
22use crate::terminal::Terminal;
23use core::time::Duration;
24
25/// Whether the game loop should continue or stop after a frame, and whether that frame renders.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27#[non_exhaustive]
28pub enum Flow {
29 /// Run another frame, and present it.
30 Continue,
31 /// Run another frame, but nothing changed: skip [`present`](Terminal::present) and leave the
32 /// previous frame on screen.
33 ///
34 /// For turn-based apps that only need to redraw in response to player input, not on every
35 /// tick of the driver's loop. Returning `Idle` while a [`Tween`](crate::animate::Tween)- or
36 /// [`FrameClock`](crate::frame_clock::FrameClock)-driven animation is still in flight is an
37 /// app bug, not a valid use: an in-progress animation has something new to show every frame,
38 /// which is exactly what `Idle` tells the driver isn't true.
39 Idle,
40 /// Stop the loop. The driver returns and the terminal unwinds normally, so
41 /// backend `Drop` logic (for example crossterm's terminal restore) runs.
42 Exit,
43}
44
45/// Per-frame context handed to [`App::update`].
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Frame {
48 /// Wall-clock time elapsed since the previous frame, supplied by the driver.
49 pub delta: Duration,
50 /// Monotonic frame counter, starting at 0.
51 pub frame: u64,
52}
53
54/// The per-frame update contract for a game.
55///
56/// Implement this once, generically over the backend, to run everywhere:
57///
58/// ```
59/// use retroglyph_core::{App, Backend, Flow, Frame, Style, Terminal};
60///
61/// struct MyGame;
62/// impl<B: Backend> App<B> for MyGame {
63/// fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
64/// term.surface().put((0, 0), '@', Style::default());
65/// Flow::Exit
66/// }
67/// }
68/// ```
69pub trait App<B: Backend> {
70 /// Advance and render one frame.
71 ///
72 /// Draw into `term`, read input via `term`, and return [`Flow::Exit`] to stop the loop.
73 ///
74 /// Draw via [`term.surface()`](Terminal::surface) or [`term.draw()`](Terminal::draw) (though
75 /// `draw` presents itself, which usually conflicts with the driver's own automatic present
76 /// below; prefer `surface()` inside `update`). Every driver ([`run_blocking`] and
77 /// `retroglyph-window`'s windowed drivers) presents the frame automatically right after this
78 /// method returns, unless it returned [`Flow::Idle`], in which case the driver skips
79 /// [`present`](Terminal::present) entirely. Calling `present` yourself inside `update` remains
80 /// fine (the driver detects it already ran via [`present_count`](Terminal::present_count) and
81 /// skips its own call) but is never required.
82 fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow;
83}
84
85/// Run one frame: the per-frame body shared by every driver.
86///
87/// Calls [`App::update`]. Both [`run_blocking`] and the windowing layer's
88/// inverted driver call this function instead of `update` directly, so the
89/// two drivers cannot drift apart as the per-frame body grows.
90#[must_use]
91pub fn step<B: Backend, A: App<B>>(term: &mut Terminal<B>, app: &mut A, frame: &Frame) -> Flow {
92 app.update(term, frame)
93}
94
95/// Drive an [`App`] with an unpaced blocking loop until it returns [`Flow::Exit`].
96///
97/// Generic over the backend, so it powers every non-inverted backend
98/// (`Crossterm` in `retroglyph-crossterm`, [`Headless`](crate::backend::Headless))
99/// with no per-backend loop code.
100/// Inverted backends (software/winit) provide their own driver.
101///
102/// The terminal is owned and dropped when the loop exits, so backend teardown
103/// (for example crossterm's terminal restore) runs on the way out.
104///
105/// Presents automatically after [`App::update`] returns, the same as `retroglyph-window`'s
106/// windowed drivers: skipped entirely on [`Flow::Idle`], and skipped as a redundant no-op if
107/// `update` already presented itself. This loop runs as fast as `update` allows, with no frame
108/// rate cap; use [`run_blocking_with`] and [`RunOptions::max_fps`] for a paced loop.
109///
110/// # Errors
111///
112/// Returns the backend's error if the automatic `present()` call fails. The loop stops and the
113/// terminal is dropped (running backend teardown) before the error is returned.
114#[cfg(feature = "std")]
115pub fn run_blocking<B, A>(term: Terminal<B>, app: A) -> Result<(), B::Error>
116where
117 B: Backend,
118 A: App<B>,
119{
120 run_blocking_with(term, app, RunOptions::default())
121}
122
123/// Options controlling [`run_blocking_with`]'s pacing.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
125#[non_exhaustive]
126pub struct RunOptions {
127 /// Caps the loop at this many [`App::update`] calls per second, using a
128 /// [`FrameClock`](crate::frame_clock::FrameClock) internally to pace them evenly. `None` (the
129 /// default) runs unpaced, as fast as `update` allows.
130 pub max_fps: Option<u32>,
131}
132
133impl RunOptions {
134 /// Options requesting a paced loop capped at `max_fps` updates per second.
135 #[must_use]
136 pub const fn paced(max_fps: u32) -> Self {
137 Self {
138 max_fps: Some(max_fps),
139 }
140 }
141}
142
143/// Drive an [`App`] with a blocking loop until it returns [`Flow::Exit`], paced by `options`.
144///
145/// The zero-config [`run_blocking`] is equivalent to `run_blocking_with(term, app,
146/// RunOptions::default())`: unpaced, spinning as fast as `update` allows. Pass
147/// [`RunOptions::paced`] to cap the loop at a fixed rate instead, using a
148/// [`FrameClock`](crate::frame_clock::FrameClock) internally so `update` is called at even
149/// intervals rather than however fast the host can spin.
150///
151/// On [`Flow::Idle`], the paced loop still waits out the remainder of the current frame interval
152/// before calling `update` again, rather than looping immediately: an idle app has nothing new to
153/// show, so there is no reason to burn CPU polling it faster than the configured rate.
154///
155/// # Errors
156///
157/// Returns the backend's error if the automatic `present()` call fails. The loop stops and the
158/// terminal is dropped (running backend teardown) before the error is returned.
159#[cfg(feature = "std")]
160pub fn run_blocking_with<B, A>(
161 mut term: Terminal<B>,
162 mut app: A,
163 options: RunOptions,
164) -> Result<(), B::Error>
165where
166 B: Backend,
167 A: App<B>,
168{
169 let mut clock = options.max_fps.map(crate::frame_clock::FrameClock::new);
170 let mut frame_count = 0u64;
171 let mut last = std::time::Instant::now();
172 loop {
173 if let Some(clock) = clock.as_mut() {
174 // Block out the rest of this frame's budget before ticking `update` again, so a
175 // paced loop doesn't busy-spin between updates the way the unpaced loop does.
176 let elapsed = last.elapsed();
177 if let Some(remaining) = clock.step().checked_sub(elapsed) {
178 std::thread::sleep(remaining);
179 }
180 clock.advance(clock.step().max(elapsed));
181 // A fixed-timestep `FrameClock` is meant to be drained in a `while tick()` loop for
182 // logic that must run in whole steps; here it only paces wall-clock timing, so a
183 // single `tick()` (there is always at least one step ready, since we just slept/
184 // advanced past the threshold) resets the accumulator for the next iteration.
185 let _ = clock.tick();
186 }
187 let now = std::time::Instant::now();
188 let delta = now.duration_since(last);
189 last = now;
190 let frame = Frame {
191 delta,
192 frame: frame_count,
193 };
194 frame_count = frame_count.wrapping_add(1);
195 let present_count_before = term.present_count();
196 let flow = step(&mut term, &mut app, &frame);
197 if flow == Flow::Exit {
198 return Ok(());
199 }
200 // A no-op if `update` already called `present()` itself (detected via `present_count`
201 // rather than relying on `present()` being a safe no-op to call twice: it always presents
202 // unconditionally, so a second call here would diff the just-cleared `current` against
203 // the just-presented `previous` and erase the frame `update` already sent).
204 if flow != Flow::Idle && term.present_count() == present_count_before {
205 term.present()?;
206 }
207 // `Flow` is `#[non_exhaustive]`; treat any variant other than `Exit`/`Idle` the same as
208 // `Continue` (keep looping and presenting) rather than exiting on an unknown future value.
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::backend::Headless;
216 use crate::event::{Event, KeyCode, KeyEvent, KeyModifiers};
217
218 struct Counter {
219 frames: u64,
220 }
221
222 impl App<Headless> for Counter {
223 fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
224 self.frames += 1;
225 term.surface()
226 .put((0, 0), '#', crate::style::Style::default());
227 term.present().expect("present");
228 // Quit when a key is pending, or after a safety cap.
229 if term.has_input() || frame.frame >= 100 {
230 Flow::Exit
231 } else {
232 Flow::Continue
233 }
234 }
235 }
236
237 #[test]
238 fn run_blocking_exits_on_flow_exit() {
239 let mut backend = Headless::new(4, 1);
240 backend.push_event(Event::Key(KeyEvent::new(
241 KeyCode::Char('q'),
242 KeyModifiers::NONE,
243 )));
244 let term = Terminal::new(backend);
245 let app = Counter { frames: 0 };
246 // Runs until the queued key is observed. Reaching the next line proves
247 // the loop terminated on Flow::Exit rather than spinning forever.
248 run_blocking(term, app).expect("run_blocking");
249 }
250
251 #[test]
252 fn step_forwards_to_update() {
253 let mut term = Terminal::new(Headless::new(2, 1));
254 let mut app = Counter { frames: 0 };
255 let frame = Frame {
256 delta: Duration::ZERO,
257 frame: 200,
258 };
259 let flow = step(&mut term, &mut app, &frame);
260 assert_eq!(flow, Flow::Exit); // frame >= 100
261 assert_eq!(app.frames, 1);
262 }
263
264 /// An app that never draws and always returns `Idle` except on the last frame: proves
265 /// `run_blocking` skips `present()` for `Idle` frames rather than erasing an untouched grid.
266 struct AlwaysIdle {
267 frames: u64,
268 }
269
270 impl App<Headless> for AlwaysIdle {
271 fn update(&mut self, _term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
272 self.frames += 1;
273 if frame.frame >= 5 {
274 Flow::Exit
275 } else {
276 Flow::Idle
277 }
278 }
279 }
280
281 #[test]
282 fn run_blocking_skips_present_on_idle() {
283 let term = Terminal::new(Headless::new(2, 1));
284 let app = AlwaysIdle { frames: 0 };
285 // `update` never draws or presents; if the driver called `present()` on an `Idle` frame
286 // anyway it would be harmless here (nothing to erase), so this mainly documents intent --
287 // the presenting behavior itself is covered by `run_blocking_with_options_presents_frames`.
288 run_blocking(term, app).expect("run_blocking");
289 }
290
291 /// An app that draws a distinct glyph per frame and never presents itself, so successfully
292 /// reaching the backend proves the driver's automatic present ran.
293 struct DrawsAndExits {
294 frames: u64,
295 exit_at: u64,
296 }
297
298 impl App<Headless> for DrawsAndExits {
299 fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
300 self.frames += 1;
301 term.surface()
302 .put((0, 0), 'x', crate::style::Style::default());
303 if frame.frame >= self.exit_at {
304 Flow::Exit
305 } else {
306 Flow::Continue
307 }
308 }
309 }
310
311 #[test]
312 fn run_blocking_presents_automatically() {
313 let term = Terminal::new(Headless::new(2, 1));
314 let app = DrawsAndExits {
315 frames: 0,
316 exit_at: 0,
317 };
318 run_blocking(term, app).expect("run_blocking");
319 // No assertion on backend content is possible here: `term` is consumed by `run_blocking`.
320 // Coverage that the automatic present actually reaches the backend lives in
321 // `retroglyph-window`'s own driver tests, which retain the terminal after the loop.
322 }
323
324 #[test]
325 fn run_blocking_with_default_options_matches_run_blocking() {
326 let term = Terminal::new(Headless::new(2, 1));
327 let app = DrawsAndExits {
328 frames: 0,
329 exit_at: 2,
330 };
331 run_blocking_with(term, app, RunOptions::default()).expect("run_blocking_with");
332 }
333
334 #[test]
335 fn run_blocking_with_paced_options_runs_to_completion() {
336 let term = Terminal::new(Headless::new(2, 1));
337 let app = DrawsAndExits {
338 frames: 0,
339 exit_at: 2,
340 };
341 // A high cap keeps this test fast; the point is that a paced loop still terminates on
342 // `Flow::Exit` and delivers the same number of updates as the unpaced loop would.
343 run_blocking_with(term, app, RunOptions::paced(1000)).expect("run_blocking_with");
344 }
345
346 #[test]
347 fn run_options_paced_sets_max_fps() {
348 assert_eq!(RunOptions::paced(30).max_fps, Some(30));
349 assert_eq!(RunOptions::default().max_fps, None);
350 }
351}