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)]
26pub enum Flow {
27    /// Run another frame.
28    Continue,
29    /// Stop the loop. The driver returns and the terminal unwinds normally, so
30    /// backend `Drop` logic (for example crossterm's terminal restore) runs.
31    Exit,
32}
33
34/// Per-frame context handed to [`App::update`].
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Frame {
37    /// Wall-clock time elapsed since the previous frame, supplied by the driver.
38    pub delta: Duration,
39    /// Monotonic frame counter, starting at 0.
40    pub frame: u64,
41}
42
43/// The per-frame update contract for a game.
44///
45/// Implement this once, generically over the backend, to run everywhere:
46///
47/// ```
48/// use retroglyph_core::{App, Backend, Flow, Frame, Terminal};
49///
50/// struct MyGame;
51/// impl<B: Backend> App<B> for MyGame {
52///     fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
53///         term.put(0, 0, '@');
54///         term.present().ok();
55///         Flow::Exit
56///     }
57/// }
58/// ```
59pub trait App<B: Backend> {
60    /// Advance and render one frame.
61    ///
62    /// Draw into `term`, read input via `term`, and call
63    /// [`term.present()`](Terminal::present) to render the frame. Return
64    /// [`Flow::Exit`] to stop the loop.
65    fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow;
66}
67
68/// Run one frame: the per-frame body shared by every driver.
69///
70/// Calls [`App::update`]. Both [`run_blocking`] and the windowing layer's
71/// inverted driver call this function instead of `update` directly, so the
72/// two drivers cannot drift apart as the per-frame body grows.
73#[must_use]
74pub fn step<B: Backend, A: App<B>>(term: &mut Terminal<B>, app: &mut A, frame: &Frame) -> Flow {
75    app.update(term, frame)
76}
77
78/// Drive an [`App`] with a blocking loop until it returns [`Flow::Exit`].
79///
80/// Generic over the backend, so it powers every non-inverted backend
81/// (`Crossterm` in `retroglyph-crossterm`, [`Headless`](crate::backend::Headless))
82/// with no per-backend loop code.
83/// Inverted backends (software/winit) provide their own driver.
84///
85/// The terminal is owned and dropped when the loop exits, so backend teardown
86/// (for example crossterm's terminal restore) runs on the way out.
87#[cfg(feature = "std")]
88pub fn run_blocking<B, A>(mut term: Terminal<B>, mut app: A)
89where
90    B: Backend,
91    A: App<B>,
92{
93    let mut frame_count = 0u64;
94    let mut last = std::time::Instant::now();
95    loop {
96        let now = std::time::Instant::now();
97        let delta = now.duration_since(last);
98        last = now;
99        let frame = Frame {
100            delta,
101            frame: frame_count,
102        };
103        frame_count = frame_count.wrapping_add(1);
104        match step(&mut term, &mut app, &frame) {
105            Flow::Continue => {}
106            Flow::Exit => return,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::backend::Headless;
115    use crate::event::{Event, KeyCode, KeyEvent, KeyModifiers};
116
117    struct Counter {
118        frames: u64,
119    }
120
121    impl App<Headless> for Counter {
122        fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
123            self.frames += 1;
124            term.put(0, 0, '#');
125            term.present().expect("present");
126            // Quit when a key is pending, or after a safety cap.
127            if term.has_input() || frame.frame >= 100 {
128                Flow::Exit
129            } else {
130                Flow::Continue
131            }
132        }
133    }
134
135    #[test]
136    fn run_blocking_exits_on_flow_exit() {
137        let mut backend = Headless::new(4, 1);
138        backend.push_event(Event::Key(KeyEvent::new(
139            KeyCode::Char('q'),
140            KeyModifiers::NONE,
141        )));
142        let term = Terminal::new(backend);
143        let app = Counter { frames: 0 };
144        // Runs until the queued key is observed. Reaching the next line proves
145        // the loop terminated on Flow::Exit rather than spinning forever.
146        run_blocking(term, app);
147    }
148
149    #[test]
150    fn step_forwards_to_update() {
151        let mut term = Terminal::new(Headless::new(2, 1));
152        let mut app = Counter { frames: 0 };
153        let frame = Frame {
154            delta: Duration::ZERO,
155            frame: 200,
156        };
157        let flow = step(&mut term, &mut app, &frame);
158        assert_eq!(flow, Flow::Exit); // frame >= 100
159        assert_eq!(app.frames, 1);
160    }
161}