Skip to main content

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}