retroglyph_core/app.rs
1//! The `App`-driven game loop.
2//!
3//! Where [`Backend`](crate::backend::Backend) is the output contract, [`App`](crate::app::App) is the per-frame update
4//! contract. A game implements [`App`](crate::app::App) once and runs on every backend unchanged.
5//!
6//! The loop decomposes into three pieces:
7//!
8//! - the contract ([`App`](crate::app::App), [`Flow`](crate::app::Flow), [`Frame`](crate::app::Frame)), here in the core;
9//! - the generic blocking driver ([`run_blocking`](crate::app::run_blocking)/[`run_blocking_with`](crate::app::run_blocking_with), `std` only), which
10//! covers `Crossterm` (in `retroglyph-crossterm`) and [`Headless`](crate::backend::Headless);
11//! - the inverted driver in the windowing layer (the software backend's
12//! `run_app`), which cannot be generic because winit owns the loop instead of
13//! handing control back to a shared driver function.
14//!
15//! ```text
16//! +-----------------------------+
17//! | App, Flow, Frame (core) |
18//! +-----------------------------+
19//! |
20//! App::update
21//! |
22//! +---------------------+---------------------+
23//! | |
24//! run_blocking / run_blocking_with windowing layer's run_app
25//! (std only; owns the loop) (winit owns the loop instead)
26//! | |
27//! crossterm, headless software backend
28//! ```
29//!
30//! Both drivers call [`App::update`](crate::app::App::update) as the per-frame body and present automatically after it
31//! returns, skipping the present on [`Flow::Idle`](crate::app::Flow::Idle) or when `update` already presented itself. The
32//! low-level [`poll`](crate::terminal::Terminal::poll) / [`present`](crate::terminal::Terminal::present) API remains
33//! available for turn-based games and headless tests.
34
35use crate::backend::Backend;
36use crate::terminal::Terminal;
37use core::time::Duration;
38
39/// Whether the game loop should continue or stop after a frame, and whether that frame renders.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum Flow {
43 /// Run another frame, and present it.
44 Continue,
45 /// Run another frame, but nothing changed: skip [`present`](crate::terminal::Terminal::present) and leave the
46 /// previous frame on screen.
47 ///
48 /// For turn-based apps that only need to redraw in response to player input, not on every
49 /// tick of the driver's loop. Returning `Idle` while a `retroglyph_ui::Tween`- or
50 /// [`FrameClock`](crate::frames::FrameClock)-driven animation is still in flight is an
51 /// app bug, not a valid use: an in-progress animation has something new to show every frame,
52 /// which is exactly what `Idle` tells the driver isn't true.
53 Idle,
54 /// Stop the loop. The driver returns and the terminal unwinds normally, so
55 /// backend `Drop` logic (for example crossterm's terminal restore) runs.
56 Exit,
57}
58
59/// Per-frame context handed to [`App::update`](crate::app::App::update).
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct Frame {
62 /// Wall-clock time elapsed since the previous frame, supplied by the driver.
63 pub delta: Duration,
64 /// Monotonic frame counter, starting at 0.
65 pub frame: u64,
66}
67
68/// The per-frame update contract for a game.
69///
70/// Implement this once, generically over the backend, to run everywhere:
71///
72/// ```
73/// use retroglyph_core::app::{App, Flow, Frame};
74/// use retroglyph_core::backend::Backend;
75/// use retroglyph_core::color::Style;
76/// use retroglyph_core::terminal::Terminal;
77///
78/// struct MyGame;
79/// impl<B: Backend> App<B> for MyGame {
80/// fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
81/// term.surface().put((0, 0), '@', Style::default());
82/// Flow::Exit
83/// }
84/// }
85/// ```
86pub trait App<B: Backend> {
87 /// Advance and render one frame.
88 ///
89 /// Draw into `term`, read input via `term`, and return [`Flow::Exit`](crate::app::Flow::Exit) to stop the loop.
90 ///
91 /// Draw via [`term.surface()`](crate::terminal::Terminal::surface) or [`term.draw()`](crate::terminal::Terminal::draw) (though
92 /// `draw` presents itself, which usually conflicts with the driver's own automatic present
93 /// below; prefer `surface()` inside `update`). Every driver ([`run_blocking`](crate::app::run_blocking) and
94 /// `retroglyph-window`'s windowed drivers) presents the frame automatically right after this
95 /// method returns, unless it returned [`Flow::Idle`](crate::app::Flow::Idle), in which case the driver skips
96 /// [`present`](crate::terminal::Terminal::present) entirely. Calling `present` yourself inside `update` remains
97 /// fine (the driver detects it already ran via [`present_count`](crate::terminal::Terminal::present_count) and
98 /// skips its own call) but is never required. [`run_blocking`](crate::app::run_blocking) and [`run_blocking_with`](crate::app::run_blocking_with) link
99 /// back here rather than restating this contract.
100 fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow;
101}
102
103/// Drive an [`App`](crate::app::App) with a blocking, event-driven loop until it returns [`Flow::Exit`](crate::app::Flow::Exit).
104///
105/// Generic over the backend, so it powers every non-inverted backend
106/// (`Crossterm` in `retroglyph-crossterm`, [`Headless`](crate::backend::Headless))
107/// with no per-backend loop code.
108/// Inverted backends (software/winit) provide their own driver.
109///
110/// The terminal is owned and dropped when the loop exits, so backend teardown
111/// (for example crossterm's terminal restore) runs on the way out.
112///
113/// See [`App::update`](crate::app::App::update) for the present/idle contract this and every other driver follows.
114/// Equivalent to `run_blocking_with(term, app, RunOptions::default())`: on [`Flow::Idle`](crate::app::Flow::Idle), blocks
115/// on input rather than calling `update` again immediately, so a turn-based app that's idle most
116/// of the time costs approximately nothing. Use [`run_blocking_with`](crate::app::run_blocking_with) with [`RunOptions::animated`](crate::app::RunOptions::animated)
117/// for a continuously-rendering app instead.
118///
119/// # Errors
120///
121/// Returns the backend's error if the automatic `present()` call fails. The loop stops and the
122/// terminal is dropped (running backend teardown) before the error is returned.
123#[cfg(feature = "std")]
124pub fn run_blocking<B, A>(term: Terminal<B>, app: A) -> Result<(), B::Error>
125where
126 B: Backend,
127 A: App<B>,
128{
129 run_blocking_with(term, app, RunOptions::default())
130}
131
132/// Options controlling [`run_blocking_with`](crate::app::run_blocking_with)'s pacing and idle behavior.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub struct RunOptions {
136 target_fps: Option<u32>,
137 event_driven: bool,
138 idle_wake: Option<Duration>,
139}
140
141impl RunOptions {
142 /// Options for a continuously-rendering, [`target_fps`](Self::target_fps)-paced loop.
143 ///
144 /// [`event_driven`](Self::event_driven) is `false`: [`Flow::Idle`](crate::app::Flow::Idle) only skips `present`, it
145 /// never blocks. Use this for apps that drive a `retroglyph_ui::Tween`/
146 /// [`FrameClock`](crate::frames::FrameClock) from [`Frame::delta`](crate::app::Frame::delta) and need `update`
147 /// called every tick regardless of input.
148 ///
149 /// `target_fps` becomes [`RunOptions::target_fps`](crate::app::RunOptions::target_fps) verbatim, including `0`: passing `0` here
150 /// builds without panicking, but [`run_blocking_with`](crate::app::run_blocking_with) panics once it constructs the
151 /// [`FrameClock`](crate::frames::FrameClock) that paces it (see that function's
152 /// `# Panics` section).
153 #[must_use]
154 pub const fn animated(target_fps: u32) -> Self {
155 Self {
156 target_fps: Some(target_fps),
157 event_driven: false,
158 idle_wake: None,
159 }
160 }
161
162 /// Caps the loop at this many [`App::update`](crate::app::App::update) calls per second whenever a frame actually
163 /// runs, using a [`FrameClock`](crate::frames::FrameClock) internally to pace them
164 /// evenly. `None` (the default) runs uncapped: as fast as `update` allows for back-to-back
165 /// [`Flow::Continue`](crate::app::Flow::Continue) frames, or immediately after whatever woke an
166 /// [`event_driven`](Self::event_driven) loop from [`Flow::Idle`](crate::app::Flow::Idle).
167 #[must_use]
168 pub const fn with_target_fps(mut self, target_fps: u32) -> Self {
169 self.target_fps = Some(target_fps);
170 self
171 }
172
173 /// Returns the configured [`target_fps`](Self::with_target_fps) cap, if any.
174 #[must_use]
175 pub const fn target_fps(&self) -> Option<u32> {
176 self.target_fps
177 }
178
179 /// On [`Flow::Idle`](crate::app::Flow::Idle), block on input instead of calling `update` again immediately.
180 ///
181 /// `true` (the default) is right for turn-based, event-driven apps that are idle most of the
182 /// time: an idle frame costs approximately nothing, blocked in the backend's input read
183 /// rather than spinning `update` as fast as the host can manage. `false` keeps `Flow::Idle`
184 /// non-blocking (skip `present`, keep looping at whatever rate
185 /// [`target_fps`](Self::target_fps) allows): right for apps that animate from
186 /// [`Frame::delta`](crate::app::Frame::delta) and only return `Idle` between animation-driven `Continue` frames, where
187 /// blocking would freeze the animation until the next stray input event. See
188 /// [`RunOptions::animated`](crate::app::RunOptions::animated) for that shape.
189 #[must_use]
190 pub const fn event_driven(mut self, event_driven: bool) -> Self {
191 self.event_driven = event_driven;
192 self
193 }
194
195 /// Returns whether [`Flow::Idle`](crate::app::Flow::Idle) blocks on input rather than looping immediately.
196 #[must_use]
197 pub const fn is_event_driven(&self) -> bool {
198 self.event_driven
199 }
200
201 /// When [`is_event_driven`](Self::is_event_driven) is `true`, the longest an idle loop blocks
202 /// before calling `update` again anyway, even with no input. `None` (the default) blocks
203 /// indefinitely: right for apps with nothing to redraw until input arrives. `Some(d)`
204 /// additionally wakes the loop every `d`, for apps that need a periodic idle redraw (a
205 /// blinking cursor, a clock) without paying full frame-rate cost. Ignored when
206 /// [`is_event_driven`](Self::is_event_driven) is `false`.
207 #[must_use]
208 pub const fn with_idle_wake(mut self, idle_wake: Duration) -> Self {
209 self.idle_wake = Some(idle_wake);
210 self
211 }
212
213 /// Returns the configured [`idle_wake`](Self::with_idle_wake) interval, if any.
214 #[must_use]
215 pub const fn idle_wake(&self) -> Option<Duration> {
216 self.idle_wake
217 }
218}
219
220impl Default for RunOptions {
221 /// Event-driven, uncapped, blocks indefinitely on [`Flow::Idle`](crate::app::Flow::Idle): see [`run_blocking`](crate::app::run_blocking).
222 fn default() -> Self {
223 Self {
224 target_fps: None,
225 event_driven: true,
226 idle_wake: None,
227 }
228 }
229}
230
231/// Drive an [`App`](crate::app::App) with a blocking loop until it returns [`Flow::Exit`](crate::app::Flow::Exit), paced by `options`.
232///
233/// The zero-config [`run_blocking`](crate::app::run_blocking) is equivalent to `run_blocking_with(term, app,
234/// RunOptions::default())`. Pass [`RunOptions::animated`](crate::app::RunOptions::animated) for a continuously-rendering loop
235/// capped at a fixed rate instead, using a [`FrameClock`](crate::frames::FrameClock)
236/// internally so `update` is called at even intervals rather than however fast the host can
237/// spin.
238///
239/// With [`RunOptions::is_event_driven`](crate::app::RunOptions::is_event_driven) `true` (the default), [`Flow::Idle`](crate::app::Flow::Idle) blocks the loop on
240/// input (via [`Terminal::wait_for_input`](crate::terminal::Terminal::wait_for_input)) instead of calling `update` again immediately:
241/// an idle app has nothing new to show, so there is no reason to burn CPU polling it at all,
242/// let alone faster than any configured rate. With `event_driven` `false`, an idle loop still
243/// waits out the remainder of the current `target_fps` interval (if set) before calling `update`
244/// again, rather than looping immediately, but never blocks on input.
245///
246/// # Errors
247///
248/// Returns the backend's error if the automatic `present()` call fails. The loop stops and the
249/// terminal is dropped (running backend teardown) before the error is returned.
250///
251/// # Panics
252///
253/// Panics if `options.target_fps` is `Some(0)`: pacing at a `FrameClock` internally, which
254/// requires a non-zero rate (see [`FrameClock::new`](crate::frames::FrameClock::new)).
255#[cfg(feature = "std")]
256pub fn run_blocking_with<B, A>(
257 mut term: Terminal<B>,
258 mut app: A,
259 options: RunOptions,
260) -> Result<(), B::Error>
261where
262 B: Backend,
263 A: App<B>,
264{
265 let mut clock = options.target_fps().map(crate::frames::FrameClock::new);
266 let mut frame_count = 0u64;
267 let mut last = std::time::Instant::now();
268 loop {
269 if let Some(clock) = clock.as_mut() {
270 // Block out the rest of this frame's budget before ticking `update` again, so a
271 // paced loop doesn't busy-spin between updates the way an uncapped one does.
272 let elapsed = last.elapsed();
273 if let Some(remaining) = clock.step().checked_sub(elapsed) {
274 std::thread::sleep(remaining);
275 }
276 clock.advance(clock.step().max(elapsed));
277 // A fixed-timestep `FrameClock` is meant to be drained in a `while tick()` loop for
278 // logic that must run in whole steps; here it only paces wall-clock timing, so a
279 // single `tick()` (there is always at least one step ready, since we just slept/
280 // advanced past the threshold) resets the accumulator for the next iteration.
281 let _ = clock.tick();
282 }
283 let now = std::time::Instant::now();
284 let delta = now.duration_since(last);
285 last = now;
286 let frame = Frame {
287 delta,
288 frame: frame_count,
289 };
290 frame_count = frame_count.wrapping_add(1);
291 let present_count_before = term.present_count();
292 let flow = app.update(&mut term, &frame);
293 if flow == Flow::Exit {
294 return Ok(());
295 }
296 // A no-op if `update` already called `present()` itself (detected via `present_count`
297 // rather than relying on `present()` being a safe no-op to call twice: it always presents
298 // unconditionally, so a second call here would diff the just-cleared `current` against
299 // the just-presented `previous` and erase the frame `update` already sent).
300 if flow != Flow::Idle && term.present_count() == present_count_before {
301 term.present()?;
302 }
303 // `Flow` is `#[non_exhaustive]`; treat any variant other than `Exit`/`Idle` the same as
304 // `Continue` (keep looping and presenting) rather than exiting on an unknown future value.
305 if flow == Flow::Idle && options.is_event_driven() {
306 // The heart of the fix for retroglyph#603: block here instead of immediately
307 // re-entering the loop, so an idle frame costs approximately nothing rather than
308 // spinning `update` as fast as the host allows. `wait_for_input` buffers any event it
309 // finds rather than consuming it, so the app's own `update` still observes it on the
310 // next iteration; this call only answers "did something happen", it doesn't steal
311 // the event. A `target_fps` clock (if set) still gets its top-of-loop sleep on the
312 // next iteration; it isn't bypassed by waking early.
313 term.wait_for_input(options.idle_wake().unwrap_or(Duration::MAX));
314 }
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::backend::Headless;
322 use crate::event::{Event, KeyCode, KeyEvent, KeyModifiers};
323
324 struct Counter {
325 frames: u64,
326 }
327
328 impl App<Headless> for Counter {
329 fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
330 self.frames += 1;
331 term.surface()
332 .put((0, 0), '#', crate::color::Style::default());
333 term.present().expect("present");
334 // Quit when a key is pending, or after a safety cap.
335 if term.has_input() || frame.frame >= 100 {
336 Flow::Exit
337 } else {
338 Flow::Continue
339 }
340 }
341 }
342
343 #[cfg(feature = "std")]
344 #[test]
345 fn run_blocking_exits_on_flow_exit() {
346 let mut backend = Headless::new(4, 1);
347 backend.push_event(Event::Key(KeyEvent::new(
348 KeyCode::Char('q'),
349 KeyModifiers::NONE,
350 )));
351 let term = Terminal::new(backend);
352 let app = Counter { frames: 0 };
353 // Runs until the queued key is observed. Reaching the next line proves
354 // the loop terminated on Flow::Exit rather than spinning forever.
355 run_blocking(term, app).expect("run_blocking");
356 }
357
358 /// An app that never draws and always returns `Idle` except on the last frame: proves
359 /// `run_blocking` skips `present()` for `Idle` frames rather than erasing an untouched grid.
360 struct AlwaysIdle {
361 frames: u64,
362 }
363
364 impl App<Headless> for AlwaysIdle {
365 fn update(&mut self, _term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
366 self.frames += 1;
367 if frame.frame >= 5 {
368 Flow::Exit
369 } else {
370 Flow::Idle
371 }
372 }
373 }
374
375 #[cfg(feature = "std")]
376 #[test]
377 fn run_blocking_skips_present_on_idle() {
378 let term = Terminal::new(Headless::new(2, 1));
379 let app = AlwaysIdle { frames: 0 };
380 // `update` never draws or presents; if the driver called `present()` on an `Idle` frame
381 // anyway it would be harmless here (nothing to erase), so this mainly documents intent --
382 // the presenting behavior itself is covered by `run_blocking_with_options_presents_frames`.
383 run_blocking(term, app).expect("run_blocking");
384 }
385
386 /// An app that draws a distinct glyph per frame and never presents itself, so successfully
387 /// reaching the backend proves the driver's automatic present ran.
388 struct DrawsAndExits {
389 frames: u64,
390 exit_at: u64,
391 }
392
393 impl App<Headless> for DrawsAndExits {
394 fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
395 self.frames += 1;
396 term.surface()
397 .put((0, 0), 'x', crate::color::Style::default());
398 if frame.frame >= self.exit_at {
399 Flow::Exit
400 } else {
401 Flow::Continue
402 }
403 }
404 }
405
406 #[cfg(feature = "std")]
407 #[test]
408 fn run_blocking_presents_automatically() {
409 let term = Terminal::new(Headless::new(2, 1));
410 let app = DrawsAndExits {
411 frames: 0,
412 exit_at: 0,
413 };
414 run_blocking(term, app).expect("run_blocking");
415 // No assertion on backend content is possible here: `term` is consumed by `run_blocking`.
416 // Coverage that the automatic present actually reaches the backend lives in
417 // `retroglyph-window`'s own driver tests, which retain the terminal after the loop.
418 }
419
420 #[cfg(feature = "std")]
421 #[test]
422 fn run_blocking_with_default_options_matches_run_blocking() {
423 let term = Terminal::new(Headless::new(2, 1));
424 let app = DrawsAndExits {
425 frames: 0,
426 exit_at: 2,
427 };
428 run_blocking_with(term, app, RunOptions::default()).expect("run_blocking_with");
429 }
430
431 #[cfg(feature = "std")]
432 #[test]
433 fn run_blocking_with_animated_options_runs_to_completion() {
434 let term = Terminal::new(Headless::new(2, 1));
435 let app = DrawsAndExits {
436 frames: 0,
437 exit_at: 2,
438 };
439 // A high cap keeps this test fast; the point is that a paced loop still terminates on
440 // `Flow::Exit` and delivers the same number of updates as an uncapped loop would.
441 run_blocking_with(term, app, RunOptions::animated(1000)).expect("run_blocking_with");
442 }
443
444 #[test]
445 fn run_options_animated_sets_fields() {
446 let animated = RunOptions::animated(30);
447 assert_eq!(animated.target_fps(), Some(30));
448 assert!(!animated.is_event_driven());
449 assert_eq!(animated.idle_wake(), None);
450
451 let default = RunOptions::default();
452 assert_eq!(default.target_fps(), None);
453 assert!(default.is_event_driven());
454 assert_eq!(default.idle_wake(), None);
455 }
456
457 #[test]
458 fn run_options_setters_override_defaults() {
459 let options = RunOptions::default()
460 .with_target_fps(60)
461 .event_driven(false)
462 .with_idle_wake(Duration::from_millis(250));
463 assert_eq!(options.target_fps(), Some(60));
464 assert!(!options.is_event_driven());
465 assert_eq!(options.idle_wake(), Some(Duration::from_millis(250)));
466 }
467
468 /// An app that returns `Idle` for its first frame, then `Exit`. The queued key is only
469 /// pushed into the backend *after* the driver would have already woken from the idle wait
470 /// (`Headless::poll_event` ignores its timeout and returns immediately either way), so this
471 /// mainly documents the contract at the type level: `event_driven: false` is accepted and the
472 /// loop still terminates, i.e. the non-blocking `Idle` shape is a supported option for
473 /// animated apps. Real blocking behavior (`event_driven: true` actually parking
474 /// the thread) can only be observed on a backend that genuinely blocks, like crossterm --
475 /// see that crate's own tests.
476 struct IdleThenExit {
477 frames: u64,
478 }
479
480 impl App<Headless> for IdleThenExit {
481 fn update(&mut self, _term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
482 self.frames += 1;
483 if frame.frame == 0 {
484 Flow::Idle
485 } else {
486 Flow::Exit
487 }
488 }
489 }
490
491 #[cfg(feature = "std")]
492 #[test]
493 fn run_blocking_with_non_event_driven_options_does_not_block_on_idle() {
494 let term = Terminal::new(Headless::new(2, 1));
495 let app = IdleThenExit { frames: 0 };
496 let options = RunOptions {
497 target_fps: None,
498 event_driven: false,
499 idle_wake: None,
500 };
501 run_blocking_with(term, app, options).expect("run_blocking_with");
502 }
503
504 /// Proves the driver's idle wait doesn't swallow the event it woke up for: `update` is only
505 /// ever called again *after* `wait_for_input` observed something, so the app's own `has_input`
506 /// must still see the same event on the next frame rather than the driver having consumed it.
507 struct ObservesQueuedEventAfterIdle {
508 frames: u64,
509 saw_input_after_idle: bool,
510 }
511
512 impl App<Headless> for ObservesQueuedEventAfterIdle {
513 fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
514 self.frames += 1;
515 if frame.frame == 0 {
516 return Flow::Idle;
517 }
518 self.saw_input_after_idle = term.has_input();
519 Flow::Exit
520 }
521 }
522
523 #[test]
524 fn run_blocking_event_driven_idle_wait_does_not_consume_the_waking_event() {
525 let mut backend = Headless::new(2, 1);
526 backend.push_event(Event::Key(KeyEvent::new(
527 KeyCode::Char('x'),
528 KeyModifiers::NONE,
529 )));
530 let term = Terminal::new(backend);
531 let mut app = ObservesQueuedEventAfterIdle {
532 frames: 0,
533 saw_input_after_idle: false,
534 };
535 // Can't recover `app` through `run_blocking` (it takes the app by value and drops it with
536 // the terminal), so drive the loop by hand via `step`, mirroring what `run_blocking_with`
537 // does around the `Flow::Idle` branch.
538 let mut term = term;
539 let frame0 = Frame {
540 delta: Duration::ZERO,
541 frame: 0,
542 };
543 assert_eq!(app.update(&mut term, &frame0), Flow::Idle);
544 // This is the exact call `run_blocking_with` makes on `Flow::Idle` when `event_driven` is
545 // `true`: it must buffer the event, not return/consume it, so `update`'s own `has_input`
546 // still finds it below.
547 assert!(term.wait_for_input(Duration::MAX));
548 let frame1 = Frame {
549 delta: Duration::ZERO,
550 frame: 1,
551 };
552 assert_eq!(app.update(&mut term, &frame1), Flow::Exit);
553 assert!(app.saw_input_after_idle);
554 }
555}