Skip to main content

retroglyph_ui/perf/
app.rs

1//! [`PerfOverlayApp`]: wraps an [`App`] with a toggleable perf overlay. See the [module
2//! docs](super).
3
4use alloc::boxed::Box;
5use alloc::vec::Vec;
6
7use retroglyph_core::app::{App, Flow, Frame};
8use retroglyph_core::backend::Backend;
9use retroglyph_core::event::{Event, KeyCode, KeyEventKind};
10use retroglyph_core::frames::FrameStats;
11use retroglyph_core::grid::{HasSize, Rect, Size};
12use retroglyph_core::terminal::Terminal;
13
14use super::mode::PerfOverlayMode;
15use super::renderer::{DefaultPerfRenderer, PerfRenderer};
16use super::{DEFAULT_LAYER, FRAME_HISTORY};
17use crate::Surface;
18
19/// Whether `event` is the overlay's default toggle key.
20///
21/// Backtick, or F1 as an alias, on a press (not a repeat or a release, so a backend that reports
22/// releases doesn't toggle twice per physical key press). Override with
23/// [`PerfOverlayApp::toggle_key`].
24#[must_use]
25pub fn default_is_toggle_key(event: &Event) -> bool {
26    let Event::Key(key) = event else {
27        return false;
28    };
29    key.kind == KeyEventKind::Press && matches!(key.code, KeyCode::Char('`') | KeyCode::F(1))
30}
31
32/// [`PerfOverlayApp::cycle_with`]'s registered [`PerfOverlayMode::Full`] renderer and its area
33/// size.
34///
35/// Kept separate from [`PerfOverlayApp::size`] (which stays
36/// [`Compact`](PerfOverlayMode::Compact)-only) since a richer renderer typically needs a taller,
37/// wider area than the single-row default.
38struct FullMode {
39    renderer: Box<dyn PerfRenderer>,
40    size: Size,
41}
42
43/// Wraps an [`App`] with a toggleable perf overlay. See the [module docs](super).
44pub struct PerfOverlayApp<A, R = DefaultPerfRenderer> {
45    inner: A,
46    stats: FrameStats<FRAME_HISTORY>,
47    backend: &'static str,
48    renderer: R,
49    mode: PerfOverlayMode,
50    full: Option<FullMode>,
51    layer: u8,
52    size: Size,
53    toggle_key: fn(&Event) -> bool,
54    /// Scratch buffer for [`update`](App::update)'s pass-through events, reused across frames (via
55    /// `clear` rather than a fresh `Vec` each call) so draining an event-free frame (the common
56    /// case in an unpaced game loop calling this many times a second) never allocates.
57    passthrough: Vec<Event>,
58}
59
60impl<A> PerfOverlayApp<A, DefaultPerfRenderer> {
61    /// Wraps `inner`, drawing with [`DefaultPerfRenderer`]. `backend` is a short label (e.g.
62    /// `"crossterm"`, `"software"`, `"gl"`) shown in the readout; there is no portable way to ask
63    /// a [`Backend`] what to call itself, so every caller supplies it directly.
64    #[must_use]
65    pub fn new(inner: A, backend: &'static str) -> Self {
66        Self::with_renderer(inner, backend, DefaultPerfRenderer::new())
67    }
68}
69
70impl<A, F> PerfOverlayApp<A, F>
71where
72    F: FnMut(&FrameStats<FRAME_HISTORY>, &str, Rect, &mut Surface<'_>),
73{
74    /// Wraps `inner`, drawing with a plain closure instead of [`DefaultPerfRenderer`].
75    ///
76    /// Prefer this over [`with_renderer`](Self::with_renderer) for a closure: writing the bound
77    /// directly as `FnMut(...)` here (rather than the [`PerfRenderer`] trait `with_renderer`
78    /// takes) is what lets Rust infer a bare closure's parameter types from context, the same way
79    /// it does for any other `Fn`-bounded API: a closure passed to `with_renderer` needs its
80    /// parameters annotated by hand, or type inference has nothing to pin them down to.
81    #[must_use]
82    pub fn with_closure(inner: A, backend: &'static str, renderer: F) -> Self {
83        Self::with_renderer(inner, backend, renderer)
84    }
85}
86
87impl<A, R: PerfRenderer> PerfOverlayApp<A, R> {
88    /// Wraps `inner`, drawing with a custom [`PerfRenderer`] instead of [`DefaultPerfRenderer`].
89    /// This constructor is for a named type that implements [`PerfRenderer`] directly (there is no
90    /// stable way for a plain struct to implement `FnMut` itself, which is why the trait exists
91    /// at all: see the [module docs](super)). See [`PerfOverlayApp::with_closure`] for why a bare
92    /// closure should go through that constructor instead.
93    ///
94    /// Defaults: visible, [`DEFAULT_LAYER`](super::DEFAULT_LAYER), a `64x1` area (wide enough for
95    /// [`DefaultPerfRenderer`]'s single-row readout with a short backend label), and
96    /// [`default_is_toggle_key`]; override any of these with the chainable methods below before
97    /// the wrapped app first runs.
98    #[must_use]
99    pub fn with_renderer(inner: A, backend: &'static str, renderer: R) -> Self {
100        Self {
101            inner,
102            stats: FrameStats::new(),
103            backend,
104            renderer,
105            mode: PerfOverlayMode::Compact,
106            full: None,
107            layer: DEFAULT_LAYER,
108            size: Size::new(64, 1),
109            toggle_key: default_is_toggle_key,
110            passthrough: Vec::new(),
111        }
112    }
113
114    /// Registers a second, richer [`PerfRenderer`] (a plain closure works, the same way
115    /// [`with_closure`](Self::with_closure) does for the primary one) at [`PerfOverlayMode::Full`],
116    /// `size` columns by rows, and makes it reachable: the toggle key now cycles `Off -> Compact
117    /// -> Full -> Off` instead of just `Off -> Compact -> Off`.
118    ///
119    /// A natural pairing is [`DefaultPerfRenderer`] (or a closure) at
120    /// [`Compact`](PerfOverlayMode::Compact) and [`PerfOverlay`](crate::PerfOverlay) (a bordered
121    /// panel with a frame-time sparkline) at `Full`; see the [module docs](super).
122    #[must_use]
123    pub fn cycle_with<F>(mut self, size: Size, renderer: F) -> Self
124    where
125        F: FnMut(&FrameStats<FRAME_HISTORY>, &str, Rect, &mut Surface<'_>) + 'static,
126    {
127        // Bound as `FnMut(...)` directly for the same reason `with_closure` is; see its doc
128        // comment. `F: FnMut(...)` still satisfies `PerfRenderer` via its blanket impl, so this
129        // boxes fine.
130        self.full = Some(FullMode {
131            renderer: Box::new(renderer),
132            size,
133        });
134        self
135    }
136
137    /// Sets whether the overlay starts visible: `true` starts at [`PerfOverlayMode::Compact`],
138    /// `false` at [`PerfOverlayMode::Off`]. Defaults to `true`; the toggle key cycles through
139    /// every registered mode at runtime regardless; see [`set_mode`](Self::set_mode) to start
140    /// directly at [`PerfOverlayMode::Full`] instead.
141    #[must_use]
142    pub const fn visible(mut self, visible: bool) -> Self {
143        self.mode = if visible {
144            PerfOverlayMode::Compact
145        } else {
146            PerfOverlayMode::Off
147        };
148        self
149    }
150
151    /// Sets which grid layer the overlay draws on. Defaults to
152    /// [`DEFAULT_LAYER`](super::DEFAULT_LAYER); raise it if the wrapped app's own content already
153    /// reaches that layer.
154    #[must_use]
155    pub const fn layer(mut self, layer: u8) -> Self {
156        self.layer = layer;
157        self
158    }
159
160    /// Sets [`Compact`](PerfOverlayMode::Compact)'s area size, placed flush against the
161    /// top-right corner of the terminal (clamped to its actual size). Defaults to `64x1`, sized
162    /// for [`DefaultPerfRenderer`]'s single-row readout; a custom [`PerfRenderer`] that draws a
163    /// panel or a sparkline at `Compact` needs a taller (and often wider) area: size it to fit,
164    /// or register it as the richer [`Full`](PerfOverlayMode::Full) mode via
165    /// [`cycle_with`](Self::cycle_with) instead, which takes its own size.
166    #[must_use]
167    pub const fn size(mut self, size: Size) -> Self {
168        self.size = size;
169        self
170    }
171
172    /// Overrides which events toggle the overlay's visibility. Defaults to
173    /// [`default_is_toggle_key`].
174    #[must_use]
175    pub const fn toggle_key(mut self, matches: fn(&Event) -> bool) -> Self {
176        self.toggle_key = matches;
177        self
178    }
179
180    /// The frame-time statistics fed by every [`update`](App::update) call so far.
181    #[must_use]
182    pub const fn stats(&self) -> &FrameStats<FRAME_HISTORY> {
183        &self.stats
184    }
185
186    /// Whether the overlay currently renders: `true` for [`Compact`](PerfOverlayMode::Compact) or
187    /// [`Full`](PerfOverlayMode::Full), `false` for [`Off`](PerfOverlayMode::Off). See
188    /// [`mode`](Self::mode) to distinguish `Compact` from `Full`.
189    #[must_use]
190    pub const fn is_visible(&self) -> bool {
191        !matches!(self.mode, PerfOverlayMode::Off)
192    }
193
194    /// The overlay's current [`PerfOverlayMode`].
195    #[must_use]
196    pub const fn mode(&self) -> PerfOverlayMode {
197        self.mode
198    }
199
200    /// Sets the overlay's [`PerfOverlayMode`] directly: the one way to mutate visibility at
201    /// runtime, including reaching [`Full`](PerfOverlayMode::Full) directly rather than only
202    /// through [`toggle`](Self::toggle)'s cycle. Setting `Full` without a renderer registered via
203    /// [`cycle_with`](Self::cycle_with) is accepted but renders nothing (the same as `Off`) until
204    /// one is.
205    pub const fn set_mode(&mut self, mode: PerfOverlayMode) {
206        self.mode = mode;
207    }
208
209    /// Advances to the next [`PerfOverlayMode`] in the cycle (`Off -> Compact -> Full -> Off`,
210    /// skipping `Full` if nothing was registered via [`cycle_with`](Self::cycle_with)). The same
211    /// effect the toggle key has.
212    pub const fn toggle(&mut self) {
213        self.mode = self.mode.next(self.full.is_some());
214    }
215
216    /// A shared reference to the wrapped app.
217    #[must_use]
218    pub const fn inner(&self) -> &A {
219        &self.inner
220    }
221
222    /// A mutable reference to the wrapped app.
223    pub const fn inner_mut(&mut self) -> &mut A {
224        &mut self.inner
225    }
226
227    /// Unwraps this overlay, discarding its accumulated [`FrameStats`] and returning the wrapped
228    /// app.
229    #[must_use]
230    pub fn into_inner(self) -> A {
231        self.inner
232    }
233
234    /// `size` (columns, rows), flush against the top-right corner of a `term_width x
235    /// term_height` terminal, clamped to its actual size.
236    fn area(size: Size, term_width: u16, term_height: u16) -> Rect {
237        let width = size.width().min(term_width);
238        let height = size.height().min(term_height);
239        Rect::new(term_width - width, 0, width, height)
240    }
241}
242
243impl<B, A, R> App<B> for PerfOverlayApp<A, R>
244where
245    B: Backend,
246    A: App<B>,
247    R: PerfRenderer,
248{
249    fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
250        self.stats.record(frame.delta);
251
252        // Drain every pending event before the wrapped app runs, keep the toggle presses, and
253        // re-queue everything else via `Terminal::requeue_events` so the wrapped app sees exactly
254        // the input it would have without this wrapper, minus the toggles. This goes entirely
255        // through `Terminal`'s own event queue, never a backend-specific input path (in
256        // particular, never `Input::push_event`, whose documented default is a no-op for
257        // backends with no external event source): see the module docs' "Toggling" section.
258        let mut toggled = false;
259        self.passthrough.clear();
260        for event in term.drain_events() {
261            if (self.toggle_key)(&event) {
262                toggled = true;
263            } else {
264                self.passthrough.push(event);
265            }
266        }
267        if toggled {
268            self.mode = self.mode.next(self.full.is_some());
269        }
270        term.requeue_events(self.passthrough.drain(..));
271
272        let mut flow = self.inner.update(term, frame);
273        // A toggle is itself a visible change worth presenting, even on a frame the wrapped app
274        // otherwise found nothing new to draw (`Flow::Idle`): upgrade to `Continue` so the flip
275        // actually reaches the screen this frame instead of waiting for the next redraw.
276        if toggled && flow == Flow::Idle {
277            flow = Flow::Continue;
278        }
279
280        // `Compact` and `Full` each have their own renderer and area size (see `FullMode`'s
281        // docs); `Off`, and `Full` without a registered `FullMode`, draw nothing.
282        let drawn = match self.mode {
283            PerfOverlayMode::Off => None,
284            PerfOverlayMode::Compact => Some((self.size, None)),
285            PerfOverlayMode::Full => self.full.as_mut().map(|full| (full.size, Some(full))),
286        };
287        if let Some((size, full)) = drawn {
288            let (term_width, term_height) = {
289                let surface = term.surface();
290                (surface.width(), surface.height())
291            };
292            let area = Self::area(size, term_width, term_height);
293            if area.width() > 0 && area.height() > 0 {
294                let mut base = term.surface();
295                let mut surface = base.on_layer(self.layer);
296                match full {
297                    None => self
298                        .renderer
299                        .render(&self.stats, self.backend, area, &mut surface),
300                    Some(full) => {
301                        full.renderer
302                            .render(&self.stats, self.backend, area, &mut surface);
303                    }
304                }
305            }
306        }
307
308        flow
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use retroglyph_core::backend::{Cursor, Headless, Input, Output};
315    use retroglyph_core::color::Style;
316    use retroglyph_core::event::{KeyEvent, KeyModifiers};
317    use retroglyph_core::grid::Pos;
318
319    use super::*;
320
321    struct CountingApp {
322        updates: u32,
323        exit_at: u64,
324    }
325
326    impl<B: Backend> App<B> for CountingApp {
327        fn update(&mut self, _term: &mut Terminal<B>, frame: &Frame) -> Flow {
328            self.updates += 1;
329            if frame.frame >= self.exit_at {
330                Flow::Exit
331            } else {
332                Flow::Continue
333            }
334        }
335    }
336
337    fn frame(n: u64) -> Frame {
338        Frame {
339            delta: core::time::Duration::from_millis(16),
340            frame: n,
341        }
342    }
343
344    /// Wraps [`Headless`] but never overrides `Input::push_event`, relying on the trait's
345    /// documented no-op default the way a backend with no external event source legitimately
346    /// can (see `retroglyph_core::backend`'s [`Input`] doc). Exists only to prove
347    /// `PerfOverlayApp` no longer needs that override to pass input through.
348    struct NoPushEventBackend(Headless);
349
350    impl Output for NoPushEventBackend {
351        type Error = <Headless as Output>::Error;
352
353        fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
354        where
355            I: Iterator<Item = retroglyph_core::backend::DrawCell<'a>>,
356        {
357            self.0.draw_layers(content)
358        }
359
360        fn resize(&mut self, size: Size) {
361            self.0.resize(size);
362        }
363
364        fn flush(&mut self) -> Result<(), Self::Error> {
365            self.0.flush()
366        }
367
368        fn size(&self) -> Size {
369            self.0.size()
370        }
371
372        fn clear(&mut self) -> Result<(), Self::Error> {
373            self.0.clear()
374        }
375    }
376
377    impl Input for NoPushEventBackend {
378        fn poll_event(&mut self, timeout: core::time::Duration) -> Option<Event> {
379            self.0.poll_event(timeout)
380        }
381        // `push_event` intentionally not overridden: this backend relies on the trait's
382        // documented no-op default, as `Crossterm` and windowed backends' doc comments say a
383        // backend with no external event source is free to do.
384    }
385
386    impl Cursor for NoPushEventBackend {
387        fn set_cursor_visible(&mut self, visible: bool) {
388            self.0.set_cursor_visible(visible);
389        }
390
391        fn set_cursor_position(&mut self, position: Pos) {
392            self.0.set_cursor_position(position);
393        }
394    }
395
396    struct SeesInput {
397        saw: bool,
398    }
399
400    impl<B: Backend> App<B> for SeesInput {
401        fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
402            if term.drain_events().next().is_some() {
403                self.saw = true;
404            }
405            Flow::Continue
406        }
407    }
408
409    #[test]
410    fn perf_overlay_passes_input_through_on_a_backend_with_the_default_push_event() {
411        let mut term = Terminal::new(NoPushEventBackend(Headless::new(40, 5)));
412        term.backend_mut().0.push_event(Event::Key(KeyEvent::new(
413            KeyCode::Char('q'),
414            KeyModifiers::NONE,
415        )));
416
417        let mut overlay = PerfOverlayApp::new(SeesInput { saw: false }, "custom");
418        App::update(&mut overlay, &mut term, &frame(0));
419
420        assert!(overlay.inner().saw);
421    }
422
423    #[test]
424    fn default_is_toggle_key_matches_backtick_and_f1_presses_only() {
425        for code in [KeyCode::Char('`'), KeyCode::F(1)] {
426            let press = Event::Key(KeyEvent::new(code, KeyModifiers::NONE));
427            assert!(default_is_toggle_key(&press));
428        }
429        let release = Event::Key(KeyEvent::with_kind(
430            KeyCode::Char('`'),
431            KeyModifiers::NONE,
432            KeyEventKind::Release,
433        ));
434        assert!(!default_is_toggle_key(&release));
435        assert!(!default_is_toggle_key(&Event::Close));
436        let other = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
437        assert!(!default_is_toggle_key(&other));
438    }
439
440    #[test]
441    fn forwards_updates_and_records_frame_stats() {
442        let mut term = Terminal::new(Headless::new(40, 5));
443        let mut overlay = PerfOverlayApp::new(
444            CountingApp {
445                updates: 0,
446                exit_at: 2,
447            },
448            "headless",
449        );
450
451        for n in 0..3 {
452            let flow = App::update(&mut overlay, &mut term, &frame(n));
453            if flow == Flow::Exit {
454                break;
455            }
456        }
457
458        assert_eq!(overlay.inner().updates, 3);
459        assert_eq!(overlay.stats().frame_count(), 3);
460    }
461
462    #[test]
463    fn swallows_toggle_key_and_passes_other_input_through() {
464        let mut term = Terminal::new(Headless::new(40, 5));
465        term.backend_mut().push_event(Event::Key(KeyEvent::new(
466            KeyCode::Char('`'),
467            KeyModifiers::NONE,
468        )));
469        term.backend_mut().push_event(Event::Key(KeyEvent::new(
470            KeyCode::Char('q'),
471            KeyModifiers::NONE,
472        )));
473
474        let mut overlay = PerfOverlayApp::new(
475            CountingApp {
476                updates: 0,
477                exit_at: 100,
478            },
479            "headless",
480        );
481        assert!(overlay.is_visible());
482        let _ = App::update(&mut overlay, &mut term, &frame(0));
483        assert!(!overlay.is_visible(), "backtick should have toggled off");
484
485        // The non-toggle key was re-queued for the wrapped app (and anyone else) to see.
486        let remaining: Vec<Event> = term.drain_events().collect();
487        assert_eq!(
488            remaining,
489            alloc::vec![Event::Key(KeyEvent::new(
490                KeyCode::Char('q'),
491                KeyModifiers::NONE
492            ))]
493        );
494    }
495
496    #[test]
497    fn toggling_while_idle_upgrades_flow_to_continue() {
498        struct AlwaysIdle;
499        impl<B: Backend> App<B> for AlwaysIdle {
500            fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
501                Flow::Idle
502            }
503        }
504
505        let mut term = Terminal::new(Headless::new(40, 5));
506        term.backend_mut().push_event(Event::Key(KeyEvent::new(
507            KeyCode::Char('`'),
508            KeyModifiers::NONE,
509        )));
510        let mut overlay = PerfOverlayApp::new(AlwaysIdle, "headless");
511
512        let flow = App::update(&mut overlay, &mut term, &frame(0));
513        assert_eq!(
514            flow,
515            Flow::Continue,
516            "toggle should upgrade Idle to Continue"
517        );
518
519        // A second, toggle-free frame stays Idle.
520        let flow = App::update(&mut overlay, &mut term, &frame(1));
521        assert_eq!(flow, Flow::Idle);
522    }
523
524    #[test]
525    fn layer_and_size_builders_set_their_fields() {
526        let overlay = PerfOverlayApp::new(
527            CountingApp {
528                updates: 0,
529                exit_at: 100,
530            },
531            "headless",
532        )
533        .layer(5)
534        .size(Size::new(10, 2));
535
536        assert_eq!(overlay.layer, 5);
537        assert_eq!(overlay.size, Size::new(10, 2));
538    }
539
540    #[test]
541    fn visible_true_is_explicitly_compact() {
542        // `.visible(false)` is exercised by `hidden_overlay_draws_nothing` below; `new` already
543        // starts at `Compact` without calling `visible` at all, so `.visible(true)`'s own branch
544        // needs its own call site to be exercised.
545        let overlay = PerfOverlayApp::new(
546            CountingApp {
547                updates: 0,
548                exit_at: 100,
549            },
550            "headless",
551        )
552        .visible(true);
553
554        assert_eq!(overlay.mode(), PerfOverlayMode::Compact);
555    }
556
557    #[test]
558    fn zero_size_compact_area_draws_nothing() {
559        let mut term = Terminal::new(Headless::new(40, 5));
560        let mut overlay = PerfOverlayApp::new(
561            CountingApp {
562                updates: 0,
563                exit_at: 100,
564            },
565            "headless",
566        )
567        .size(Size::new(0, 0));
568
569        let _ = App::update(&mut overlay, &mut term, &frame(0));
570        term.present().expect("present");
571        assert!(
572            !term.backend().format_view().contains("fps"),
573            "a zero-size compact area should draw nothing"
574        );
575    }
576
577    #[test]
578    fn hidden_overlay_draws_nothing() {
579        let mut term = Terminal::new(Headless::new(40, 5));
580        let mut overlay = PerfOverlayApp::new(
581            CountingApp {
582                updates: 0,
583                exit_at: 100,
584            },
585            "headless",
586        )
587        .visible(false);
588
589        let _ = App::update(&mut overlay, &mut term, &frame(0));
590        term.present().expect("present");
591        assert!(
592            !term.backend().format_view().contains("fps"),
593            "hidden overlay should not draw"
594        );
595    }
596
597    #[test]
598    fn visible_overlay_draws_a_readout_after_the_first_frame() {
599        let mut term = Terminal::new(Headless::new(80, 5));
600        let mut overlay = PerfOverlayApp::new(
601            CountingApp {
602                updates: 0,
603                exit_at: 100,
604            },
605            "headless",
606        );
607
608        let _ = App::update(&mut overlay, &mut term, &frame(0));
609        term.present().expect("present");
610        let view = term.backend().format_view();
611        assert!(view.contains("fps"), "{view}");
612        assert!(view.contains("headless"), "{view}");
613    }
614
615    #[test]
616    fn custom_renderer_closure_is_used_instead_of_the_default() {
617        let mut term = Terminal::new(Headless::new(40, 5));
618        let mut overlay = PerfOverlayApp::with_closure(
619            CountingApp {
620                updates: 0,
621                exit_at: 100,
622            },
623            "headless",
624            |_stats, backend, area, surface| {
625                surface.print((area.left(), area.top()), backend, Style::new());
626            },
627        );
628
629        let _ = App::update(&mut overlay, &mut term, &frame(0));
630        term.present().expect("present");
631        let view = term.backend().format_view();
632        assert!(view.contains("headless"), "{view}");
633        assert!(
634            !view.contains("fps"),
635            "custom renderer should replace the default, {view}"
636        );
637    }
638
639    #[test]
640    fn without_cycle_with_toggle_only_visits_off_and_compact() {
641        let mut overlay = PerfOverlayApp::new(
642            CountingApp {
643                updates: 0,
644                exit_at: 100,
645            },
646            "headless",
647        );
648        assert_eq!(overlay.mode(), PerfOverlayMode::Compact);
649        overlay.toggle();
650        assert_eq!(overlay.mode(), PerfOverlayMode::Off);
651        overlay.toggle();
652        assert_eq!(overlay.mode(), PerfOverlayMode::Compact);
653    }
654
655    #[test]
656    fn cycle_with_makes_toggle_visit_off_compact_full() {
657        let mut overlay = PerfOverlayApp::new(
658            CountingApp {
659                updates: 0,
660                exit_at: 100,
661            },
662            "headless",
663        )
664        .cycle_with(Size::new(40, 6), |_stats, _backend, _area, _surface| {});
665
666        assert_eq!(overlay.mode(), PerfOverlayMode::Compact, "starts visible");
667        overlay.toggle();
668        assert_eq!(overlay.mode(), PerfOverlayMode::Full);
669        overlay.toggle();
670        assert_eq!(overlay.mode(), PerfOverlayMode::Off);
671        overlay.toggle();
672        assert_eq!(overlay.mode(), PerfOverlayMode::Compact, "cycle repeats");
673    }
674
675    #[test]
676    fn set_mode_jumps_directly_to_full() {
677        let mut overlay = PerfOverlayApp::new(
678            CountingApp {
679                updates: 0,
680                exit_at: 100,
681            },
682            "headless",
683        )
684        .cycle_with(Size::new(40, 6), |_stats, _backend, _area, _surface| {});
685
686        overlay.set_mode(PerfOverlayMode::Full);
687        assert_eq!(overlay.mode(), PerfOverlayMode::Full);
688        assert!(overlay.is_visible());
689    }
690
691    #[test]
692    fn full_mode_draws_with_its_own_renderer_at_its_own_size() {
693        let mut term = Terminal::new(Headless::new(60, 10));
694        let mut overlay = PerfOverlayApp::new(
695            CountingApp {
696                updates: 0,
697                exit_at: 100,
698            },
699            "headless",
700        )
701        .cycle_with(Size::new(30, 5), |_stats, _backend, area, surface| {
702            // No space in the marker: `Headless::format_view` renders a space glyph as `ยท`,
703            // so a literal `" "` in the assertion below would never match.
704            surface.print((area.left(), area.top()), "FULLMODE", Style::new());
705        });
706
707        // Compact mode: the default renderer's readout shows, not the Full-mode marker.
708        let _ = App::update(&mut overlay, &mut term, &frame(0));
709        term.present().expect("present");
710        let view = term.backend().format_view();
711        assert!(view.contains("fps"), "{view}");
712        assert!(!view.contains("FULLMODE"), "{view}");
713
714        // Advance to Full: now the registered renderer draws instead.
715        overlay.set_mode(PerfOverlayMode::Full);
716        let _ = App::update(&mut overlay, &mut term, &frame(1));
717        term.present().expect("present");
718        let view = term.backend().format_view();
719        assert!(view.contains("FULLMODE"), "{view}");
720    }
721
722    #[test]
723    fn area_places_the_overlay_flush_against_the_top_right_corner() {
724        let area =
725            PerfOverlayApp::<CountingApp, DefaultPerfRenderer>::area(Size::new(20, 3), 80, 24);
726        assert_eq!(area, Rect::new(60, 0, 20, 3));
727    }
728
729    #[test]
730    fn area_clamps_to_a_terminal_smaller_than_the_requested_size() {
731        let area =
732            PerfOverlayApp::<CountingApp, DefaultPerfRenderer>::area(Size::new(20, 3), 10, 2);
733        assert_eq!(area, Rect::new(0, 0, 10, 2));
734    }
735
736    #[test]
737    fn toggle_key_can_be_overridden() {
738        let mut term = Terminal::new(Headless::new(40, 5));
739        term.backend_mut().push_event(Event::Key(KeyEvent::new(
740            KeyCode::Char('p'),
741            KeyModifiers::NONE,
742        )));
743        let mut overlay = PerfOverlayApp::new(
744            CountingApp {
745                updates: 0,
746                exit_at: 100,
747            },
748            "headless",
749        )
750        .toggle_key(|event| {
751            matches!(
752                event,
753                Event::Key(k) if k.code == KeyCode::Char('p') && k.kind == KeyEventKind::Press
754            )
755        });
756
757        assert!(overlay.is_visible());
758        let _ = App::update(&mut overlay, &mut term, &frame(0));
759        assert!(
760            !overlay.is_visible(),
761            "the overridden toggle key ('p') should have cycled the overlay off"
762        );
763
764        // The default toggle key (backtick) no longer does anything once overridden.
765        term.backend_mut().push_event(Event::Key(KeyEvent::new(
766            KeyCode::Char('`'),
767            KeyModifiers::NONE,
768        )));
769        let _ = App::update(&mut overlay, &mut term, &frame(1));
770        assert!(
771            !overlay.is_visible(),
772            "backtick should no longer be the toggle key"
773        );
774    }
775
776    #[test]
777    fn inner_accessors_and_into_inner_round_trip() {
778        let mut overlay = PerfOverlayApp::new(
779            CountingApp {
780                updates: 3,
781                exit_at: 100,
782            },
783            "headless",
784        );
785        assert_eq!(overlay.inner().updates, 3);
786        overlay.inner_mut().updates = 7;
787        assert_eq!(overlay.inner().updates, 7);
788        assert_eq!(overlay.into_inner().updates, 7);
789    }
790}