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`], `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}