Skip to main content

retroglyph_window/winit/
run.rs

1//! The winit event loop and the windowed app drivers.
2//!
3//! [`run_windowed`] drives a raw `FnMut(&mut Terminal<..>)` closure;
4//! [`run_app`] drives an [`App`](retroglyph_core::App). This is the inverted
5//! driver: winit owns the loop and calls back into the app on each redraw,
6//! so it cannot be core's generic
7//! [`run_blocking`](retroglyph_core::run_blocking), which owns its own
8//! `while` loop.
9
10use super::translate::{
11    physical_pos_from, pixel_to_cell, translate_ime, translate_key, translate_modifiers,
12    translate_mouse_button,
13};
14#[cfg(target_arch = "wasm32")]
15use super::web;
16use crate::backend::WindowBackend;
17use crate::presenter::Presenter;
18use retroglyph_core::Terminal;
19use retroglyph_core::backend::{Input, Output};
20use retroglyph_core::event::{
21    Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, PhysicalPos,
22};
23use std::cell::Cell;
24use std::fmt;
25use std::marker::PhantomData;
26use std::rc::Rc;
27use std::sync::Arc;
28use std::time::Duration;
29use winit::application::ApplicationHandler;
30use winit::event::WindowEvent;
31use winit::event_loop::{ActiveEventLoop, EventLoop};
32use winit::window::{Window, WindowId};
33
34/// A thread-safe handle for injecting application-defined events into a running windowed event
35/// loop from another thread (network, audio, timer, ...).
36///
37/// Obtained via the `on_proxy` callback passed to [`run_windowed_with_proxy`]/
38/// [`run_app_with_proxy`] (payload fixed to `u64`, delivered as [`Event::Custom`]) or
39/// [`run_windowed_with_typed_proxy`]/[`run_app_with_typed_proxy`] (any `T: Send + 'static`,
40/// delivered to a caller-supplied handler), invoked synchronously right after the event loop
41/// (and this proxy) is created, before the loop starts blocking the calling thread. Clone it
42/// freely to hand a copy to each worker thread that needs to wake the loop; wraps winit's own
43/// [`EventLoopProxy`](winit::event_loop::EventLoopProxy), which is `Send + Sync` for any
44/// `T: Send + 'static` payload.
45///
46/// `T` defaults to `u64` (the payload [`Event::Custom`] itself carries), so existing code
47/// naming the bare `EventProxy` type (from before this type became generic) keeps compiling
48/// unchanged.
49pub struct EventProxy<T: Send + 'static = u64>(winit::event_loop::EventLoopProxy<T>);
50
51// Hand-written rather than `#[derive(Clone, Debug)]`: a derive would add `T: Clone`/`T: Debug`
52// bounds to the impl, but `winit::event_loop::EventLoopProxy<T>` itself needs neither: cloning
53// or formatting the proxy handle never touches a buffered `T` value (there isn't one; `T` is
54// only ever a transient argument to `send_event`).
55impl<T: Send + 'static> Clone for EventProxy<T> {
56    fn clone(&self) -> Self {
57        Self(self.0.clone())
58    }
59}
60
61impl<T: Send + 'static> fmt::Debug for EventProxy<T> {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.debug_tuple("EventProxy").field(&self.0).finish()
64    }
65}
66
67impl<T: Send + 'static> EventProxy<T> {
68    /// Injects `payload` into the event loop's queue, waking it if it's asleep.
69    ///
70    /// With the default `T = u64` (via [`run_windowed_with_proxy`]/[`run_app_with_proxy`]), the
71    /// payload surfaces through the app's normal `poll_event`/frame loop as
72    /// [`Event::Custom(payload)`](Event::Custom), like any other [`Event`]. With a custom `T`
73    /// (via [`run_windowed_with_typed_proxy`]/[`run_app_with_typed_proxy`]), the payload is
74    /// handed directly to that call's `on_custom_event` handler instead: it never becomes an
75    /// [`Event`], since [`Event::Custom`] is fixed to `u64`.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`EventProxyClosed`] if the event loop has already exited.
80    pub fn send_event(&self, payload: T) -> Result<(), EventProxyClosed<T>> {
81        self.0
82            .send_event(payload)
83            .map_err(|e| EventProxyClosed(e.0))
84    }
85}
86
87/// Error returned by [`EventProxy::send_event`] when the event loop it targets has already
88/// exited.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub struct EventProxyClosed<T = u64>(T);
91
92impl<T> EventProxyClosed<T> {
93    /// The payload that could not be delivered.
94    #[must_use]
95    pub fn into_inner(self) -> T {
96        self.0
97    }
98}
99
100impl<T> fmt::Display for EventProxyClosed<T> {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(f, "event loop closed")
103    }
104}
105
106impl<T: fmt::Debug> std::error::Error for EventProxyClosed<T> {}
107
108/// Window configuration for [`run_windowed`] / [`run_app`].
109///
110/// Deliberately renderer-agnostic: pixel dimensions, not grid/font/scale.
111/// Use [`fit`](Self::fit) to derive the pixel size from a presenter's own
112/// cell geometry.
113// Five independent window attribute toggles (`fill_viewport`, `resizable`, `decorations`,
114// `fullscreen`, `transparency`), not a state machine in disguise: each maps to one winit
115// `WindowAttributes` builder call and is meaningful on its own.
116#[allow(clippy::struct_excessive_bools)]
117pub struct WindowConfig {
118    title: String,
119    width: u32,
120    height: u32,
121    target_fps: Option<u32>,
122    event_driven: bool,
123    fill_viewport: bool,
124    resizable: bool,
125    decorations: bool,
126    min_size: Option<(u32, u32)>,
127    max_size: Option<(u32, u32)>,
128    initial_position: Option<(i32, i32)>,
129    fullscreen: bool,
130    transparency: bool,
131}
132
133impl WindowConfig {
134    /// Size the window to exactly fit `presenter`'s grid:
135    /// `cols x cell_w` by `rows x cell_h` physical pixels.
136    ///
137    /// This is why renderer crates don't need their own windowing code: the
138    /// grid/cell geometry already lives behind
139    /// [`Output::size`] and
140    /// [`Presenter::cell_size`].
141    ///
142    /// `target_fps` and `event_driven` are independent controls, on native and `wasm32` alike:
143    ///
144    /// - `target_fps` is the frame-rate cap applied whenever a frame is actually rendered: `None`
145    ///   is uncapped (render as fast as the loop reaches a redraw), `Some(fps)` paces redraws to
146    ///   no more than `fps` per second.
147    /// - `event_driven` picks between the two redraw-triggering modes:
148    ///   - `true` is **redraw-on-demand**: a frame is rendered only after something happened (an
149    ///     input or window event, an injected [`Event::Custom`], window creation), and the loop
150    ///     sleeps otherwise. Right for event-driven retro/terminal UIs, which are idle most of
151    ///     the time; wrong for anything that animates from
152    ///     [`Frame::delta`](retroglyph_core::Frame::delta), which will render one frame and then
153    ///     sit still until the next stray event.
154    ///   - `false` is **continuous**: a frame is rendered every tick whether or not anything
155    ///     happened, which is what a [`Tween`](retroglyph_core::Tween)/
156    ///     [`FrameClock`](retroglyph_core::FrameClock)-driven app needs.
157    ///
158    /// The two combine independently: `(Some(fps), false)` is the common capped-animation shape
159    /// (see [`Self::animated`] for a shorthand), `(None, true)` is the common idle-UI shape, and
160    /// `(None, false)` (render every tick, uncapped) is the one combination that was
161    /// previously inexpressible, useful for e.g. measuring a render loop's raw throughput.
162    ///
163    /// On `wasm32` the browser owns frame pacing: winit's web backend delivers each requested
164    /// redraw on the next `requestAnimationFrame`, so an uncapped or `event_driven: false` loop
165    /// still runs at the display refresh rate and `target_fps`'s specific number is advisory
166    /// (there is no way to render faster than `requestAnimationFrame`, and rendering slower would
167    /// mean discarding frames the browser already scheduled). Only the `event_driven` choice
168    /// carries across unaffected.
169    #[must_use]
170    pub fn fit<P: Presenter>(
171        presenter: &P,
172        title: impl Into<String>,
173        target_fps: Option<u32>,
174        event_driven: bool,
175    ) -> Self {
176        let grid = presenter.size();
177        let (cell_w, cell_h) = presenter.cell_size();
178        Self {
179            title: title.into(),
180            width: u32::from(grid.width) * cell_w,
181            height: u32::from(grid.height) * cell_h,
182            target_fps,
183            event_driven,
184            fill_viewport: false,
185            resizable: true,
186            decorations: true,
187            min_size: None,
188            max_size: None,
189            initial_position: None,
190            fullscreen: false,
191            transparency: false,
192        }
193    }
194
195    /// The window title, as set by [`fit`](Self::fit).
196    #[must_use]
197    pub fn title(&self) -> &str {
198        &self.title
199    }
200
201    /// Initial inner width in physical pixels, as computed by [`fit`](Self::fit).
202    #[must_use]
203    pub const fn width(&self) -> u32 {
204        self.width
205    }
206
207    /// Initial inner height in physical pixels, as computed by [`fit`](Self::fit).
208    #[must_use]
209    pub const fn height(&self) -> u32 {
210        self.height
211    }
212
213    /// Shorthand for [`fit`](Self::fit) with continuous, non-event-driven, `fps`-capped
214    /// redraws: the shape most animated apps want. Equivalent to
215    /// `Self::fit(presenter, title, Some(fps), false)`.
216    #[must_use]
217    pub fn animated<P: Presenter>(presenter: &P, title: impl Into<String>, fps: u32) -> Self {
218        Self::fit(presenter, title, Some(fps), false)
219    }
220
221    /// The frame-rate cap passed to [`fit`](Self::fit), if any. `None` means uncapped: a frame
222    /// renders as fast as the loop reaches a redraw; see [`event_driven`](Self::event_driven)
223    /// for whether that's every tick or only on demand.
224    #[must_use]
225    pub const fn target_fps(&self) -> Option<u32> {
226        self.target_fps
227    }
228
229    /// Whether the loop only redraws after an input/window event or injected
230    /// [`Event::Custom`] (`true`), or every tick regardless (`false`), as passed to
231    /// [`fit`](Self::fit).
232    #[must_use]
233    pub const fn event_driven(&self) -> bool {
234        self.event_driven
235    }
236
237    /// Sets whether to size (and keep resizing) the canvas to fill the browser viewport on
238    /// `wasm32`, instead of the pixel size [`fit`](Self::fit) computed: a full-screen,
239    /// mobile-web-app feel for games that want it. Has no effect on native, where the OS window
240    /// is already sized by [`fit`](Self::fit) and the window manager owns further resizing
241    /// either way.
242    ///
243    /// Defaults to `false`: most demos/examples should render at their natural grid size
244    /// (`cols x cell_w` by `rows x cell_h`) wherever they land on the page, not stretch to fill
245    /// whatever viewport happens to be hosting them. Opt in explicitly for an app-like,
246    /// full-screen game.
247    #[must_use]
248    pub const fn fill_viewport(mut self, fill_viewport: bool) -> Self {
249        self.fill_viewport = fill_viewport;
250        self
251    }
252
253    /// Sets whether the window can be resized by the user/window manager after creation.
254    ///
255    /// Defaults to `true` (winit's own default). Set to `false` for fixed-size retro windows
256    /// where the grid is meant to stay put: resizing a pseudo-graphic UI usually means picking
257    /// a new grid size, not stretching cells, and most callers that care already size the window
258    /// to their content via [`fit`](Self::fit).
259    ///
260    /// On `wasm32`, winit's web backend ignores this (there is no OS-level resize grip on a
261    /// canvas); it's still applied for source-level parity with native, it just has no effect.
262    #[must_use]
263    pub const fn resizable(mut self, resizable: bool) -> Self {
264        self.resizable = resizable;
265        self
266    }
267
268    /// Sets whether the window has OS chrome: title bar, borders, close/minimize/maximize
269    /// buttons.
270    ///
271    /// Defaults to `true` (winit's own default). Set to `false` for a borderless window
272    /// (custom-drawn title bars, retro full-bleed layouts).
273    ///
274    /// On `wasm32`, winit's web backend ignores this (a canvas has no OS chrome to begin with);
275    /// it's still applied for source-level parity with native, it just has no effect.
276    #[must_use]
277    pub const fn decorations(mut self, decorations: bool) -> Self {
278        self.decorations = decorations;
279        self
280    }
281
282    /// Sets the minimum inner (content) size in physical pixels.
283    ///
284    /// Defaults to no minimum.
285    #[must_use]
286    pub const fn min_size(mut self, width: u32, height: u32) -> Self {
287        self.min_size = Some((width, height));
288        self
289    }
290
291    /// Sets the maximum inner (content) size in physical pixels.
292    ///
293    /// Defaults to no maximum.
294    #[must_use]
295    pub const fn max_size(mut self, width: u32, height: u32) -> Self {
296        self.max_size = Some((width, height));
297        self
298    }
299
300    /// Sets the desired initial outer window position in physical pixels.
301    ///
302    /// Defaults to letting the platform choose.
303    ///
304    /// On `wasm32`, winit's web backend maps this to the canvas's `position: absolute`
305    /// left/top, which only does anything if the page's CSS has already opted the canvas into
306    /// absolute/relative positioning; otherwise normal document flow overrides it.
307    #[must_use]
308    pub const fn initial_position(mut self, x: i32, y: i32) -> Self {
309        self.initial_position = Some((x, y));
310        self
311    }
312
313    /// Sets whether to request borderless fullscreen (on the window's current monitor) at
314    /// creation.
315    ///
316    /// Defaults to `false`. This only exposes borderless fullscreen, not winit's
317    /// exclusive-fullscreen video-mode API: retro/terminal-style apps render a fixed cell grid,
318    /// not a resolution-dependent 3D scene, so there is no benefit to an exclusive video-mode
319    /// switch, only extra platform-specific complexity (enumerating
320    /// [`VideoModeHandle`](winit::monitor::VideoModeHandle)s) for a mode real games would rarely
321    /// want here.
322    ///
323    /// On `wasm32`, winit's web backend maps this to the browser's Fullscreen API
324    /// (`Element.requestFullscreen`), which most browsers refuse to grant without a user
325    /// gesture; requesting it unconditionally at window-creation time (before any gesture) is
326    /// liable to silently fail there. Still applied for source-level parity with native.
327    #[must_use]
328    pub const fn fullscreen(mut self, fullscreen: bool) -> Self {
329        self.fullscreen = fullscreen;
330        self
331    }
332
333    /// Sets whether the window's background supports transparency (alpha blending with whatever
334    /// is behind it).
335    ///
336    /// Defaults to `false` (winit's own default).
337    ///
338    /// On `wasm32`, winit's web backend ignores this (a canvas is already alpha-blended with the
339    /// page behind it via normal CSS compositing); it's still applied for source-level parity
340    /// with native, it just has no effect.
341    #[must_use]
342    pub const fn transparency(mut self, transparency: bool) -> Self {
343        self.transparency = transparency;
344        self
345    }
346}
347
348/// Open a window and drive `app_loop` from the winit event loop.
349///
350/// On native this blocks the calling thread until the loop exits; on wasm it
351/// returns immediately and the loop continues on `requestAnimationFrame`.
352///
353/// The closure receives `&mut Terminal<WindowBackend<P>>` and is called on
354/// every frame tick. Window close pushes [`Event::Close`] into the event
355/// queue rather than exiting: the game decides when to terminate.
356///
357/// # Presenting is automatic
358///
359/// Unlike [`run_blocking`](retroglyph_core::run_blocking), this driver calls
360/// [`Terminal::present`] for you, once, right after `app_loop` returns each frame: you no longer
361/// need to (and, for a stale-content bug fixed by this behavior, should not rely on remembering
362/// to) call it yourself inside `app_loop`. Calling it yourself is still supported and has no ill
363/// effect (the driver detects it already ran and skips its own call), for example if you also want
364/// to call [`Terminal::present`] to observe its `Result` directly.
365///
366/// # Errors
367///
368/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
369/// created or fails while running.
370pub fn run_windowed<P, F>(
371    config: WindowConfig,
372    presenter: P,
373    app_loop: F,
374) -> Result<(), winit::error::EventLoopError>
375where
376    P: Presenter + 'static,
377    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
378{
379    run_windowed_with_proxy(config, presenter, app_loop, |_proxy| {})
380}
381
382/// Same as [`run_windowed`], but also hands `on_proxy` an [`EventProxy`] for injecting
383/// cross-thread events.
384///
385/// `on_proxy` is called synchronously right after the event loop (and the proxy) is created,
386/// before this function starts blocking the calling thread on native. Use this over
387/// [`run_windowed`] whenever another thread (network, audio, timer, ...) needs to wake the event
388/// loop and deliver an [`Event::Custom`] to the app; `on_proxy` is the hook to hand a clone of the
389/// proxy off to that thread before the loop takes over the calling thread.
390///
391/// The injected payload is always a `u64`, delivered as [`Event::Custom`] through the app's
392/// normal `poll_event`/frame loop; see [`run_windowed_with_typed_proxy`] if a worker thread
393/// needs to hand back a real payload (a loaded asset, a network response) instead of a
394/// correlation id into a side table.
395///
396/// # Presenting is automatic
397///
398/// See [`run_windowed`]'s "Presenting is automatic" section: this function shares the same
399/// automatic-present behavior; `app_loop` no longer needs to call [`Terminal::present`] itself.
400///
401/// # Examples
402///
403/// ```no_run
404/// use retroglyph_core::event::Event;
405/// use retroglyph_software::SoftwareBackendBuilder;
406/// use retroglyph_window::winit::{WindowConfig, run_windowed_with_proxy};
407/// use std::time::Duration;
408///
409/// let renderer = SoftwareBackendBuilder::new()
410///     .grid_size(80, 25)
411///     .scale(2)
412///     .build()
413///     .expect("backend init failed")
414///     .run_headless()
415///     .expect("renderer init failed");
416/// let config = WindowConfig::fit(&renderer, "My Game", None, true);
417///
418/// run_windowed_with_proxy(
419///     config,
420///     renderer,
421///     move |term| {
422///         if let Some(Event::Custom(id)) = term.poll(Duration::from_millis(16)) {
423///             // Handle the tick/network/audio result tagged `id`.
424///             println!("got custom event {id}");
425///         }
426///     },
427///     |proxy| {
428///         // Runs before the blocking call below starts, so the proxy can be
429///         // handed off to a worker thread up front.
430///         std::thread::spawn(move || loop {
431///             std::thread::sleep(Duration::from_secs(1));
432///             if proxy.send_event(1).is_err() {
433///                 break; // The window closed; stop ticking.
434///             }
435///         });
436///     },
437/// )
438/// .expect("event loop failed");
439/// ```
440///
441/// # Errors
442///
443/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
444/// created or fails while running.
445pub fn run_windowed_with_proxy<P, F, O>(
446    config: WindowConfig,
447    presenter: P,
448    app_loop: F,
449    on_proxy: O,
450) -> Result<(), winit::error::EventLoopError>
451where
452    P: Presenter + 'static,
453    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
454    O: FnOnce(EventProxy),
455{
456    run_windowed_with_typed_proxy_and_exit_flag(
457        config,
458        presenter,
459        app_loop,
460        on_proxy,
461        push_custom_event,
462        Rc::new(Cell::new(false)),
463        Rc::new(Cell::new(false)),
464    )
465}
466
467/// Same as [`run_windowed_with_proxy`], but the injected payload can be any `T: Send + 'static`
468/// instead of a fixed `u64`.
469///
470/// A `T` payload never becomes a [`retroglyph_core::event::Event`]: [`Event::Custom`] is fixed to
471/// `u64` (see its doc comment for why), so genericizing it would be a breaking change to
472/// [`retroglyph_core`] far larger than this API needs. Instead, each injected `T` is handed
473/// directly to `on_custom_event`, called synchronously from winit's `user_event` callback with
474/// the same `&mut Terminal<WindowBackend<P>>` `app_loop` receives on redraw, so a handler that
475/// wants the result to affect the next frame just needs to record it in state the closures
476/// share, or push its own backend-agnostic event/marker for `app_loop` to notice.
477///
478/// # Presenting is automatic
479///
480/// See [`run_windowed`]'s "Presenting is automatic" section: this function shares the same
481/// automatic-present behavior; `app_loop` no longer needs to call [`Terminal::present`] itself.
482///
483/// This delivery is a side channel, not a queued [`Event`]: `on_custom_event` runs as soon as
484/// winit dispatches the `user_event`, which can be before `app_loop` next drains earlier-queued
485/// window/input events via [`poll`](retroglyph_core::Terminal::poll). Don't assume a `T` arrives
486/// interleaved with the `poll()` stream in send order relative to those events; if that matters,
487/// use [`run_windowed_with_proxy`]'s plain `u64`/[`Event::Custom`] path instead, which does
488/// interleave on the backend's own FIFO.
489///
490/// # Examples
491///
492/// ```no_run
493/// use retroglyph_software::SoftwareBackendBuilder;
494/// use retroglyph_window::winit::{WindowConfig, run_windowed_with_typed_proxy};
495/// use std::time::Duration;
496///
497/// enum WorkerResult {
498///     AssetLoaded { name: String, bytes: Vec<u8> },
499/// }
500///
501/// let renderer = SoftwareBackendBuilder::new()
502///     .grid_size(80, 25)
503///     .scale(2)
504///     .build()
505///     .expect("backend init failed")
506///     .run_headless()
507///     .expect("renderer init failed");
508/// let config = WindowConfig::fit(&renderer, "My Game", None, true);
509///
510/// run_windowed_with_typed_proxy(
511///     config,
512///     renderer,
513///     move |term| {
514///         let _ = term.poll(Duration::from_millis(16));
515///     },
516///     |proxy| {
517///         std::thread::spawn(move || {
518///             let bytes = std::fs::read("asset.bin").unwrap_or_default();
519///             let _ = proxy.send_event(WorkerResult::AssetLoaded {
520///                 name: "asset.bin".into(),
521///                 bytes,
522///             });
523///         });
524///     },
525///     |result: WorkerResult, _term| match result {
526///         WorkerResult::AssetLoaded { name, bytes } => {
527///             println!("loaded {name}: {} bytes", bytes.len());
528///         }
529///     },
530/// )
531/// .expect("event loop failed");
532/// ```
533///
534/// # Errors
535///
536/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
537/// created or fails while running.
538pub fn run_windowed_with_typed_proxy<T, P, F, O, D>(
539    config: WindowConfig,
540    presenter: P,
541    app_loop: F,
542    on_proxy: O,
543    on_custom_event: D,
544) -> Result<(), winit::error::EventLoopError>
545where
546    T: Send + 'static,
547    P: Presenter + 'static,
548    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
549    O: FnOnce(EventProxy<T>),
550    D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
551{
552    run_windowed_with_typed_proxy_and_exit_flag(
553        config,
554        presenter,
555        app_loop,
556        on_proxy,
557        on_custom_event,
558        Rc::new(Cell::new(false)),
559        Rc::new(Cell::new(false)),
560    )
561}
562
563/// Delivers a `u64` payload injected through [`EventProxy::send_event`] as
564/// [`Event::Custom`]: the fixed `on_custom_event` behind [`run_windowed_with_proxy`]/
565/// [`run_app_with_proxy`], preserving the pre-generic behavior exactly.
566fn push_custom_event<P: Presenter>(id: u64, term: &mut Terminal<WindowBackend<P>>) {
567    term.backend_mut().push_event(Event::Custom(id));
568}
569
570/// Shared implementation behind [`run_windowed_with_proxy`], [`run_windowed_with_typed_proxy`],
571/// [`run_app_with_proxy`], and [`run_app_with_typed_proxy`].
572///
573/// `exit_requested` is checked after every [`WindowEvent::RedrawRequested`] and, when set, drives
574/// [`ActiveEventLoop::exit`] so the loop unwinds normally (see [`WindowApp::exit_requested`]'s doc
575/// comment for why this can't be plumbed through `app_loop`'s return value instead).
576/// [`run_windowed_with_proxy`]/[`run_windowed_with_typed_proxy`] pass flags nobody ever sets (a
577/// plain `FnMut(&mut Terminal<..>)` closure has no way to reach them); [`run_app_with_proxy`]/
578/// [`run_app_with_typed_proxy`] share both with the closure they build around `app_loop`: it sets
579/// `exit_requested` on [`Flow::Exit`](retroglyph_core::Flow::Exit) and `skip_present` on
580/// [`Flow::Idle`](retroglyph_core::Flow::Idle).
581fn run_windowed_with_typed_proxy_and_exit_flag<T, P, F, O, D>(
582    config: WindowConfig,
583    presenter: P,
584    app_loop: F,
585    on_proxy: O,
586    on_custom_event: D,
587    exit_requested: Rc<Cell<bool>>,
588    skip_present: Rc<Cell<bool>>,
589) -> Result<(), winit::error::EventLoopError>
590where
591    T: Send + 'static,
592    P: Presenter + 'static,
593    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
594    O: FnOnce(EventProxy<T>),
595    D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
596{
597    let terminal = Terminal::new(WindowBackend::new(presenter));
598    let event_loop = EventLoop::<T>::with_user_event().build()?;
599    on_proxy(EventProxy(event_loop.create_proxy()));
600
601    let frame_interval = config
602        .target_fps
603        .map(|fps| Duration::from_secs_f64(1.0 / f64::from(fps)));
604
605    let attrs = WindowAttrs::from(&config);
606    let app = WindowApp {
607        terminal: Some(terminal),
608        app_loop,
609        on_custom_event,
610        window: None,
611        title: config.title,
612        init_size: InitWindowSize {
613            width: config.width,
614            height: config.height,
615        },
616        attrs,
617        #[cfg(target_arch = "wasm32")]
618        fill_viewport: config.fill_viewport,
619        current_modifiers: KeyModifiers::NONE,
620        cursor_px: (0.0, 0.0),
621        active_touch: None,
622        held_buttons: 0,
623        frame_interval,
624        event_driven: config.event_driven,
625        #[cfg(not(target_arch = "wasm32"))]
626        next_frame: std::time::Instant::now(),
627        exit_requested,
628        skip_present,
629        needs_redraw: true,
630        consecutive_present_errors: 0,
631        _user_event: PhantomData,
632    };
633
634    #[cfg(not(target_arch = "wasm32"))]
635    {
636        let mut app = app;
637        event_loop.run_app(&mut app)
638    }
639
640    #[cfg(target_arch = "wasm32")]
641    {
642        use winit::platform::web::EventLoopExtWebSys;
643        event_loop.spawn_app(app);
644        Ok(())
645    }
646}
647
648/// Drive an [`App`](retroglyph_core::App) from the windowed event loop.
649///
650/// This is the inverted driver: winit owns the event loop and calls back
651/// into the app on each redraw, rather than the app owning a `while` loop.
652///
653/// Each frame builds a [`Frame`](retroglyph_core::Frame) with a wall-clock
654/// `dt` measured via [`web_time::Instant`]: a plain [`std::time::Instant`]
655/// re-export on native, backed by the browser's `Performance.now()` on
656/// `wasm32` (where `std::time::Instant` itself is unavailable). Calls
657/// [`step`](retroglyph_core::step).
658///
659/// On [`Flow::Exit`](retroglyph_core::Flow) the event loop exits gracefully
660/// (via [`ActiveEventLoop::exit`]) instead of force-exiting the process, so
661/// the stack unwinds normally and `Drop` impls up the call chain (unflushed
662/// writes, GPU/surface teardown, app-level RAII) run before the process
663/// exits. This works the same on wasm: winit's web backend implements
664/// `ActiveEventLoop::exit` by stopping its `requestAnimationFrame`-driven
665/// runner rather than leaving it a no-op.
666///
667/// # Presenting is automatic
668///
669/// [`App::update`](retroglyph_core::App::update) no longer needs to call [`Terminal::present`]
670/// itself here: this driver presents automatically after each call, the same as [`run_windowed`]
671/// (see its "Presenting is automatic" section), except on
672/// [`Flow::Idle`](retroglyph_core::Flow::Idle), where the present is skipped entirely and the
673/// previous frame stays on screen.
674///
675/// # Resizing is not automatic
676///
677/// This driver does not resize the [`Terminal`] itself. On every window resize it pushes
678/// [`Event::Resize`] with the new cell dimensions; the app must poll that event and call
679/// [`Terminal::resize`] to resize the terminal's own grid buffers.
680///
681/// # Errors
682///
683/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
684/// created or fails while running.
685pub fn run_app<P, A>(
686    config: WindowConfig,
687    presenter: P,
688    app: A,
689) -> Result<(), winit::error::EventLoopError>
690where
691    P: Presenter + 'static,
692    A: retroglyph_core::App<WindowBackend<P>> + 'static,
693{
694    run_app_with_proxy(config, presenter, app, |_proxy| {})
695}
696
697/// Same as [`run_app`], but also hands `on_proxy` an [`EventProxy`] for injecting cross-thread
698/// events.
699///
700/// See [`run_windowed_with_proxy`] for when/why to use the `_with_proxy` variant over the plain
701/// one. The injected payload is always a `u64`, delivered as [`Event::Custom`]; see
702/// [`run_app_with_typed_proxy`] for injecting any `T: Send + 'static`.
703///
704/// See [`run_app`]'s "Presenting is automatic" section: this function shares the same
705/// automatic-present behavior.
706///
707/// # Errors
708///
709/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
710/// created or fails while running.
711pub fn run_app_with_proxy<P, A, O>(
712    config: WindowConfig,
713    presenter: P,
714    app: A,
715    on_proxy: O,
716) -> Result<(), winit::error::EventLoopError>
717where
718    P: Presenter + 'static,
719    A: retroglyph_core::App<WindowBackend<P>> + 'static,
720    O: FnOnce(EventProxy),
721{
722    run_app_with_typed_proxy(config, presenter, app, on_proxy, push_custom_event)
723}
724
725/// Same as [`run_app_with_proxy`], but the injected payload can be any `T: Send + 'static`
726/// instead of a fixed `u64`.
727///
728/// See [`run_windowed_with_typed_proxy`] for the same generalization on the raw closure-based
729/// driver, including why a non-`u64` payload bypasses [`retroglyph_core::event::Event`] entirely
730/// and goes straight to `on_custom_event`.
731///
732/// See [`run_app`]'s "Presenting is automatic" section: this function shares the same
733/// automatic-present behavior.
734///
735/// # Errors
736///
737/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
738/// created or fails while running.
739pub fn run_app_with_typed_proxy<T, P, A, O, D>(
740    config: WindowConfig,
741    presenter: P,
742    mut app: A,
743    on_proxy: O,
744    on_custom_event: D,
745) -> Result<(), winit::error::EventLoopError>
746where
747    T: Send + 'static,
748    P: Presenter + 'static,
749    A: retroglyph_core::App<WindowBackend<P>> + 'static,
750    O: FnOnce(EventProxy<T>),
751    D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
752{
753    let mut frame_count = 0u64;
754    let mut last = web_time::Instant::now();
755    let exit_requested = Rc::new(Cell::new(false));
756    let exit_requested_in_loop = exit_requested.clone();
757    let skip_present = Rc::new(Cell::new(false));
758    let skip_present_in_loop = skip_present.clone();
759    run_windowed_with_typed_proxy_and_exit_flag(
760        config,
761        presenter,
762        move |term| {
763            let now = web_time::Instant::now();
764            let delta = now.duration_since(last);
765            last = now;
766            let frame = retroglyph_core::Frame {
767                delta,
768                frame: frame_count,
769            };
770            frame_count = frame_count.wrapping_add(1);
771            match retroglyph_core::step(term, &mut app, &frame) {
772                retroglyph_core::Flow::Exit => exit_requested_in_loop.set(true),
773                // Nothing changed: tell `handle_redraw_requested` to skip its automatic present
774                // for this frame. `Terminal::present` always presents unconditionally, so this
775                // flag is the only thing standing between an idle frame and an unwanted redraw.
776                retroglyph_core::Flow::Idle => skip_present_in_loop.set(true),
777                // `Flow` is `#[non_exhaustive]`; any other variant (including `Continue`) presents
778                // as usual via `handle_redraw_requested`'s automatic present.
779                _ => {}
780            }
781        },
782        on_proxy,
783        on_custom_event,
784        exit_requested,
785        skip_present,
786    )
787}
788
789/// Initial window dimensions used before the first Resized event.
790struct InitWindowSize {
791    width: u32,
792    height: u32,
793}
794
795/// The subset of [`WindowConfig`]'s builder attributes applied once, up front, to
796/// `Window::default_attributes()` in [`create_window_and_surface`](WindowApp::create_window_and_surface).
797///
798/// Grouped into its own type (rather than six more fields directly on [`WindowApp`]) since
799/// they're only ever read in that one place, unlike `fill_viewport`, which also gates per-resize
800/// behavior elsewhere.
801// See `WindowConfig`'s matching `#[allow]` for why these bools are independent toggles, not a
802// state machine.
803#[allow(clippy::struct_excessive_bools)]
804struct WindowAttrs {
805    resizable: bool,
806    decorations: bool,
807    min_size: Option<(u32, u32)>,
808    max_size: Option<(u32, u32)>,
809    initial_position: Option<(i32, i32)>,
810    fullscreen: bool,
811    transparency: bool,
812}
813
814impl From<&WindowConfig> for WindowAttrs {
815    fn from(config: &WindowConfig) -> Self {
816        Self {
817            resizable: config.resizable,
818            decorations: config.decorations,
819            min_size: config.min_size,
820            max_size: config.max_size,
821            initial_position: config.initial_position,
822            fullscreen: config.fullscreen,
823            transparency: config.transparency,
824        }
825    }
826}
827
828impl Default for WindowAttrs {
829    /// Mirrors [`WindowConfig::fit`]'s defaults, for tests that construct a [`WindowApp`]
830    /// directly without going through a [`WindowConfig`].
831    fn default() -> Self {
832        Self {
833            resizable: true,
834            decorations: true,
835            min_size: None,
836            max_size: None,
837            initial_position: None,
838            fullscreen: false,
839            transparency: false,
840        }
841    }
842}
843
844/// Bitmask for [`MouseButton::Left`] in [`WindowApp::held_buttons`].
845const BUTTON_MASK_LEFT: u8 = 1 << 0;
846/// Bitmask for [`MouseButton::Right`] in [`WindowApp::held_buttons`].
847const BUTTON_MASK_RIGHT: u8 = 1 << 1;
848/// Bitmask for [`MouseButton::Middle`] in [`WindowApp::held_buttons`].
849const BUTTON_MASK_MIDDLE: u8 = 1 << 2;
850
851/// Maps a [`MouseButton`] to its bit in [`WindowApp::held_buttons`].
852const fn button_mask(button: MouseButton) -> u8 {
853    match button {
854        MouseButton::Left => BUTTON_MASK_LEFT,
855        MouseButton::Right => BUTTON_MASK_RIGHT,
856        MouseButton::Middle => BUTTON_MASK_MIDDLE,
857        // `MouseButton` is `#[non_exhaustive]`; treat any future variant as unmasked (never
858        // drives a `Drag`) rather than failing to compile when one is added upstream.
859        _ => 0,
860    }
861}
862
863/// The winit `ApplicationHandler`: owns the window, the terminal, and the
864/// per-frame closure.
865///
866/// Generic over the injected user-event payload `T` and its delivery handler `D`, so the same
867/// type backs both the `u64`/[`Event::Custom`] path ([`run_windowed_with_proxy`]/
868/// [`run_app_with_proxy`], where `T = u64` and `D` is [`push_custom_event`]) and the typed-`T`
869/// path ([`run_windowed_with_typed_proxy`]/[`run_app_with_typed_proxy`], where `D` is the
870/// caller-supplied `on_custom_event`).
871struct WindowApp<P: Presenter, F, T, D> {
872    terminal: Option<Terminal<WindowBackend<P>>>,
873    app_loop: F,
874    /// Delivers one injected `T` payload to the app; see [`handle_user_event`](Self::handle_user_event).
875    on_custom_event: D,
876    /// `T` only ever appears as `D`'s argument, never stored directly: see [`ApplicationHandler`]
877    /// for why `WindowApp` still needs to name it (winit dispatches `user_event` generically over
878    /// the event-loop's payload type).
879    _user_event: PhantomData<fn(T)>,
880    window: Option<Arc<Window>>,
881    title: String,
882    init_size: InitWindowSize,
883    /// See [`WindowConfig`]'s `resizable`/`decorations`/`min_size`/`max_size`/
884    /// `initial_position`/`fullscreen`/`transparency` fields; applied once at window creation.
885    attrs: WindowAttrs,
886    /// See [`WindowConfig::fill_viewport`]. Only meaningful on `wasm32`; not
887    /// even stored on native, where it would do nothing.
888    #[cfg(target_arch = "wasm32")]
889    fill_viewport: bool,
890    /// Current modifier key state, updated by `ModifiersChanged` events.
891    current_modifiers: KeyModifiers,
892    /// Last known cursor position in physical pixels.
893    cursor_px: (f64, f64),
894    /// The finger currently treated as the pointer, if any.
895    ///
896    /// Touch input (mobile browsers, touchscreens) arrives as
897    /// [`WindowEvent::Touch`], not as `CursorMoved`/`MouseInput`. The first
898    /// finger down is adopted as "the pointer" and synthesized into the same
899    /// left-button mouse events games already handle; other fingers are
900    /// ignored until it lifts, so a stray second finger can't teleport the
901    /// cursor mid-drag.
902    active_touch: Option<u64>,
903    /// Bitmask of currently held mouse buttons, built from [`button_mask`]. Updated by
904    /// [`on_mouse_input`](Self::on_mouse_input) and consulted by
905    /// [`on_cursor_moved`](Self::on_cursor_moved) to decide between [`MouseEventKind::Moved`] and
906    /// [`MouseEventKind::Drag`]. A bitmask (rather than tracking only the most recent button)
907    /// because more than one button can be held at once, and each needs its own accurate
908    /// press/release accounting.
909    held_buttons: u8,
910    /// Frame-rate cap derived from [`WindowConfig::target_fps`]: `Some(interval)` paces redraws
911    /// to no more than one per `interval`, `None` leaves them uncapped. Independent of
912    /// [`event_driven`](Self::event_driven); see [`WindowConfig::fit`].
913    ///
914    /// Stored on `wasm32` too, where only the `Some`/`None` distinction is used: the browser's
915    /// `requestAnimationFrame` already paces the loop, so there is no deadline to sleep until.
916    frame_interval: Option<Duration>,
917    /// Deadline for the next frame when `frame_interval` is set. Native only: `wasm32` has no
918    /// sleeping event loop to schedule against.
919    #[cfg(not(target_arch = "wasm32"))]
920    next_frame: std::time::Instant,
921    /// Whether [`about_to_wait`](ApplicationHandler::about_to_wait) gates redraws on
922    /// [`needs_redraw`](Self::needs_redraw) (`true`) or always redraws every tick (`false`),
923    /// as passed to [`WindowConfig::fit`]. Independent of
924    /// [`frame_interval`](Self::frame_interval): this controls *whether* a tick redraws at all,
925    /// the frame-rate cap controls *how often* once it does.
926    event_driven: bool,
927    /// Set by `app_loop` (specifically [`run_app_with_proxy`]'s closure) to request the event
928    /// loop stop, instead of calling `std::process::exit` directly.
929    ///
930    /// `app_loop` is a plain `FnMut(&mut Terminal<..>)` with no return value and no
931    /// [`ActiveEventLoop`] handle, so it can't call `event_loop.exit()` itself; it can only flip
932    /// this shared flag. [`handle_window_event`](Self::handle_window_event) (which runs
933    /// `app_loop` on [`WindowEvent::RedrawRequested`]) deliberately takes no
934    /// [`ActiveEventLoop`] either, so unit tests can drive it without a live winit loop (see its
935    /// doc comment). `ApplicationHandler::window_event`, which does have the `ActiveEventLoop`,
936    /// checks this flag right after `handle_window_event` returns and calls `event_loop.exit()`
937    /// if it's set, letting the stack unwind normally (`Drop` impls run) instead of
938    /// force-terminating the process.
939    exit_requested: Rc<Cell<bool>>,
940    /// Set by `app_loop` (specifically [`run_app_with_proxy`]'s closure) on
941    /// [`Flow::Idle`](retroglyph_core::Flow::Idle) to tell
942    /// [`handle_redraw_requested`](Self::handle_redraw_requested) to skip its automatic present
943    /// for this frame. Cleared at the start of every `handle_redraw_requested` call, so it only
944    /// ever reflects the outcome of the `app_loop` call about to run.
945    ///
946    /// A plain `FnMut(&mut Terminal<..>)` closure (`run_windowed`/`run_windowed_with_proxy`) has
947    /// no `Flow` concept and never sets this, the same way it never sets `exit_requested`.
948    skip_present: Rc<Cell<bool>>,
949    /// Set whenever something happened that the app loop should get a chance to react to:
950    /// window creation, an input/window event, or an injected [`Event::Custom`]. Cleared once
951    /// [`about_to_wait`](ApplicationHandler::about_to_wait) turns it into a `request_redraw()`
952    /// call.
953    ///
954    /// Retro/terminal-style apps are event-driven, not animation-driven, so "nothing happened"
955    /// should mean "render nothing new": see this field's use in `about_to_wait` for why that
956    /// keeps the loop asleep (`ControlFlow::Wait`) instead of spinning at ~100% CPU redrawing an
957    /// unchanged frame forever.
958    ///
959    /// Only consulted when [`event_driven`](Self::event_driven) is `true`, i.e. redraw-on-demand
960    /// mode. An app that animates over time has no event to point at and would freeze under this
961    /// gate, which is what `event_driven: false` (continuous mode) is for; see
962    /// [`WindowConfig::fit`].
963    needs_redraw: bool,
964    /// Count of consecutive `present()` failures, reset to 0 on the next success. Drives
965    /// [`present_failure_action`]'s logging-verbosity and surface-recovery decisions in the
966    /// `RedrawRequested` arm of [`handle_window_event`](Self::handle_window_event).
967    consecutive_present_errors: u32,
968}
969
970impl<P: Presenter, F, T, D> WindowApp<P, F, T, D> {
971    /// Create the window and initialize the surface.
972    ///
973    /// Returns `Some(window)` on success, logs and returns `None` on failure.
974    fn create_window_and_surface(&mut self, event_loop: &ActiveEventLoop) -> Option<Arc<Window>> {
975        // On native, size the window to fit the grid (`WindowConfig::fit`)
976        // and let the OS window manager own further resizing. On wasm, if
977        // `fill_viewport` is set, there's no OS window to fit into (the
978        // canvas *is* the page), so size it to the browser viewport
979        // instead, for a full-screen, mobile-web-app feel; otherwise it's
980        // sized the same as native (`init_size`, the natural grid size),
981        // which is what most demos/examples want; see
982        // `WindowConfig::fill_viewport`'s doc comment. winit sets an inline
983        // `width`/`height` style on the canvas matching whatever size we
984        // request here; it does not derive that size from page CSS, so this
985        // has to happen in Rust.
986        //
987        // Crucially, the viewport-filling size *must* be the viewport size
988        // at the real (uncapped) device pixel ratio, not the DPR-capped size
989        // used for the software backing store below. winit's wasm backend
990        // converts whatever `PhysicalSize` we pass here back to a logical
991        // (CSS pixel) size using `window.devicePixelRatio()` (the actual,
992        // uncapped ratio) to set the canvas's inline `style.width`/
993        // `style.height`. Handing it a DPR-capped physical size makes it
994        // divide by a *larger* real DPR than the one used to compute that
995        // size, so the resulting CSS size comes out smaller than the
996        // viewport (the higher the real DPR above the cap, the more the
997        // canvas visibly shrinks, on a phone with DPR 3 and our 1.5 cap,
998        // that's 50% of the screen). See `web::web_viewport_surface_physical_size`
999        // for the separate, capped size used for the raster backing store.
1000        // On native, `init_size` is expressed in logical (1x) pixels --
1001        // `WindowConfig::fit` derives it from the presenter's grid/cell
1002        // geometry, which assumes an unscaled cell. Requesting that count
1003        // directly as a `PhysicalSize` on a HiDPI display asks winit/the OS
1004        // for a window with fewer true pixels than the monitor actually
1005        // has, so it gets upscaled blurrily to fill the same logical space
1006        // instead of rendering crisply at native resolution from the first
1007        // frame. Scaling by the primary monitor's `scale_factor` up front
1008        // (falling back to `1.0` when no monitor is available, e.g.
1009        // headless/CI) avoids that: see `physical_size_for`.
1010        #[cfg(not(target_arch = "wasm32"))]
1011        let physical_size = {
1012            let scale_factor = event_loop
1013                .primary_monitor()
1014                .map_or(1.0, |monitor| monitor.scale_factor());
1015            let (width, height) =
1016                physical_size_for(self.init_size.width, self.init_size.height, scale_factor);
1017            winit::dpi::PhysicalSize::new(width, height)
1018        };
1019        #[cfg(target_arch = "wasm32")]
1020        let physical_size = if self.fill_viewport {
1021            web::web_viewport_layout_physical_size().unwrap_or_else(|| {
1022                winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height)
1023            })
1024        } else {
1025            winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height)
1026        };
1027        #[cfg(target_arch = "wasm32")]
1028        let surface_physical_size = if self.fill_viewport {
1029            web::web_viewport_surface_physical_size().unwrap_or(physical_size)
1030        } else {
1031            physical_size
1032        };
1033        #[cfg(not(target_arch = "wasm32"))]
1034        let surface_physical_size = physical_size;
1035
1036        let attrs = Window::default_attributes()
1037            .with_title(&self.title)
1038            .with_inner_size(physical_size)
1039            .with_resizable(self.attrs.resizable)
1040            .with_decorations(self.attrs.decorations)
1041            .with_transparent(self.attrs.transparency);
1042        let attrs = match self.attrs.min_size {
1043            Some((w, h)) => attrs.with_min_inner_size(winit::dpi::PhysicalSize::new(w, h)),
1044            None => attrs,
1045        };
1046        let attrs = match self.attrs.max_size {
1047            Some((w, h)) => attrs.with_max_inner_size(winit::dpi::PhysicalSize::new(w, h)),
1048            None => attrs,
1049        };
1050        let attrs = match self.attrs.initial_position {
1051            Some((x, y)) => attrs.with_position(winit::dpi::PhysicalPosition::new(x, y)),
1052            None => attrs,
1053        };
1054        let attrs = if self.attrs.fullscreen {
1055            attrs.with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)))
1056        } else {
1057            attrs
1058        };
1059
1060        #[cfg(target_family = "wasm")]
1061        let attrs = {
1062            use winit::platform::web::WindowAttributesExtWebSys;
1063            attrs.with_append(true)
1064        };
1065
1066        let window = Arc::new(match event_loop.create_window(attrs) {
1067            Ok(w) => w,
1068            Err(e) => {
1069                log::error!("window creation failed: {e}");
1070                event_loop.exit();
1071                return None;
1072            }
1073        });
1074
1075        // IME composition (`WindowEvent::Ime`) is opt-in per winit's own doc comment on that
1076        // variant: without this, platform input methods (Pinyin, Kana, dead-key accents, ...)
1077        // never surface composed text at all, silently limiting windowed-app text input to
1078        // whatever a bare `KeyboardInput` logical key can express. See `translate::translate_ime`
1079        // for how a committed composition is turned into an `Event`.
1080        window.set_ime_allowed(true);
1081
1082        if let Some(term) = self.terminal.as_mut() {
1083            // Hand the presenter a windowing-library-agnostic handle (see
1084            // `Presenter::init_surface`); the winit window stays owned here.
1085            let handle: Arc<dyn crate::presenter::WindowHandle> = window.clone();
1086            if let Err(e) = term.backend_mut().presenter_mut().init_surface(handle) {
1087                log::error!("surface init failed: {e}");
1088                event_loop.exit();
1089                return None;
1090            }
1091            // Set the initial surface size (required on WASM before first present).
1092            // Deliberately `surface_physical_size`, not `physical_size`: the
1093            // raster backing store stays DPR-capped for present() cost even
1094            // though the canvas's CSS size (driven by `physical_size` via
1095            // winit above) matches the full, uncapped viewport.
1096            term.backend_mut()
1097                .presenter_mut()
1098                .resize_surface(surface_physical_size.width, surface_physical_size.height);
1099        }
1100
1101        // Keep the canvas matching the browser viewport as it changes
1102        // (device rotation, browser window resize, address-bar
1103        // show/hide): winit only reacts to size changes we ask for
1104        // ourselves (`request_inner_size`), so a `resize` listener is
1105        // required to make this genuinely responsive rather than a
1106        // one-shot fit at startup. Only installed when `fill_viewport` is
1107        // set, otherwise the canvas should stay at its natural grid size
1108        // regardless of viewport changes.
1109        #[cfg(target_arch = "wasm32")]
1110        if self.fill_viewport {
1111            web::install_viewport_resize_listener(&window);
1112        }
1113
1114        // `WindowEvent::ThemeChanged` (handled in `handle_window_event`)
1115        // only fires on a *change*, so an app that never sees a system
1116        // theme change would otherwise never learn the starting one.
1117        // `Window::theme()` reflects the current system theme both on
1118        // native and on winit's web target (backed by the
1119        // `prefers-color-scheme` media query there), so query it once
1120        // up-front and synthesize the same event a live change would send.
1121        if let Some(theme) = window.theme()
1122            && let Some(term) = self.terminal.as_mut()
1123        {
1124            term.backend_mut().push_event(system_theme_event(theme));
1125        }
1126
1127        Some(window)
1128    }
1129}
1130
1131/// Scales a logical (1x) initial window size up to true physical pixels for
1132/// `scale_factor`, so [`create_window_and_surface`](WindowApp::create_window_and_surface)
1133/// can request a window sized to the primary monitor's actual resolution
1134/// from the first frame, instead of a too-small physical window the OS then
1135/// has to upscale blurrily to fill the same on-screen space.
1136///
1137/// Pure math, kept separate from `create_window_and_surface` so it's unit
1138/// -testable without a live winit event loop / monitor.
1139#[cfg(not(target_arch = "wasm32"))]
1140#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1141fn physical_size_for(logical_width: u32, logical_height: u32, scale_factor: f64) -> (u32, u32) {
1142    (
1143        (f64::from(logical_width) * scale_factor).round() as u32,
1144        (f64::from(logical_height) * scale_factor).round() as u32,
1145    )
1146}
1147
1148/// Number of consecutive `present()` failures after which
1149/// [`handle_window_event`](WindowApp::handle_window_event)'s `RedrawRequested` arm attempts to
1150/// recover by re-initializing the surface (see [`PresentFailureAction::Recover`]).
1151///
1152/// Roughly half a second at 60 FPS: long enough that a single dropped frame (a transient `VSync`
1153/// hiccup, a momentarily occluded window) never triggers a surface rebuild, but short enough that
1154/// a genuinely broken surface (context loss, invalidated swapchain) doesn't sit unrecovered for
1155/// many seconds.
1156const PRESENT_FAILURE_RECOVERY_THRESHOLD: u32 = 30;
1157
1158/// What [`handle_window_event`](WindowApp::handle_window_event)'s `RedrawRequested` arm should do
1159/// in response to the outcome of one `present()` call, given the running count of consecutive
1160/// failures *before* this call.
1161///
1162/// [`Presenter::SurfaceError`] is a generic associated type: the software backend's
1163/// `SurfaceError` just wraps `softbuffer::SoftBufferError`, a plain `#[non_exhaustive]` enum with
1164/// no `Lost`/`Outdated`/`Timeout` discrimination the way `wgpu::SurfaceError` has, so most
1165/// backends can't pattern-match on *why* a present failed to decide whether it's recoverable the
1166/// way a wgpu-based app would. All they can generally observe is a bare `Display`able error and
1167/// whether the failure is a one-off or persistent (via the consecutive-failure count), so the
1168/// recovery strategy here is deliberately generic for that case: rate-limit logging so a
1169/// persistent failure doesn't spam every frame, and after a run of failures long enough to rule
1170/// out a one-off glitch, attempt the one backend-agnostic recovery available: re-running
1171/// [`Presenter::init_surface`] to rebuild the surface from scratch, the same call
1172/// [`create_window_and_surface`](WindowApp::create_window_and_surface) makes at startup.
1173///
1174/// [`RecoverableError::is_recoverable`](crate::presenter::RecoverableError::is_recoverable) is
1175/// the escape hatch for a presenter that *can* categorize its errors: when a failed `present()`
1176/// reports `is_recoverable() == false`, that decision table is skipped entirely in favor of
1177/// [`PresentFailureAction::Fatal`]: retrying a failure the presenter itself already knows is
1178/// unrecoverable can't help, so there's no reason to wait out the consecutive-failure threshold
1179/// first.
1180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1181enum PresentFailureAction {
1182    /// Presenting succeeded; if `was_failing` is `true` the caller should log recovery at `info`
1183    /// or `warn` level (a prior failure streak just ended).
1184    Ok { was_failing: bool },
1185    /// Presenting failed; log at `error!` (first failure in a streak, or the very first ever)
1186    /// or suppress (a already-logged, ongoing streak below the recovery threshold).
1187    Log { at_error_level: bool },
1188    /// Presenting failed and the consecutive-failure count just crossed the recovery threshold:
1189    /// log at `warn!` and attempt to reinitialize the surface.
1190    Recover,
1191    /// Presenting failed with an error the presenter reports as unrecoverable (see
1192    /// [`RecoverableError::is_recoverable`](crate::presenter::RecoverableError::is_recoverable)):
1193    /// log at `error!` immediately and skip the consecutive-failure/recovery bookkeeping
1194    /// entirely: rebuilding the surface via [`Presenter::init_surface`] cannot help a failure
1195    /// already classified as fatal.
1196    Fatal,
1197}
1198
1199/// Decides the action for one `present()` outcome, given `consecutive_failures` *before* this
1200/// call (0 if the previous call succeeded or this is the first call) and, for a failed call,
1201/// whether the presenter reports the error as recoverable (see
1202/// [`RecoverableError::is_recoverable`](crate::presenter::RecoverableError::is_recoverable);
1203/// ignored when `succeeded` is `true`).
1204///
1205/// Pure decision table, kept separate from the live `RedrawRequested` handling (which needs a
1206/// real `Terminal`/`Presenter`/`Window`) so the threshold and logging-level logic is unit
1207/// -testable without any of those, the same reasoning as [`physical_size_for`] and
1208/// [`web::dpr_pointer_scale`] above.
1209const fn present_failure_action(
1210    consecutive_failures: u32,
1211    succeeded: bool,
1212    recoverable: bool,
1213) -> PresentFailureAction {
1214    if succeeded {
1215        return PresentFailureAction::Ok {
1216            was_failing: consecutive_failures > 0,
1217        };
1218    }
1219    if !recoverable {
1220        return PresentFailureAction::Fatal;
1221    }
1222    // `consecutive_failures` is the count *before* this failure, so the count *including* this
1223    // one is `consecutive_failures + 1`; recover exactly when that reaches the threshold, and
1224    // again every full threshold-worth of failures after that (so a failed recovery attempt
1225    // doesn't get retried on literally the next frame, hot-looping surface rebuilds).
1226    if (consecutive_failures + 1).is_multiple_of(PRESENT_FAILURE_RECOVERY_THRESHOLD) {
1227        return PresentFailureAction::Recover;
1228    }
1229    PresentFailureAction::Log {
1230        at_error_level: consecutive_failures == 0,
1231    }
1232}
1233
1234/// Continuous mode's next-frame decision on native: `None` while `now` is still short of
1235/// `next_frame` (the caller parks the loop on `ControlFlow::WaitUntil(next_frame)`), or
1236/// `Some(advanced)` once the deadline has passed, where `advanced` is the deadline for the frame
1237/// after this one.
1238///
1239/// `advanced` is `next_frame + interval` clamped to `now`, so a frame that overran its budget (a
1240/// stalled GPU, a descheduled thread) resumes from the present rather than firing a burst of
1241/// catch-up renders to "make up" the lost time: there is nothing to make up when every frame
1242/// renders the current state.
1243///
1244/// Pure function of the two instants and the interval, kept separate from the live `about_to_wait`
1245/// handling (which needs an [`ActiveEventLoop`] no unit test can construct) for the same reason as
1246/// [`present_failure_action`] and [`physical_size_for`] above. `wasm32` has no sleeping event loop
1247/// to schedule against and never calls this; see `about_to_wait`.
1248#[cfg(not(target_arch = "wasm32"))]
1249fn next_frame_deadline(
1250    now: std::time::Instant,
1251    next_frame: std::time::Instant,
1252    interval: Duration,
1253) -> Option<std::time::Instant> {
1254    if next_frame > now {
1255        return None;
1256    }
1257    Some((next_frame + interval).max(now))
1258}
1259
1260/// Maps winit's [`Theme`](winit::window::Theme) to the backend-agnostic
1261/// [`Event::ThemeChanged`], the only place that conversion needs to happen.
1262const fn system_theme_event(theme: winit::window::Theme) -> Event {
1263    use retroglyph_core::event::SystemTheme;
1264    match theme {
1265        winit::window::Theme::Light => Event::ThemeChanged(SystemTheme::Light),
1266        winit::window::Theme::Dark => Event::ThemeChanged(SystemTheme::Dark),
1267    }
1268}
1269
1270impl<P, F, T, D> ApplicationHandler<T> for WindowApp<P, F, T, D>
1271where
1272    P: Presenter,
1273    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
1274    T: 'static,
1275    D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
1276{
1277    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1278        if let Some(window) = self.create_window_and_surface(event_loop) {
1279            self.window = Some(window);
1280        }
1281        // First frame: nothing has "happened" yet in the input-event sense, but the app still
1282        // needs an initial render once the window/surface exists.
1283        self.needs_redraw = true;
1284    }
1285
1286    fn window_event(
1287        &mut self,
1288        event_loop: &ActiveEventLoop,
1289        _window_id: WindowId,
1290        event: WindowEvent,
1291    ) {
1292        self.handle_window_event(event);
1293        // `app_loop` (run on `RedrawRequested`, inside `handle_window_event`) can only signal
1294        // exit by setting `exit_requested`; see its doc comment for why. Check it here, where
1295        // an `ActiveEventLoop` is actually available, and ask winit to exit gracefully instead of
1296        // the caller force-exiting the process.
1297        if self.exit_requested.get() {
1298            event_loop.exit();
1299        }
1300    }
1301
1302    fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: T) {
1303        self.handle_user_event(event);
1304    }
1305
1306    fn about_to_wait(
1307        &mut self,
1308        #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] event_loop: &ActiveEventLoop,
1309    ) {
1310        // `event_driven` (redraw-on-demand): only proceed if something actually happened since
1311        // the last redraw. Otherwise leave `ControlFlow` at its default `Wait` so the loop sleeps
1312        // instead of spinning at ~100% CPU re-rendering an unchanged frame every iteration --
1313        // retro/terminal-style apps are idle most of the time and event-driven, so "nothing
1314        // happened" should mean "render nothing new". See `needs_redraw`'s doc comment. Not
1315        // `event_driven` (continuous): always proceed, regardless of `needs_redraw`: an app
1316        // driving a tween off `Frame::delta` has something new to show every tick even though no
1317        // input event arrived, which is precisely what the `needs_redraw` gate cannot express.
1318        if self.event_driven && !self.needs_redraw {
1319            return;
1320        }
1321
1322        let Some(interval) = self.frame_interval else {
1323            // Uncapped: render every tick this point is reached.
1324            self.needs_redraw = false;
1325            self.request_redraw();
1326            return;
1327        };
1328
1329        // Capped: pace to `interval`. The two platforms do that differently. Native sleeps until
1330        // the deadline and then renders, since `request_redraw` is serviced within the same loop
1331        // iteration. On `wasm32` there is nothing to sleep in: winit's web backend services
1332        // `request_redraw` on the browser's next `requestAnimationFrame`, roughly one display
1333        // frame later, so sleeping out a full interval *before* asking would pay that latency on
1334        // top of it and halve the achieved frame rate. Ask on every iteration instead and let
1335        // `requestAnimationFrame` do the pacing, which is also what the browser wants, since it
1336        // already throttles background tabs and matches the compositor's cadence.
1337        #[cfg(not(target_arch = "wasm32"))]
1338        match next_frame_deadline(std::time::Instant::now(), self.next_frame, interval) {
1339            None => {
1340                event_loop
1341                    .set_control_flow(winit::event_loop::ControlFlow::WaitUntil(self.next_frame));
1342                return;
1343            }
1344            Some(advanced) => self.next_frame = advanced,
1345        }
1346        #[cfg(target_arch = "wasm32")]
1347        let _ = interval;
1348        self.needs_redraw = false;
1349        self.request_redraw();
1350    }
1351}
1352
1353impl<P, F, T, D> WindowApp<P, F, T, D>
1354where
1355    P: Presenter,
1356    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
1357    D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
1358{
1359    /// Ask winit for a `RedrawRequested`, if the window exists yet.
1360    ///
1361    /// Both [`about_to_wait`](ApplicationHandler::about_to_wait) branches end here; the window is
1362    /// `None` only before `resumed` has run.
1363    fn request_redraw(&self) {
1364        if let Some(window) = &self.window {
1365            window.request_redraw();
1366        }
1367    }
1368
1369    /// Drain one injected user event into `on_custom_event`.
1370    ///
1371    /// Extracted from the `ApplicationHandler::user_event` impl for the same reason as
1372    /// [`handle_window_event`](Self::handle_window_event): so the drain logic can be exercised in
1373    /// unit tests without a live [`ActiveEventLoop`]. There is only ever one event to drain per
1374    /// call (winit calls `user_event` once per [`EventProxy::send_event`]), so "drain" here
1375    /// means "push the one event this call carries", not draining a whole queue at once. For the
1376    /// `u64`/[`Event::Custom`] path, `on_custom_event` is [`push_custom_event`]; for a typed `T`,
1377    /// it's the caller-supplied `on_custom_event` handler passed to
1378    /// [`run_windowed_with_typed_proxy`]/[`run_app_with_typed_proxy`].
1379    fn handle_user_event(&mut self, event: T) {
1380        if let Some(term) = self.terminal.as_mut() {
1381            (self.on_custom_event)(event, term);
1382        }
1383        self.needs_redraw = true;
1384    }
1385
1386    /// Dispatch a [`WindowEvent`] without requiring an [`ActiveEventLoop`].
1387    ///
1388    /// Extracted from the `ApplicationHandler` impl so the translation and
1389    /// event-buffer logic can be called directly in unit tests, where
1390    /// [`ActiveEventLoop`] is not constructable.
1391    fn handle_window_event(&mut self, event: WindowEvent) {
1392        // Every branch below (other than `RedrawRequested`, which *is* the render this flag
1393        // exists to gate) represents something the app loop should get a chance to react to on
1394        // the next frame; see `needs_redraw`'s doc comment for why that matters for idle CPU.
1395        // Set unconditionally up front rather than per-arm: simpler, and the only event that must
1396        // *not* set it (`RedrawRequested`) already clears it again in `about_to_wait` right before
1397        // requesting this same redraw, so a same-tick `RedrawRequested` can't retrigger itself.
1398        if !matches!(event, WindowEvent::RedrawRequested) {
1399            self.needs_redraw = true;
1400        }
1401        match event {
1402            WindowEvent::CloseRequested => {
1403                // Push the event so the game loop can process it (save game,
1404                // confirm dialog, etc.).  Do not call event_loop.exit() here;
1405                // the game decides when to terminate.
1406                if let Some(term) = self.terminal.as_mut() {
1407                    term.backend_mut().push_event(Event::Close);
1408                }
1409            }
1410            WindowEvent::Resized(size) => self.on_resized(size),
1411            WindowEvent::CursorMoved { position, .. } => self.on_cursor_moved(position),
1412            WindowEvent::MouseInput { state, button, .. } => self.on_mouse_input(state, button),
1413            WindowEvent::MouseWheel { delta, .. } => self.on_mouse_wheel(delta),
1414            WindowEvent::Touch(touch) => self.on_touch(touch),
1415            WindowEvent::ModifiersChanged(mods) => {
1416                self.current_modifiers = translate_modifiers(mods.state());
1417            }
1418            WindowEvent::ThemeChanged(theme) => {
1419                if let Some(term) = self.terminal.as_mut() {
1420                    term.backend_mut().push_event(system_theme_event(theme));
1421                }
1422            }
1423            WindowEvent::Focused(gained) => self.on_focus_changed(gained),
1424            WindowEvent::KeyboardInput { event, .. } => {
1425                if let Some(term) = self.terminal.as_mut()
1426                    && let Some(e) = translate_key(event, self.current_modifiers)
1427                {
1428                    term.backend_mut().push_event(e);
1429                }
1430            }
1431            WindowEvent::Ime(ime) => {
1432                if let Some(term) = self.terminal.as_mut()
1433                    && let Some(e) = translate_ime(ime)
1434                {
1435                    term.backend_mut().push_event(e);
1436                }
1437            }
1438            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
1439                self.on_scale_factor_changed(scale_factor);
1440            }
1441
1442            WindowEvent::RedrawRequested => self.handle_redraw_requested(),
1443
1444            _ => {}
1445        }
1446    }
1447
1448    /// Runs the app closure, automatically presents the `Terminal` if the app didn't already (and
1449    /// didn't return [`Flow::Idle`](retroglyph_core::Flow::Idle)), and presents the frame to the
1450    /// surface, tracking consecutive `present()` failures to rate-limit logging and trigger
1451    /// surface recovery.
1452    ///
1453    /// See [`present_failure_action`] for the decision table; this method just runs the `Terminal`
1454    /// -/`Presenter`-dependent side effects (`app_loop`, `present`, `init_surface`, logging) that
1455    /// function can't perform itself since it's a pure function of the failure count alone.
1456    ///
1457    /// # Automatic `Terminal::present`
1458    ///
1459    /// Windowed apps no longer need to call [`Terminal::present`] themselves: this method calls it
1460    /// once, right after `app_loop` returns, unless [`skip_present`](Self::skip_present) was set
1461    /// (an [`App`](retroglyph_core::App) returned `Flow::Idle`) or
1462    /// [`Terminal::present_count`] shows `app_loop` already called it. A [`Terminal::present`]
1463    /// error is logged and does not stop the surface-level present below from running (matching
1464    /// this function's existing keep-going-on-failure philosophy); it uses a different error type
1465    /// (`<B as Output>::Error`) than [`Presenter::SurfaceError`], so it is tracked and logged
1466    /// independently of the consecutive-failure counter below, which is scoped to the surface
1467    /// present.
1468    fn handle_redraw_requested(&mut self) {
1469        let Some(term) = self.terminal.as_mut() else {
1470            return;
1471        };
1472        self.skip_present.set(false);
1473        let present_count_before = term.present_count();
1474        (self.app_loop)(term);
1475        if !self.skip_present.get()
1476            && term.present_count() == present_count_before
1477            && let Err(e) = term.present()
1478        {
1479            log::error!("automatic terminal present failed: {e}");
1480        }
1481        let result = term.backend_mut().presenter_mut().present();
1482        let succeeded = result.is_ok();
1483        let recoverable = result
1484            .as_ref()
1485            .err()
1486            .is_none_or(crate::presenter::RecoverableError::is_recoverable);
1487        match present_failure_action(self.consecutive_present_errors, succeeded, recoverable) {
1488            PresentFailureAction::Ok { was_failing } => {
1489                if was_failing {
1490                    log::info!(
1491                        "frame present recovered after {} consecutive failures",
1492                        self.consecutive_present_errors
1493                    );
1494                }
1495                self.consecutive_present_errors = 0;
1496            }
1497            PresentFailureAction::Log { at_error_level } => {
1498                self.consecutive_present_errors += 1;
1499                let e = result.unwrap_err();
1500                if at_error_level {
1501                    log::error!("frame present failed: {e}");
1502                } else {
1503                    // Ongoing failure streak below the recovery threshold: already logged at
1504                    // `error!` when the streak started, so avoid re-logging every single frame
1505                    // (the log-spam this issue exists to fix) while still keeping the detail
1506                    // available at `debug!` for anyone investigating a live failure.
1507                    log::debug!("frame present still failing: {e}");
1508                }
1509            }
1510            PresentFailureAction::Recover => {
1511                self.consecutive_present_errors += 1;
1512                let e = result.unwrap_err();
1513                log::warn!(
1514                    "frame present failed {} times consecutively ({e}); attempting surface recovery",
1515                    self.consecutive_present_errors
1516                );
1517                self.try_recover_surface();
1518            }
1519            PresentFailureAction::Fatal => {
1520                self.consecutive_present_errors += 1;
1521                let e = result.unwrap_err();
1522                log::error!("frame present failed with an unrecoverable error: {e}");
1523            }
1524        }
1525    }
1526
1527    /// Attempts to recover from a persistent `present()` failure by re-running
1528    /// [`Presenter::init_surface`], the same call
1529    /// [`create_window_and_surface`](Self::create_window_and_surface) makes at startup.
1530    ///
1531    /// This is the only recovery available generically: [`Presenter::SurfaceError`] carries no
1532    /// structured "is this recoverable" signal (see [`present_failure_action`]'s doc comment), so
1533    /// rebuilding the surface from scratch is the one action that's meaningful across every
1534    /// backend. A no-op if there is no window to rebuild the surface from (headless/pre-`resumed`
1535    /// states), or if the terminal has already been torn down.
1536    fn try_recover_surface(&mut self) {
1537        let Some(window) = self.window.clone() else {
1538            return;
1539        };
1540        let Some(term) = self.terminal.as_mut() else {
1541            return;
1542        };
1543        let handle: Arc<dyn crate::presenter::WindowHandle> = window;
1544        if let Err(e) = term.backend_mut().presenter_mut().init_surface(handle) {
1545            log::error!("surface recovery failed: {e}");
1546        }
1547    }
1548
1549    fn on_resized(&mut self, size: winit::dpi::PhysicalSize<u32>) {
1550        // On wasm with `fill_viewport` set, `size` is whatever (uncapped)
1551        // physical size we last handed winit for CSS layout purposes, not
1552        // the backing store size. Recompute the DPR-capped surface size
1553        // independently so the raster buffer doesn't silently lose its cap
1554        // on every resize. Without `fill_viewport`, the canvas never resizes
1555        // on its own (no listener installed above), so `size` here is
1556        // already the natural grid size and needs no such override.
1557        #[cfg(target_arch = "wasm32")]
1558        let size = if self.fill_viewport {
1559            web::web_viewport_surface_physical_size().unwrap_or(size)
1560        } else {
1561            size
1562        };
1563        self.resize_to(size);
1564    }
1565
1566    /// React to a scale-factor (DPI) change: notify the presenter, then
1567    /// realign the surface and grid to the window's new physical size.
1568    ///
1569    /// Every modern `HiDPI` display is scaled, so without this the surface
1570    /// silently keeps rendering at the old (pre-change) physical size --
1571    /// e.g. half the true resolution after moving to a 2x-scale display --
1572    /// until (if ever) an independent `Resized` event happens to arrive.
1573    /// Reusing [`resize_to`](Self::resize_to) here mirrors
1574    /// [`on_resized`](Self::on_resized), so both paths clamp/align the
1575    /// surface to whole cells the same way.
1576    fn on_scale_factor_changed(&mut self, scale_factor: f64) {
1577        if let Some(term) = self.terminal.as_mut() {
1578            term.backend_mut()
1579                .presenter_mut()
1580                .scale_factor_changed(scale_factor);
1581        }
1582        let Some(window) = self.window.clone() else {
1583            return;
1584        };
1585        self.resize_to(window.inner_size());
1586    }
1587
1588    /// Recompute the grid size (in cells) from a physical pixel size, resize
1589    /// the presenter's surface to the whole-cell-aligned pixel size, update
1590    /// the backend's own reported [`Output::size`], and push [`Event::Resize`] with the new
1591    /// cell dimensions.
1592    ///
1593    /// This keeps `backend.size()` in sync with the surface immediately, but it does not
1594    /// resize the [`Terminal`]'s own grid buffers: that stays the app's responsibility,
1595    /// done by calling [`Terminal::resize`] in response to the pushed [`Event::Resize`].
1596    ///
1597    /// Shared by [`on_resized`](Self::on_resized) and
1598    /// [`on_scale_factor_changed`](Self::on_scale_factor_changed): both need
1599    /// the same clamp-to-cell-grid math, just triggered by different winit
1600    /// events.
1601    fn resize_to(&mut self, size: winit::dpi::PhysicalSize<u32>) {
1602        let Some(term) = self.terminal.as_mut() else {
1603            return;
1604        };
1605        let (cell_w, cell_h) = term.backend().presenter().cell_size();
1606        // Clamp to at least one cell: a window smaller than one cell in
1607        // either dimension would otherwise divide down to 0 cols/rows,
1608        // which in turn asks `resize_surface` for a zero-size surface --
1609        // softbuffer (and likely other presenters) can't handle that and
1610        // panics. `Event::Resize` must report the same clamped grid the
1611        // surface was actually sized to, or callers reading `Event::Resize`
1612        // and querying the presenter's surface size would disagree.
1613        //
1614        // Integer division here also truncates any sub-cell remainder: when
1615        // `size` isn't an exact multiple of the cell size, `cols`/`rows`
1616        // round down and the surface below is sized to exactly
1617        // `cols * cell_w` x `rows * cell_h`, which can be smaller than
1618        // `size` itself. The OS window stays at the full physical `size`
1619        // the window manager gave it (retroglyph never resizes the OS
1620        // window to match), so a non-exact-multiple resize leaves a thin
1621        // strip at the window's trailing (right/bottom) edge outside the
1622        // surface entirely. That strip is not cleared or painted by
1623        // retroglyph; whatever the OS/windowing backend leaves there (old
1624        // frame content, backdrop color) shows through until the window is
1625        // resized again to a size the presenter does cover. See
1626        // `Presenter::resize_surface` for the documented contract.
1627        let cols = (size.width / cell_w).max(1);
1628        let rows = (size.height / cell_h).max(1);
1629        term.backend_mut()
1630            .presenter_mut()
1631            .resize_surface(cols * cell_w, rows * cell_h);
1632        #[allow(clippy::cast_possible_truncation)]
1633        let (cols, rows) = (cols as u16, rows as u16);
1634        // Update the backend's own reported size immediately so `backend.size()` agrees with
1635        // the surface without waiting for the app to react to `Event::Resize` below. This does
1636        // not touch the `Terminal`'s grid content (see `Terminal::resize`, which additionally
1637        // resizes/clears both grids): that remains the app's job in response to the event.
1638        term.backend_mut().resize(retroglyph_core::grid::Size {
1639            width: cols,
1640            height: rows,
1641        });
1642        term.backend_mut().push_event(Event::Resize(cols, rows));
1643    }
1644
1645    fn on_cursor_moved(&mut self, position: winit::dpi::PhysicalPosition<f64>) {
1646        // winit always reports pointer positions in real-DPR physical
1647        // pixels; rescale to the (possibly DPR-capped, on wasm) backing-store
1648        // pixel space that `cell_size`/`pixel_to_cell` use, so taps land on
1649        // the cell actually under the finger/cursor instead of drifting
1650        // south-east of it as the real DPR grows past the cap. `1.0` on
1651        // native (no such cap exists there) *and* on wasm when
1652        // `fill_viewport` is off: `create_window_and_surface` only computes
1653        // a DPR-capped `surface_physical_size` when `fill_viewport` is set
1654        // (see its branch above); without it, the backing store already
1655        // matches the real, uncapped DPR 1:1, so applying the cap
1656        // correction anyway scales every reported position *down* toward
1657        // the origin for no reason, biasing every tap/click up-and-left of
1658        // where it actually landed on any real_dpr > 1.5 device (most
1659        // phones, and Retina/HiDPI desktops).
1660        #[cfg(target_arch = "wasm32")]
1661        let scale = if self.fill_viewport {
1662            web::wasm_pointer_scale()
1663        } else {
1664            1.0
1665        };
1666        #[cfg(not(target_arch = "wasm32"))]
1667        let scale = 1.0;
1668        let (x, y) = (position.x * scale, position.y * scale);
1669        self.cursor_px = (x, y);
1670        let px = physical_pos_from(x, y);
1671        let Some(term) = self.terminal.as_mut() else {
1672            return;
1673        };
1674        let (cell_w, cell_h) = term.backend().presenter().cell_size();
1675        let pos = pixel_to_cell(x, y, cell_w, cell_h);
1676        // Report a drag (rather than a plain move) while any button is held. Left takes
1677        // priority over Right over Middle when more than one is held at once: an arbitrary but
1678        // deterministic choice, matching the order the buttons are declared in `MouseButton`.
1679        let kind = if self.held_buttons & BUTTON_MASK_LEFT != 0 {
1680            MouseEventKind::Drag(MouseButton::Left)
1681        } else if self.held_buttons & BUTTON_MASK_RIGHT != 0 {
1682            MouseEventKind::Drag(MouseButton::Right)
1683        } else if self.held_buttons & BUTTON_MASK_MIDDLE != 0 {
1684            MouseEventKind::Drag(MouseButton::Middle)
1685        } else {
1686            MouseEventKind::Moved
1687        };
1688        term.backend_mut().push_event(Event::Mouse(MouseEvent {
1689            kind,
1690            position: pos,
1691            pixel_position: Some(px),
1692            modifiers: self.current_modifiers,
1693        }));
1694    }
1695
1696    fn on_mouse_input(
1697        &mut self,
1698        state: winit::event::ElementState,
1699        button: winit::event::MouseButton,
1700    ) {
1701        let Some(btn) = translate_mouse_button(button) else {
1702            return;
1703        };
1704        let px = self.cursor_physical_pos();
1705        let Some(term) = self.terminal.as_mut() else {
1706            return;
1707        };
1708        let (cell_w, cell_h) = term.backend().presenter().cell_size();
1709        let pos = pixel_to_cell(self.cursor_px.0, self.cursor_px.1, cell_w, cell_h);
1710        let kind = if state.is_pressed() {
1711            self.held_buttons |= button_mask(btn);
1712            MouseEventKind::Down(btn)
1713        } else {
1714            self.held_buttons &= !button_mask(btn);
1715            MouseEventKind::Up(btn)
1716        };
1717        term.backend_mut().push_event(Event::Mouse(MouseEvent {
1718            kind,
1719            position: pos,
1720            pixel_position: Some(px),
1721            modifiers: self.current_modifiers,
1722        }));
1723    }
1724
1725    fn on_mouse_wheel(&mut self, delta: winit::event::MouseScrollDelta) {
1726        let px = self.cursor_physical_pos();
1727        let Some(term) = self.terminal.as_mut() else {
1728            return;
1729        };
1730        let (cell_w, cell_h) = term.backend().presenter().cell_size();
1731        let pos = pixel_to_cell(self.cursor_px.0, self.cursor_px.1, cell_w, cell_h);
1732        let (scroll_x, scroll_y) = match delta {
1733            winit::event::MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
1734            winit::event::MouseScrollDelta::PixelDelta(p) => (p.x, p.y),
1735        };
1736        // A delta of exactly zero on both axes emits nothing (retroglyph#293's original
1737        // reasoning for not synthesizing a spurious event still applies).
1738        if scroll_x == 0.0 && scroll_y == 0.0 {
1739            return;
1740        }
1741        #[allow(clippy::cast_possible_truncation)]
1742        let kind = MouseEventKind::Scroll {
1743            dx: scroll_x as f32,
1744            dy: scroll_y as f32,
1745        };
1746        term.backend_mut().push_event(Event::Mouse(MouseEvent {
1747            kind,
1748            position: pos,
1749            pixel_position: Some(px),
1750            modifiers: self.current_modifiers,
1751        }));
1752    }
1753
1754    /// Synthesize mouse events from a touch so tap/drag work out of the box.
1755    ///
1756    /// Mobile browsers (and native touchscreens) deliver touch input as
1757    /// [`WindowEvent::Touch`], which has no `CursorMoved`/`MouseInput`
1758    /// counterpart. Games shouldn't need a second input path for it, so the
1759    /// first finger down becomes the pointer: its start is a `Moved` +
1760    /// left-button `Down`, its motion is `Moved` (a drag), and its lift is
1761    /// `Up`. Additional simultaneous fingers are ignored.
1762    fn on_touch(&mut self, touch: winit::event::Touch) {
1763        use winit::event::TouchPhase;
1764
1765        match touch.phase {
1766            TouchPhase::Started => {
1767                if self.active_touch.is_some() {
1768                    return; // a second finger; keep tracking the first
1769                }
1770                self.active_touch = Some(touch.id);
1771                self.on_cursor_moved(touch.location);
1772                self.on_mouse_input(
1773                    winit::event::ElementState::Pressed,
1774                    winit::event::MouseButton::Left,
1775                );
1776            }
1777            TouchPhase::Moved => {
1778                if self.active_touch == Some(touch.id) {
1779                    self.on_cursor_moved(touch.location);
1780                }
1781            }
1782            TouchPhase::Ended | TouchPhase::Cancelled => {
1783                if self.active_touch != Some(touch.id) {
1784                    return;
1785                }
1786                self.active_touch = None;
1787                self.on_cursor_moved(touch.location);
1788                self.on_mouse_input(
1789                    winit::event::ElementState::Released,
1790                    winit::event::MouseButton::Left,
1791                );
1792            }
1793        }
1794    }
1795
1796    /// Convert the cached cursor pixel position to [`PhysicalPos`].
1797    const fn cursor_physical_pos(&self) -> PhysicalPos {
1798        physical_pos_from(self.cursor_px.0, self.cursor_px.1)
1799    }
1800
1801    /// Push [`Event::FocusGained`]/[`Event::FocusLost`], and on loss, reset state that only makes
1802    /// sense while the window is focused.
1803    ///
1804    /// Winit keeps delivering `ModifiersChanged` only while focused, so a modifier key held down
1805    /// when focus is lost (e.g. alt-tabbing away while holding Shift) never generates the release
1806    /// that would normally clear it: without this, `current_modifiers` stays stuck "held" for
1807    /// every event after focus returns. Similarly, a finger lifted while the window is
1808    /// unfocused/backgrounded never delivers `TouchPhase::Ended`/`Cancelled`, so `active_touch`
1809    /// would otherwise stay set forever, permanently ignoring the next finger down. The stuck
1810    /// touch is released the same way a real lift is (see [`on_touch`](Self::on_touch)'s
1811    /// `Ended`/`Cancelled` arm): a left-button `Up` at the last known cursor position, so the app
1812    /// sees a normal, balanced Down/Up pair instead of a Down with no matching Up. No `Moved` is
1813    /// synthesized first, unlike a real lift: blur carries no new pointer location, and
1814    /// `cursor_px` already holds the touch's last reported position from the `Started`/`Moved`
1815    /// arms that got it there.
1816    ///
1817    /// The same problem applies to `held_buttons`: a mouse button released while the window is
1818    /// unfocused never delivers `MouseInput`, so without this it would stay marked "held" and
1819    /// every move after refocus would keep reporting a stale `Drag` instead of `Moved`. It's
1820    /// force-cleared directly (not via a synthesized `Up`, since there's no single button, or
1821    /// combination of buttons, that unambiguously round-trips through `on_mouse_input`).
1822    fn on_focus_changed(&mut self, gained: bool) {
1823        if let Some(term) = self.terminal.as_mut() {
1824            let event = if gained {
1825                Event::FocusGained
1826            } else {
1827                Event::FocusLost
1828            };
1829            term.backend_mut().push_event(event);
1830        }
1831        if !gained {
1832            self.current_modifiers = KeyModifiers::NONE;
1833            if self.active_touch.take().is_some() {
1834                self.on_mouse_input(
1835                    winit::event::ElementState::Released,
1836                    winit::event::MouseButton::Left,
1837                );
1838            }
1839            self.held_buttons = 0;
1840        }
1841    }
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846    use super::*;
1847    use retroglyph_core::DrawCell;
1848    use retroglyph_core::backend::Output;
1849    use retroglyph_core::event::{MouseButton, MouseEvent, MouseEventKind};
1850    use retroglyph_core::grid::{Pos, Size};
1851    use std::cell::RefCell;
1852    use std::time::Duration;
1853
1854    // ── physical_size_for ─────────────────────────────────────────────────────
1855
1856    #[test]
1857    fn physical_size_for_unscaled_monitor_is_unchanged() {
1858        assert_eq!(physical_size_for(80, 80, 1.0), (80, 80));
1859    }
1860
1861    #[test]
1862    fn physical_size_for_hidpi_monitor_scales_up() {
1863        // 2x display: a 80x80 logical window needs 160x160 true physical
1864        // pixels to render crisply instead of being upscaled by the OS.
1865        assert_eq!(physical_size_for(80, 80, 2.0), (160, 160));
1866    }
1867
1868    #[test]
1869    fn physical_size_for_fractional_scale_rounds() {
1870        // 1.5x display: 81x81 rounds to the nearest physical pixel rather
1871        // than truncating.
1872        assert_eq!(physical_size_for(81, 81, 1.5), (122, 122));
1873    }
1874
1875    // ── WindowConfig builder chain ───────────────────────────────────────────
1876
1877    #[test]
1878    fn fit_defaults_match_winit_defaults() {
1879        // `fit` should start from the same defaults winit itself uses for a plain
1880        // `Window::default_attributes()`, so a caller that never touches the new builder
1881        // methods gets identical behavior to before this API existed.
1882        let presenter = MockPresenter::default();
1883        let config = WindowConfig::fit(&presenter, "test", None, true);
1884        assert!(config.resizable);
1885        assert!(config.decorations);
1886        assert_eq!(config.min_size, None);
1887        assert_eq!(config.max_size, None);
1888        assert_eq!(config.initial_position, None);
1889        assert!(!config.fullscreen);
1890        assert!(!config.transparency);
1891        assert!(!config.fill_viewport);
1892    }
1893
1894    #[test]
1895    fn builder_chain_sets_each_attribute() {
1896        let presenter = MockPresenter::default();
1897        let config = WindowConfig::fit(&presenter, "test", None, true)
1898            .resizable(false)
1899            .decorations(false)
1900            .min_size(320, 240)
1901            .max_size(1920, 1080)
1902            .initial_position(10, 20)
1903            .fullscreen(true)
1904            .transparency(true);
1905        assert!(!config.resizable);
1906        assert!(!config.decorations);
1907        assert_eq!(config.min_size, Some((320, 240)));
1908        assert_eq!(config.max_size, Some((1920, 1080)));
1909        assert_eq!(config.initial_position, Some((10, 20)));
1910        assert!(config.fullscreen);
1911        assert!(config.transparency);
1912    }
1913
1914    #[test]
1915    fn window_attrs_from_config_copies_all_fields() {
1916        let presenter = MockPresenter::default();
1917        let config = WindowConfig::fit(&presenter, "test", None, true)
1918            .resizable(false)
1919            .decorations(false)
1920            .min_size(1, 2)
1921            .max_size(3, 4)
1922            .initial_position(5, 6)
1923            .fullscreen(true)
1924            .transparency(true);
1925        let attrs = WindowAttrs::from(&config);
1926        assert!(!attrs.resizable);
1927        assert!(!attrs.decorations);
1928        assert_eq!(attrs.min_size, Some((1, 2)));
1929        assert_eq!(attrs.max_size, Some((3, 4)));
1930        assert_eq!(attrs.initial_position, Some((5, 6)));
1931        assert!(attrs.fullscreen);
1932        assert!(attrs.transparency);
1933    }
1934
1935    // ── present_failure_action ───────────────────────────────────────────────
1936
1937    #[test]
1938    fn present_success_with_no_prior_failures_is_plain_ok() {
1939        assert_eq!(
1940            present_failure_action(0, true, true),
1941            PresentFailureAction::Ok { was_failing: false }
1942        );
1943    }
1944
1945    #[test]
1946    fn present_success_after_a_failure_streak_reports_recovery() {
1947        assert_eq!(
1948            present_failure_action(5, true, true),
1949            PresentFailureAction::Ok { was_failing: true }
1950        );
1951    }
1952
1953    #[test]
1954    fn first_failure_in_a_streak_logs_at_error_level() {
1955        assert_eq!(
1956            present_failure_action(0, false, true),
1957            PresentFailureAction::Log {
1958                at_error_level: true
1959            }
1960        );
1961    }
1962
1963    #[test]
1964    fn subsequent_failures_below_threshold_log_below_error_level() {
1965        for count in 1..PRESENT_FAILURE_RECOVERY_THRESHOLD - 1 {
1966            assert_eq!(
1967                present_failure_action(count, false, true),
1968                PresentFailureAction::Log {
1969                    at_error_level: false
1970                },
1971                "consecutive_failures = {count}"
1972            );
1973        }
1974    }
1975
1976    #[test]
1977    fn failure_crossing_the_threshold_triggers_recovery() {
1978        // consecutive_failures is the count *before* this call, so
1979        // `PRESENT_FAILURE_RECOVERY_THRESHOLD - 1` failures already happened; this call is the
1980        // one that reaches the threshold.
1981        assert_eq!(
1982            present_failure_action(PRESENT_FAILURE_RECOVERY_THRESHOLD - 1, false, true),
1983            PresentFailureAction::Recover
1984        );
1985    }
1986
1987    #[test]
1988    fn failure_recovers_again_every_full_threshold_after_the_first() {
1989        // A failed recovery attempt must not be retried on literally the next frame: the next
1990        // `Recover` only fires after another full threshold's worth of failures.
1991        assert_eq!(
1992            present_failure_action(2 * PRESENT_FAILURE_RECOVERY_THRESHOLD - 1, false, true),
1993            PresentFailureAction::Recover
1994        );
1995        for count in
1996            PRESENT_FAILURE_RECOVERY_THRESHOLD..(2 * PRESENT_FAILURE_RECOVERY_THRESHOLD - 1)
1997        {
1998            assert_eq!(
1999                present_failure_action(count, false, true),
2000                PresentFailureAction::Log {
2001                    at_error_level: false
2002                },
2003                "consecutive_failures = {count}"
2004            );
2005        }
2006    }
2007
2008    #[test]
2009    fn unrecoverable_failure_is_fatal_immediately_regardless_of_streak_length() {
2010        // A presenter reporting `is_recoverable() == false` should skip straight to `Fatal` on
2011        // the very first failure, not wait for the consecutive-failure threshold the way the
2012        // generic (`recoverable == true`) path does.
2013        assert_eq!(
2014            present_failure_action(0, false, false),
2015            PresentFailureAction::Fatal
2016        );
2017    }
2018
2019    #[test]
2020    fn unrecoverable_failure_stays_fatal_mid_streak() {
2021        // Whatever the running consecutive-failure count, an unrecoverable error always takes
2022        // the fatal path rather than the count-dependent `Log`/`Recover` decision.
2023        assert_eq!(
2024            present_failure_action(5, false, false),
2025            PresentFailureAction::Fatal
2026        );
2027        assert_eq!(
2028            present_failure_action(PRESENT_FAILURE_RECOVERY_THRESHOLD - 1, false, false),
2029            PresentFailureAction::Fatal
2030        );
2031    }
2032
2033    #[test]
2034    fn recoverable_flag_is_ignored_on_success() {
2035        // `recoverable` only matters for a failed present; passing `false` alongside
2036        // `succeeded == true` must not change the outcome.
2037        assert_eq!(
2038            present_failure_action(3, true, false),
2039            PresentFailureAction::Ok { was_failing: true }
2040        );
2041    }
2042
2043    /// A dependency-free [`Presenter`] with fixed 8x16 cells.
2044    ///
2045    /// The `WindowApp` tests only exercise event translation, cell math, and the `WindowBackend`
2046    /// queue: no rasterization or surface is needed.
2047    struct MockPresenter {
2048        /// Records the last [`Presenter::scale_factor_changed`] argument, if any.
2049        last_scale_factor: Cell<Option<f64>>,
2050        /// The size last reported by [`Output::size`], updated by [`Output::resize`] so tests
2051        /// can assert that `resize_to` keeps it in sync with the surface immediately, rather
2052        /// than only via a separate `Terminal::resize` call in response to `Event::Resize`.
2053        size: Cell<Size>,
2054    }
2055
2056    impl Default for MockPresenter {
2057        fn default() -> Self {
2058            Self {
2059                last_scale_factor: Cell::new(None),
2060                size: Cell::new(Size {
2061                    width: 10,
2062                    height: 5,
2063                }),
2064            }
2065        }
2066    }
2067
2068    impl Output for MockPresenter {
2069        type Error = core::convert::Infallible;
2070
2071        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
2072        where
2073            I: Iterator<Item = DrawCell<'a>>,
2074        {
2075            Ok(())
2076        }
2077
2078        fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
2079        where
2080            I: Iterator<Item = DrawCell<'a>>,
2081        {
2082            Ok(())
2083        }
2084
2085        fn flush(&mut self) -> Result<(), Self::Error> {
2086            Ok(())
2087        }
2088
2089        fn size(&self) -> Size {
2090            self.size.get()
2091        }
2092
2093        fn clear(&mut self) -> Result<(), Self::Error> {
2094            Ok(())
2095        }
2096
2097        fn resize(&mut self, size: Size) {
2098            self.size.set(size);
2099        }
2100    }
2101
2102    impl Presenter for MockPresenter {
2103        type SurfaceError = core::convert::Infallible;
2104
2105        fn init_surface(
2106            &mut self,
2107            _window: Arc<dyn crate::presenter::WindowHandle>,
2108        ) -> Result<(), Self::SurfaceError> {
2109            Ok(())
2110        }
2111
2112        fn resize_surface(&mut self, _width: u32, _height: u32) {}
2113
2114        fn present(&mut self) -> Result<(), Self::SurfaceError> {
2115            Ok(())
2116        }
2117
2118        fn cell_size(&self) -> (u32, u32) {
2119            (8, 16)
2120        }
2121
2122        fn scale_factor_changed(&mut self, scale_factor: f64) {
2123            self.last_scale_factor.set(Some(scale_factor));
2124        }
2125    }
2126
2127    /// A [`Presenter`] that records every `resize_surface` call, so tests
2128    /// can assert on the pixel dimensions `on_resized` actually requests.
2129    #[derive(Default)]
2130    struct RecordingPresenter {
2131        resize_calls: Rc<RefCell<Vec<(u32, u32)>>>,
2132    }
2133
2134    impl Output for RecordingPresenter {
2135        type Error = core::convert::Infallible;
2136
2137        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
2138        where
2139            I: Iterator<Item = DrawCell<'a>>,
2140        {
2141            Ok(())
2142        }
2143
2144        fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
2145        where
2146            I: Iterator<Item = DrawCell<'a>>,
2147        {
2148            Ok(())
2149        }
2150
2151        fn flush(&mut self) -> Result<(), Self::Error> {
2152            Ok(())
2153        }
2154
2155        fn size(&self) -> Size {
2156            Size {
2157                width: 10,
2158                height: 5,
2159            }
2160        }
2161
2162        fn clear(&mut self) -> Result<(), Self::Error> {
2163            Ok(())
2164        }
2165
2166        fn resize(&mut self, _size: Size) {}
2167    }
2168
2169    impl Presenter for RecordingPresenter {
2170        type SurfaceError = core::convert::Infallible;
2171
2172        fn init_surface(
2173            &mut self,
2174            _window: Arc<dyn crate::presenter::WindowHandle>,
2175        ) -> Result<(), Self::SurfaceError> {
2176            Ok(())
2177        }
2178
2179        fn resize_surface(&mut self, width: u32, height: u32) {
2180            self.resize_calls.borrow_mut().push((width, height));
2181        }
2182
2183        fn present(&mut self) -> Result<(), Self::SurfaceError> {
2184            Ok(())
2185        }
2186
2187        fn cell_size(&self) -> (u32, u32) {
2188            (8, 16)
2189        }
2190    }
2191
2192    /// A [`Presenter`] whose `present()` fails on demand, and which counts `init_surface` calls
2193    /// so tests can assert whether [`WindowApp::try_recover_surface`] actually ran.
2194    #[derive(Default)]
2195    struct FailingPresenter {
2196        /// `present()` returns `Err` while this is `true`.
2197        failing: Rc<Cell<bool>>,
2198        /// Number of `init_surface` calls observed (1 at construction time in real use; extra
2199        /// calls here are surface-recovery attempts).
2200        init_surface_calls: Rc<Cell<u32>>,
2201    }
2202
2203    impl Output for FailingPresenter {
2204        type Error = core::convert::Infallible;
2205
2206        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
2207        where
2208            I: Iterator<Item = DrawCell<'a>>,
2209        {
2210            Ok(())
2211        }
2212
2213        fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
2214        where
2215            I: Iterator<Item = DrawCell<'a>>,
2216        {
2217            Ok(())
2218        }
2219
2220        fn flush(&mut self) -> Result<(), Self::Error> {
2221            Ok(())
2222        }
2223
2224        fn size(&self) -> Size {
2225            Size {
2226                width: 10,
2227                height: 5,
2228            }
2229        }
2230
2231        fn clear(&mut self) -> Result<(), Self::Error> {
2232            Ok(())
2233        }
2234
2235        fn resize(&mut self, _size: Size) {}
2236    }
2237
2238    impl Presenter for FailingPresenter {
2239        type SurfaceError = &'static str;
2240
2241        fn init_surface(
2242            &mut self,
2243            _window: Arc<dyn crate::presenter::WindowHandle>,
2244        ) -> Result<(), Self::SurfaceError> {
2245            self.init_surface_calls
2246                .set(self.init_surface_calls.get() + 1);
2247            Ok(())
2248        }
2249
2250        fn resize_surface(&mut self, _width: u32, _height: u32) {}
2251
2252        fn present(&mut self) -> Result<(), Self::SurfaceError> {
2253            if self.failing.get() {
2254                Err("simulated present failure")
2255            } else {
2256                Ok(())
2257            }
2258        }
2259
2260        fn cell_size(&self) -> (u32, u32) {
2261            (8, 16)
2262        }
2263    }
2264
2265    // `&'static str` inherits the default `is_recoverable() -> true`: `FailingPresenter`'s tests
2266    // exercise the existing (pre-`RecoverableError`) `Log`/`Recover` behavior, which must stay
2267    // unchanged now that `Presenter::SurfaceError` is bounded by `RecoverableError` instead of
2268    // plain `Debug + Display`.
2269    impl crate::presenter::RecoverableError for &'static str {}
2270
2271    /// A `present()` error that always reports itself as unrecoverable (overrides
2272    /// [`RecoverableError::is_recoverable`](crate::presenter::RecoverableError::is_recoverable) to
2273    /// return `false`), so tests can exercise [`PresentFailureAction::Fatal`] end to end through
2274    /// [`WindowApp::handle_redraw_requested`].
2275    #[derive(Debug)]
2276    struct UnrecoverableError(&'static str);
2277
2278    impl core::fmt::Display for UnrecoverableError {
2279        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2280            write!(f, "{}", self.0)
2281        }
2282    }
2283
2284    impl crate::presenter::RecoverableError for UnrecoverableError {
2285        fn is_recoverable(&self) -> bool {
2286            false
2287        }
2288    }
2289
2290    /// A [`Presenter`] whose `present()` always fails with an [`UnrecoverableError`] on demand,
2291    /// otherwise identical to [`FailingPresenter`].
2292    #[derive(Default)]
2293    struct FatalPresenter {
2294        /// `present()` returns `Err` while this is `true`.
2295        failing: Rc<Cell<bool>>,
2296        /// Number of `init_surface` calls observed.
2297        init_surface_calls: Rc<Cell<u32>>,
2298    }
2299
2300    impl Output for FatalPresenter {
2301        type Error = core::convert::Infallible;
2302
2303        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
2304        where
2305            I: Iterator<Item = DrawCell<'a>>,
2306        {
2307            Ok(())
2308        }
2309
2310        fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
2311        where
2312            I: Iterator<Item = DrawCell<'a>>,
2313        {
2314            Ok(())
2315        }
2316
2317        fn flush(&mut self) -> Result<(), Self::Error> {
2318            Ok(())
2319        }
2320
2321        fn size(&self) -> Size {
2322            Size {
2323                width: 10,
2324                height: 5,
2325            }
2326        }
2327
2328        fn clear(&mut self) -> Result<(), Self::Error> {
2329            Ok(())
2330        }
2331
2332        fn resize(&mut self, _size: Size) {}
2333    }
2334
2335    impl Presenter for FatalPresenter {
2336        type SurfaceError = UnrecoverableError;
2337
2338        fn init_surface(
2339            &mut self,
2340            _window: Arc<dyn crate::presenter::WindowHandle>,
2341        ) -> Result<(), Self::SurfaceError> {
2342            self.init_surface_calls
2343                .set(self.init_surface_calls.get() + 1);
2344            Ok(())
2345        }
2346
2347        fn resize_surface(&mut self, _width: u32, _height: u32) {}
2348
2349        fn present(&mut self) -> Result<(), Self::SurfaceError> {
2350            if self.failing.get() {
2351                Err(UnrecoverableError(
2352                    "simulated unrecoverable present failure",
2353                ))
2354            } else {
2355                Ok(())
2356            }
2357        }
2358
2359        fn cell_size(&self) -> (u32, u32) {
2360            (8, 16)
2361        }
2362    }
2363
2364    type MockApp = WindowApp<
2365        MockPresenter,
2366        fn(&mut Terminal<WindowBackend<MockPresenter>>),
2367        u64,
2368        fn(u64, &mut Terminal<WindowBackend<MockPresenter>>),
2369    >;
2370
2371    fn test_window_app() -> MockApp {
2372        let terminal = Terminal::new(WindowBackend::new(MockPresenter::default()));
2373        WindowApp {
2374            terminal: Some(terminal),
2375            app_loop: |_| {},
2376            on_custom_event: push_custom_event,
2377            _user_event: PhantomData,
2378            window: None,
2379            title: String::new(),
2380            init_size: InitWindowSize {
2381                width: 80,
2382                height: 80,
2383            },
2384            attrs: WindowAttrs::default(),
2385            current_modifiers: KeyModifiers::NONE,
2386            cursor_px: (0.0, 0.0),
2387            active_touch: None,
2388            held_buttons: 0,
2389            frame_interval: None,
2390            event_driven: true,
2391            #[cfg(not(target_arch = "wasm32"))]
2392            next_frame: std::time::Instant::now(),
2393            exit_requested: Rc::new(Cell::new(false)),
2394            skip_present: Rc::new(Cell::new(false)),
2395            needs_redraw: false,
2396            consecutive_present_errors: 0,
2397        }
2398    }
2399
2400    fn poll(app: &mut MockApp) -> Option<Event> {
2401        app.terminal
2402            .as_mut()
2403            .unwrap()
2404            .backend_mut()
2405            .poll_event(Duration::ZERO)
2406    }
2407
2408    // ── WindowBackend queue ───────────────────────────────────────────────────
2409
2410    #[test]
2411    fn mouse_event_round_trips_through_event_buffer() {
2412        let mut backend = WindowBackend::new(MockPresenter::default());
2413        let ev = Event::Mouse(MouseEvent {
2414            kind: MouseEventKind::Down(MouseButton::Left),
2415            position: Pos { x: 3, y: 1 },
2416            pixel_position: None,
2417            modifiers: KeyModifiers::NONE,
2418        });
2419        backend.push_event(ev.clone());
2420        assert_eq!(backend.poll_event(Duration::ZERO), Some(ev));
2421        assert_eq!(backend.poll_event(Duration::ZERO), None);
2422    }
2423
2424    #[test]
2425    fn multiple_mouse_events_preserve_fifo_order() {
2426        let mut backend = WindowBackend::new(MockPresenter::default());
2427        let moved = Event::Mouse(MouseEvent {
2428            kind: MouseEventKind::Moved,
2429            position: Pos { x: 1, y: 2 },
2430            pixel_position: None,
2431            modifiers: KeyModifiers::NONE,
2432        });
2433        let clicked = Event::Mouse(MouseEvent {
2434            kind: MouseEventKind::Down(MouseButton::Left),
2435            position: Pos { x: 1, y: 2 },
2436            pixel_position: None,
2437            modifiers: KeyModifiers::NONE,
2438        });
2439        backend.push_event(moved.clone());
2440        backend.push_event(clicked.clone());
2441        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved));
2442        assert_eq!(backend.poll_event(Duration::ZERO), Some(clicked));
2443    }
2444
2445    // ── handle_window_event ──────────────────────────────────────────────────
2446
2447    #[test]
2448    fn cursor_moved_pushes_moved_event_at_correct_cell() {
2449        // 8-wide × 16-tall cells; cursor at pixel (20, 32) → col 2, row 2.
2450        let mut app = test_window_app();
2451        app.handle_window_event(WindowEvent::CursorMoved {
2452            device_id: winit::event::DeviceId::dummy(),
2453            position: winit::dpi::PhysicalPosition::new(20.0_f64, 32.0_f64),
2454        });
2455        assert_eq!(
2456            poll(&mut app),
2457            Some(Event::Mouse(MouseEvent {
2458                kind: MouseEventKind::Moved,
2459                position: Pos { x: 2, y: 2 },
2460                pixel_position: Some(PhysicalPos { x: 20, y: 32 }),
2461                modifiers: KeyModifiers::NONE,
2462            }))
2463        );
2464    }
2465
2466    #[test]
2467    fn cursor_moved_caches_position_for_subsequent_click() {
2468        // Move to pixel (16, 16) = col 2, row 1, then click — button event
2469        // must reuse the cached position.
2470        let mut app = test_window_app();
2471        app.handle_window_event(WindowEvent::CursorMoved {
2472            device_id: winit::event::DeviceId::dummy(),
2473            position: winit::dpi::PhysicalPosition::new(16.0_f64, 16.0_f64),
2474        });
2475        let _ = poll(&mut app); // discard the Moved event
2476        app.handle_window_event(WindowEvent::MouseInput {
2477            device_id: winit::event::DeviceId::dummy(),
2478            state: winit::event::ElementState::Pressed,
2479            button: winit::event::MouseButton::Left,
2480        });
2481        assert_eq!(
2482            poll(&mut app),
2483            Some(Event::Mouse(MouseEvent {
2484                kind: MouseEventKind::Down(MouseButton::Left),
2485                position: Pos { x: 2, y: 1 },
2486                pixel_position: Some(PhysicalPos { x: 16, y: 16 }),
2487                modifiers: KeyModifiers::NONE,
2488            }))
2489        );
2490    }
2491
2492    #[test]
2493    fn mouse_button_release_produces_up_event() {
2494        let mut app = test_window_app();
2495        app.handle_window_event(WindowEvent::MouseInput {
2496            device_id: winit::event::DeviceId::dummy(),
2497            state: winit::event::ElementState::Released,
2498            button: winit::event::MouseButton::Right,
2499        });
2500        assert_eq!(
2501            poll(&mut app),
2502            Some(Event::Mouse(MouseEvent {
2503                kind: MouseEventKind::Up(MouseButton::Right),
2504                position: Pos { x: 0, y: 0 },
2505                pixel_position: Some(PhysicalPos { x: 0, y: 0 }),
2506                modifiers: KeyModifiers::NONE,
2507            }))
2508        );
2509    }
2510
2511    #[test]
2512    fn unknown_mouse_button_produces_no_event() {
2513        let mut app = test_window_app();
2514        app.handle_window_event(WindowEvent::MouseInput {
2515            device_id: winit::event::DeviceId::dummy(),
2516            state: winit::event::ElementState::Pressed,
2517            button: winit::event::MouseButton::Other(99),
2518        });
2519        assert_eq!(poll(&mut app), None);
2520    }
2521
2522    fn touch(id: u64, phase: winit::event::TouchPhase, x: f64, y: f64) -> WindowEvent {
2523        WindowEvent::Touch(winit::event::Touch {
2524            device_id: winit::event::DeviceId::dummy(),
2525            phase,
2526            location: winit::dpi::PhysicalPosition::new(x, y),
2527            force: None,
2528            id,
2529        })
2530    }
2531
2532    #[test]
2533    fn touch_tap_synthesizes_left_click() {
2534        use winit::event::TouchPhase;
2535        let mut app = test_window_app();
2536        // MockPresenter cells are 8x16 px; a tap at (20, 18) lands on cell (2, 1).
2537        app.handle_window_event(touch(7, TouchPhase::Started, 20.0, 18.0));
2538        // Moved (from the synthesized cursor move) then Down.
2539        assert!(matches!(
2540            poll(&mut app),
2541            Some(Event::Mouse(MouseEvent {
2542                kind: MouseEventKind::Moved,
2543                position: Pos { x: 2, y: 1 },
2544                ..
2545            }))
2546        ));
2547        assert!(matches!(
2548            poll(&mut app),
2549            Some(Event::Mouse(MouseEvent {
2550                kind: MouseEventKind::Down(MouseButton::Left),
2551                position: Pos { x: 2, y: 1 },
2552                ..
2553            }))
2554        ));
2555
2556        app.handle_window_event(touch(7, TouchPhase::Ended, 20.0, 18.0));
2557        // The synthesized move fires while the touch's `Left` button is still held (the release
2558        // hasn't been synthesized yet), so it's reported as a drag, not a plain move.
2559        assert!(matches!(
2560            poll(&mut app),
2561            Some(Event::Mouse(MouseEvent {
2562                kind: MouseEventKind::Drag(MouseButton::Left),
2563                ..
2564            }))
2565        ));
2566        assert!(matches!(
2567            poll(&mut app),
2568            Some(Event::Mouse(MouseEvent {
2569                kind: MouseEventKind::Up(MouseButton::Left),
2570                position: Pos { x: 2, y: 1 },
2571                ..
2572            }))
2573        ));
2574        assert_eq!(poll(&mut app), None);
2575    }
2576
2577    #[test]
2578    fn touch_drag_synthesizes_moves_between_down_and_up() {
2579        use winit::event::TouchPhase;
2580        let mut app = test_window_app();
2581        app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
2582        poll(&mut app); // Moved
2583        poll(&mut app); // Down
2584
2585        app.handle_window_event(touch(1, TouchPhase::Moved, 40.0, 32.0));
2586        // Held Left button since Started: this is a drag, not a plain move.
2587        assert!(matches!(
2588            poll(&mut app),
2589            Some(Event::Mouse(MouseEvent {
2590                kind: MouseEventKind::Drag(MouseButton::Left),
2591                position: Pos { x: 5, y: 2 },
2592                ..
2593            }))
2594        ));
2595
2596        app.handle_window_event(touch(1, TouchPhase::Cancelled, 40.0, 32.0));
2597        poll(&mut app); // Drag (button still held until the synthesized Up just below)
2598        assert!(matches!(
2599            poll(&mut app),
2600            Some(Event::Mouse(MouseEvent {
2601                kind: MouseEventKind::Up(MouseButton::Left),
2602                ..
2603            }))
2604        ));
2605    }
2606
2607    #[test]
2608    fn second_finger_is_ignored_while_first_is_down() {
2609        use winit::event::TouchPhase;
2610        let mut app = test_window_app();
2611        app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
2612        poll(&mut app); // Moved
2613        poll(&mut app); // Down
2614
2615        // A second finger goes down, moves, and lifts: all ignored.
2616        app.handle_window_event(touch(2, TouchPhase::Started, 80.0, 80.0));
2617        app.handle_window_event(touch(2, TouchPhase::Moved, 88.0, 80.0));
2618        app.handle_window_event(touch(2, TouchPhase::Ended, 88.0, 80.0));
2619        assert_eq!(poll(&mut app), None);
2620
2621        // The first finger still completes its gesture.
2622        app.handle_window_event(touch(1, TouchPhase::Ended, 8.0, 0.0));
2623        poll(&mut app); // Moved
2624        assert!(matches!(
2625            poll(&mut app),
2626            Some(Event::Mouse(MouseEvent {
2627                kind: MouseEventKind::Up(MouseButton::Left),
2628                position: Pos { x: 1, y: 0 },
2629                ..
2630            }))
2631        ));
2632    }
2633
2634    #[test]
2635    fn scroll_up_line_delta() {
2636        let mut app = test_window_app();
2637        app.handle_window_event(WindowEvent::MouseWheel {
2638            device_id: winit::event::DeviceId::dummy(),
2639            delta: winit::event::MouseScrollDelta::LineDelta(0.0, 1.0),
2640            phase: winit::event::TouchPhase::Moved,
2641        });
2642        let ev = poll(&mut app).unwrap();
2643        assert!(matches!(
2644            ev,
2645            Event::Mouse(MouseEvent {
2646                kind: MouseEventKind::Scroll { dx: 0.0, dy },
2647                ..
2648            }) if dy > 0.0
2649        ));
2650    }
2651
2652    #[test]
2653    fn scroll_down_line_delta() {
2654        let mut app = test_window_app();
2655        app.handle_window_event(WindowEvent::MouseWheel {
2656            device_id: winit::event::DeviceId::dummy(),
2657            delta: winit::event::MouseScrollDelta::LineDelta(0.0, -1.0),
2658            phase: winit::event::TouchPhase::Moved,
2659        });
2660        let ev = poll(&mut app).unwrap();
2661        assert!(matches!(
2662            ev,
2663            Event::Mouse(MouseEvent {
2664                kind: MouseEventKind::Scroll { dx: 0.0, dy },
2665                ..
2666            }) if dy < 0.0
2667        ));
2668    }
2669
2670    #[test]
2671    fn scroll_up_pixel_delta() {
2672        let mut app = test_window_app();
2673        app.handle_window_event(WindowEvent::MouseWheel {
2674            device_id: winit::event::DeviceId::dummy(),
2675            delta: winit::event::MouseScrollDelta::PixelDelta(winit::dpi::PhysicalPosition::new(
2676                0.0_f64, 15.0_f64,
2677            )),
2678            phase: winit::event::TouchPhase::Moved,
2679        });
2680        let ev = poll(&mut app).unwrap();
2681        assert!(matches!(
2682            ev,
2683            Event::Mouse(MouseEvent {
2684                kind: MouseEventKind::Scroll { dx: 0.0, dy },
2685                ..
2686            }) if dy > 0.0
2687        ));
2688    }
2689
2690    #[test]
2691    fn scroll_right_line_delta() {
2692        // A pure horizontal LineDelta (trackpad swipe, tilt wheel): scroll_y == 0.0.
2693        let mut app = test_window_app();
2694        app.handle_window_event(WindowEvent::MouseWheel {
2695            device_id: winit::event::DeviceId::dummy(),
2696            delta: winit::event::MouseScrollDelta::LineDelta(1.0, 0.0),
2697            phase: winit::event::TouchPhase::Moved,
2698        });
2699        let ev = poll(&mut app).unwrap();
2700        assert!(matches!(
2701            ev,
2702            Event::Mouse(MouseEvent {
2703                kind: MouseEventKind::Scroll { dx, dy: 0.0 },
2704                ..
2705            }) if dx > 0.0
2706        ));
2707    }
2708
2709    #[test]
2710    fn scroll_left_pixel_delta() {
2711        // Regression test for retroglyph#293: before the fix, a pure-horizontal `PixelDelta`
2712        // (scroll_y == 0.0) spuriously fell through to a spurious vertical scroll instead of
2713        // being reported as (or, before horizontal scroll was wired up, dropped as) a
2714        // horizontal scroll.
2715        let mut app = test_window_app();
2716        app.handle_window_event(WindowEvent::MouseWheel {
2717            device_id: winit::event::DeviceId::dummy(),
2718            delta: winit::event::MouseScrollDelta::PixelDelta(winit::dpi::PhysicalPosition::new(
2719                -15.0_f64, 0.0_f64,
2720            )),
2721            phase: winit::event::TouchPhase::Moved,
2722        });
2723        let ev = poll(&mut app).unwrap();
2724        assert!(matches!(
2725            ev,
2726            Event::Mouse(MouseEvent {
2727                kind: MouseEventKind::Scroll { dx, dy: 0.0 },
2728                ..
2729            }) if dx < 0.0
2730        ));
2731    }
2732
2733    #[test]
2734    fn scroll_with_zero_delta_on_both_axes_pushes_no_event() {
2735        let mut app = test_window_app();
2736        app.handle_window_event(WindowEvent::MouseWheel {
2737            device_id: winit::event::DeviceId::dummy(),
2738            delta: winit::event::MouseScrollDelta::LineDelta(0.0, 0.0),
2739            phase: winit::event::TouchPhase::Moved,
2740        });
2741        assert_eq!(poll(&mut app), None);
2742    }
2743
2744    #[test]
2745    fn modifiers_propagate_to_mouse_event() {
2746        let mut app = test_window_app();
2747        // Simulate a ModifiersChanged before the click.
2748        app.handle_window_event(WindowEvent::ModifiersChanged(
2749            winit::event::Modifiers::from(winit::keyboard::ModifiersState::SHIFT),
2750        ));
2751        let _ = poll(&mut app); // no event emitted for modifiers
2752        app.handle_window_event(WindowEvent::MouseInput {
2753            device_id: winit::event::DeviceId::dummy(),
2754            state: winit::event::ElementState::Pressed,
2755            button: winit::event::MouseButton::Left,
2756        });
2757        let ev = poll(&mut app).unwrap();
2758        assert!(matches!(
2759            ev,
2760            Event::Mouse(MouseEvent {
2761                modifiers,
2762                ..
2763            }) if modifiers.contains(KeyModifiers::SHIFT)
2764        ));
2765    }
2766
2767    // ── mouse drag (retroglyph#554) ───────────────────────────────────────────
2768
2769    #[test]
2770    fn cursor_moved_with_no_button_held_emits_moved() {
2771        let mut app = test_window_app();
2772        app.handle_window_event(WindowEvent::CursorMoved {
2773            device_id: winit::event::DeviceId::dummy(),
2774            position: winit::dpi::PhysicalPosition::new(8.0_f64, 16.0_f64),
2775        });
2776        assert!(matches!(
2777            poll(&mut app),
2778            Some(Event::Mouse(MouseEvent {
2779                kind: MouseEventKind::Moved,
2780                ..
2781            }))
2782        ));
2783    }
2784
2785    #[test]
2786    fn cursor_moved_while_button_held_emits_drag_not_moved() {
2787        let mut app = test_window_app();
2788        app.handle_window_event(WindowEvent::MouseInput {
2789            device_id: winit::event::DeviceId::dummy(),
2790            state: winit::event::ElementState::Pressed,
2791            button: winit::event::MouseButton::Left,
2792        });
2793        let _ = poll(&mut app); // Down
2794
2795        app.handle_window_event(WindowEvent::CursorMoved {
2796            device_id: winit::event::DeviceId::dummy(),
2797            position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
2798        });
2799        assert!(matches!(
2800            poll(&mut app),
2801            Some(Event::Mouse(MouseEvent {
2802                kind: MouseEventKind::Drag(MouseButton::Left),
2803                ..
2804            }))
2805        ));
2806    }
2807
2808    #[test]
2809    fn cursor_moved_after_button_release_goes_back_to_moved() {
2810        let mut app = test_window_app();
2811        app.handle_window_event(WindowEvent::MouseInput {
2812            device_id: winit::event::DeviceId::dummy(),
2813            state: winit::event::ElementState::Pressed,
2814            button: winit::event::MouseButton::Left,
2815        });
2816        let _ = poll(&mut app); // Down
2817        app.handle_window_event(WindowEvent::MouseInput {
2818            device_id: winit::event::DeviceId::dummy(),
2819            state: winit::event::ElementState::Released,
2820            button: winit::event::MouseButton::Left,
2821        });
2822        let _ = poll(&mut app); // Up
2823
2824        app.handle_window_event(WindowEvent::CursorMoved {
2825            device_id: winit::event::DeviceId::dummy(),
2826            position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
2827        });
2828        assert!(matches!(
2829            poll(&mut app),
2830            Some(Event::Mouse(MouseEvent {
2831                kind: MouseEventKind::Moved,
2832                ..
2833            }))
2834        ));
2835    }
2836
2837    #[test]
2838    fn right_button_drag_reports_right_not_left() {
2839        let mut app = test_window_app();
2840        app.handle_window_event(WindowEvent::MouseInput {
2841            device_id: winit::event::DeviceId::dummy(),
2842            state: winit::event::ElementState::Pressed,
2843            button: winit::event::MouseButton::Right,
2844        });
2845        let _ = poll(&mut app); // Down
2846
2847        app.handle_window_event(WindowEvent::CursorMoved {
2848            device_id: winit::event::DeviceId::dummy(),
2849            position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
2850        });
2851        assert!(matches!(
2852            poll(&mut app),
2853            Some(Event::Mouse(MouseEvent {
2854                kind: MouseEventKind::Drag(MouseButton::Right),
2855                ..
2856            }))
2857        ));
2858    }
2859
2860    #[test]
2861    fn left_button_takes_priority_over_right_when_both_are_held() {
2862        // Deterministic tie-break documented on `on_cursor_moved`: Left wins when more than one
2863        // button is held at once.
2864        let mut app = test_window_app();
2865        app.handle_window_event(WindowEvent::MouseInput {
2866            device_id: winit::event::DeviceId::dummy(),
2867            state: winit::event::ElementState::Pressed,
2868            button: winit::event::MouseButton::Right,
2869        });
2870        let _ = poll(&mut app); // Down
2871        app.handle_window_event(WindowEvent::MouseInput {
2872            device_id: winit::event::DeviceId::dummy(),
2873            state: winit::event::ElementState::Pressed,
2874            button: winit::event::MouseButton::Left,
2875        });
2876        let _ = poll(&mut app); // Down
2877
2878        app.handle_window_event(WindowEvent::CursorMoved {
2879            device_id: winit::event::DeviceId::dummy(),
2880            position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
2881        });
2882        assert!(matches!(
2883            poll(&mut app),
2884            Some(Event::Mouse(MouseEvent {
2885                kind: MouseEventKind::Drag(MouseButton::Left),
2886                ..
2887            }))
2888        ));
2889    }
2890
2891    #[test]
2892    fn touch_drag_produces_drag_left_not_moved() {
2893        // Regression test for retroglyph#554: `on_touch` synthesizes a left-button `Down` before
2894        // its `Moved` phase forwards to `on_cursor_moved`, so a touch drag must fall out of the
2895        // same `held_buttons` tracking a real mouse drag uses, with no touch-specific code.
2896        use winit::event::TouchPhase;
2897        let mut app = test_window_app();
2898        app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
2899        poll(&mut app); // Moved
2900        poll(&mut app); // Down
2901
2902        app.handle_window_event(touch(1, TouchPhase::Moved, 40.0, 32.0));
2903        assert!(matches!(
2904            poll(&mut app),
2905            Some(Event::Mouse(MouseEvent {
2906                kind: MouseEventKind::Drag(MouseButton::Left),
2907                ..
2908            }))
2909        ));
2910    }
2911
2912    #[test]
2913    fn focus_lost_clears_held_button_so_refocus_move_is_not_a_stale_drag() {
2914        // Regression test for retroglyph#554: a button released while the window is unfocused
2915        // never delivers `MouseInput`, so `held_buttons` must be force-cleared on blur or every
2916        // move after refocus keeps reporting a `Drag` for a button that's actually up.
2917        let mut app = test_window_app();
2918        app.handle_window_event(WindowEvent::MouseInput {
2919            device_id: winit::event::DeviceId::dummy(),
2920            state: winit::event::ElementState::Pressed,
2921            button: winit::event::MouseButton::Left,
2922        });
2923        let _ = poll(&mut app); // Down
2924
2925        app.handle_window_event(WindowEvent::Focused(false));
2926        assert_eq!(poll(&mut app), Some(Event::FocusLost));
2927        assert_eq!(app.held_buttons, 0);
2928
2929        app.handle_window_event(WindowEvent::Focused(true));
2930        assert_eq!(poll(&mut app), Some(Event::FocusGained));
2931        app.handle_window_event(WindowEvent::CursorMoved {
2932            device_id: winit::event::DeviceId::dummy(),
2933            position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
2934        });
2935        assert!(matches!(
2936            poll(&mut app),
2937            Some(Event::Mouse(MouseEvent {
2938                kind: MouseEventKind::Moved,
2939                ..
2940            }))
2941        ));
2942    }
2943
2944    // ── user events (EventProxy) ─────────────────────────────────────────────
2945
2946    #[test]
2947    fn user_event_pushes_custom_event() {
2948        let mut app = test_window_app();
2949        app.handle_user_event(42);
2950        assert_eq!(poll(&mut app), Some(Event::Custom(42)));
2951    }
2952
2953    #[test]
2954    fn multiple_user_events_preserve_fifo_order() {
2955        let mut app = test_window_app();
2956        app.handle_user_event(1);
2957        app.handle_user_event(2);
2958        assert_eq!(poll(&mut app), Some(Event::Custom(1)));
2959        assert_eq!(poll(&mut app), Some(Event::Custom(2)));
2960        assert_eq!(poll(&mut app), None);
2961    }
2962
2963    #[test]
2964    fn user_events_interleave_with_window_events_in_arrival_order() {
2965        let mut app = test_window_app();
2966        app.handle_user_event(7);
2967        app.handle_window_event(WindowEvent::CloseRequested);
2968        assert_eq!(poll(&mut app), Some(Event::Custom(7)));
2969        assert_eq!(poll(&mut app), Some(Event::Close));
2970    }
2971
2972    #[test]
2973    fn event_proxy_closed_reports_the_undelivered_id() {
2974        let err = EventProxyClosed(42);
2975        assert_eq!(err.into_inner(), 42);
2976        assert_eq!(err.to_string(), "event loop closed");
2977    }
2978
2979    #[test]
2980    fn event_proxy_closed_round_trips_a_non_u64_payload() {
2981        // `EventProxyClosed<T>` carries whatever `T` `EventProxy<T>::send_event` was called
2982        // with, not just the `u64` default.
2983        let err = EventProxyClosed(String::from("asset.bin"));
2984        assert_eq!(err.to_string(), "event loop closed");
2985        assert_eq!(err.into_inner(), "asset.bin");
2986    }
2987
2988    // ── typed EventProxy<T> (non-`u64` custom payload) ────────────────────────
2989
2990    /// A payload that is emphatically not `u64`, to prove the typed path never funnels through
2991    /// [`Event::Custom`] (which is fixed to `u64` in `retroglyph_core`).
2992    #[derive(Debug, Clone, PartialEq, Eq)]
2993    struct AssetLoaded {
2994        name: String,
2995        bytes: usize,
2996    }
2997
2998    type TypedAppLoop = fn(&mut Terminal<WindowBackend<MockPresenter>>);
2999    type TypedHandler = Box<dyn FnMut(AssetLoaded, &mut Terminal<WindowBackend<MockPresenter>>)>;
3000    type TypedApp = WindowApp<MockPresenter, TypedAppLoop, AssetLoaded, TypedHandler>;
3001
3002    fn test_typed_window_app(on_custom_event: TypedHandler) -> TypedApp {
3003        let terminal = Terminal::new(WindowBackend::new(MockPresenter::default()));
3004        WindowApp {
3005            terminal: Some(terminal),
3006            app_loop: |_| {},
3007            on_custom_event,
3008            _user_event: PhantomData,
3009            window: None,
3010            title: String::new(),
3011            init_size: InitWindowSize {
3012                width: 80,
3013                height: 80,
3014            },
3015            attrs: WindowAttrs::default(),
3016            current_modifiers: KeyModifiers::NONE,
3017            cursor_px: (0.0, 0.0),
3018            active_touch: None,
3019            held_buttons: 0,
3020            frame_interval: None,
3021            event_driven: true,
3022            #[cfg(not(target_arch = "wasm32"))]
3023            next_frame: std::time::Instant::now(),
3024            exit_requested: Rc::new(Cell::new(false)),
3025            skip_present: Rc::new(Cell::new(false)),
3026            needs_redraw: false,
3027            consecutive_present_errors: 0,
3028        }
3029    }
3030
3031    #[test]
3032    fn typed_user_event_reaches_the_custom_handler_not_event_custom() {
3033        let received: Rc<RefCell<Vec<AssetLoaded>>> = Rc::new(RefCell::new(Vec::new()));
3034        let received_in_handler = received.clone();
3035        let handler: TypedHandler = Box::new(move |payload, _term| {
3036            received_in_handler.borrow_mut().push(payload);
3037        });
3038        let mut app = test_typed_window_app(handler);
3039
3040        let payload = AssetLoaded {
3041            name: "asset.bin".to_string(),
3042            bytes: 4096,
3043        };
3044        app.handle_user_event(payload.clone());
3045
3046        // Delivered to the handler directly...
3047        assert_eq!(received.borrow().as_slice(), &[payload]);
3048        // ...and never pushed onto the `WindowBackend` event queue as an `Event` at all: there is
3049        // no `Event` variant a non-`u64` payload could become.
3050        assert_eq!(
3051            app.terminal
3052                .as_mut()
3053                .unwrap()
3054                .backend_mut()
3055                .poll_event(Duration::ZERO),
3056            None
3057        );
3058    }
3059
3060    #[test]
3061    fn typed_user_event_still_sets_needs_redraw() {
3062        // Same wake-the-idle-loop behavior as the `u64`/`Event::Custom` path.
3063        let handler: TypedHandler = Box::new(|_payload, _term| {});
3064        let mut app = test_typed_window_app(handler);
3065        assert!(!app.needs_redraw);
3066        app.handle_user_event(AssetLoaded {
3067            name: "asset.bin".to_string(),
3068            bytes: 4096,
3069        });
3070        assert!(app.needs_redraw);
3071    }
3072
3073    #[test]
3074    fn close_requested_pushes_close_event() {
3075        let mut app = test_window_app();
3076        app.handle_window_event(WindowEvent::CloseRequested);
3077        assert_eq!(poll(&mut app), Some(Event::Close));
3078    }
3079
3080    // ── IME (issue #296) ──────────────────────────────────────────────────────
3081
3082    #[test]
3083    fn ime_commit_pushes_paste_event() {
3084        let mut app = test_window_app();
3085        app.handle_window_event(WindowEvent::Ime(winit::event::Ime::Commit(
3086            "pasted".to_string(),
3087        )));
3088        assert_eq!(poll(&mut app), Some(Event::Paste("pasted".to_string())));
3089    }
3090
3091    #[test]
3092    fn ime_preedit_and_enabled_push_no_event() {
3093        let mut app = test_window_app();
3094        app.handle_window_event(WindowEvent::Ime(winit::event::Ime::Enabled));
3095        app.handle_window_event(WindowEvent::Ime(winit::event::Ime::Preedit(
3096            "nihon".to_string(),
3097            Some((0, 5)),
3098        )));
3099        assert_eq!(poll(&mut app), None);
3100    }
3101
3102    // ── graceful exit (issue #157) ────────────────────────────────────────────
3103
3104    /// A `WindowApp` whose `app_loop` is a boxed closure, so a test can capture and flip a
3105    /// shared flag from inside it, mirroring how `run_app_with_proxy`'s real closure sets
3106    /// `exit_requested` on `Flow::Exit` (it can't return a value or reach `ActiveEventLoop`
3107    /// itself; see `exit_requested`'s doc comment).
3108    type BoxedAppLoop = Box<dyn FnMut(&mut Terminal<WindowBackend<MockPresenter>>)>;
3109    type BoxedApp = WindowApp<
3110        MockPresenter,
3111        BoxedAppLoop,
3112        u64,
3113        fn(u64, &mut Terminal<WindowBackend<MockPresenter>>),
3114    >;
3115
3116    #[test]
3117    fn redraw_requested_runs_app_loop_and_does_not_set_exit_by_default() {
3118        let mut app = test_window_app();
3119        app.handle_window_event(WindowEvent::RedrawRequested);
3120        assert!(!app.exit_requested.get());
3121    }
3122
3123    #[test]
3124    fn app_loop_setting_exit_requested_is_observed_after_redraw() {
3125        // Simulates `run_app_with_proxy`'s closure: on `Flow::Exit` it sets the shared flag
3126        // instead of calling `std::process::exit`. `handle_window_event` itself never calls
3127        // `event_loop.exit()` (it can't: no `ActiveEventLoop`, see its doc comment); that
3128        // happens in `ApplicationHandler::window_event`, which this flag lets the test assert
3129        // on without a live winit event loop.
3130        let terminal = Terminal::new(WindowBackend::new(MockPresenter::default()));
3131        let exit_requested = Rc::new(Cell::new(false));
3132        let exit_requested_in_loop = exit_requested.clone();
3133        let app_loop: BoxedAppLoop = Box::new(move |_term| exit_requested_in_loop.set(true));
3134        let mut app: BoxedApp = WindowApp {
3135            terminal: Some(terminal),
3136            app_loop,
3137            on_custom_event: push_custom_event,
3138            _user_event: PhantomData,
3139            window: None,
3140            title: String::new(),
3141            init_size: InitWindowSize {
3142                width: 80,
3143                height: 80,
3144            },
3145            attrs: WindowAttrs::default(),
3146            current_modifiers: KeyModifiers::NONE,
3147            cursor_px: (0.0, 0.0),
3148            active_touch: None,
3149            held_buttons: 0,
3150            frame_interval: None,
3151            event_driven: true,
3152            #[cfg(not(target_arch = "wasm32"))]
3153            next_frame: std::time::Instant::now(),
3154            exit_requested,
3155            skip_present: Rc::new(Cell::new(false)),
3156            needs_redraw: false,
3157            consecutive_present_errors: 0,
3158        };
3159
3160        assert!(!app.exit_requested.get());
3161        app.handle_window_event(WindowEvent::RedrawRequested);
3162        assert!(app.exit_requested.get());
3163    }
3164
3165    #[test]
3166    fn theme_changed_pushes_mapped_system_theme_event() {
3167        let mut app = test_window_app();
3168        app.handle_window_event(WindowEvent::ThemeChanged(winit::window::Theme::Light));
3169        assert_eq!(
3170            poll(&mut app),
3171            Some(Event::ThemeChanged(
3172                retroglyph_core::event::SystemTheme::Light
3173            ))
3174        );
3175
3176        app.handle_window_event(WindowEvent::ThemeChanged(winit::window::Theme::Dark));
3177        assert_eq!(
3178            poll(&mut app),
3179            Some(Event::ThemeChanged(
3180                retroglyph_core::event::SystemTheme::Dark
3181            ))
3182        );
3183    }
3184
3185    #[test]
3186    fn focused_pushes_focus_gained_and_lost_events() {
3187        let mut app = test_window_app();
3188        app.handle_window_event(WindowEvent::Focused(true));
3189        assert_eq!(poll(&mut app), Some(Event::FocusGained));
3190
3191        app.handle_window_event(WindowEvent::Focused(false));
3192        assert_eq!(poll(&mut app), Some(Event::FocusLost));
3193    }
3194
3195    #[test]
3196    fn focus_lost_resets_stuck_modifiers() {
3197        // Regression test for #153: a modifier held down when focus is lost
3198        // (e.g. alt-tabbing away while holding Shift) must not stay "held"
3199        // for events delivered after focus returns.
3200        let mut app = test_window_app();
3201        app.handle_window_event(WindowEvent::ModifiersChanged(
3202            winit::event::Modifiers::from(winit::keyboard::ModifiersState::SHIFT),
3203        ));
3204        let _ = poll(&mut app); // no event emitted for modifiers
3205        assert_eq!(app.current_modifiers, KeyModifiers::SHIFT);
3206
3207        app.handle_window_event(WindowEvent::Focused(false));
3208        assert_eq!(poll(&mut app), Some(Event::FocusLost));
3209        assert_eq!(app.current_modifiers, KeyModifiers::NONE);
3210
3211        // A click after refocusing must not still carry the stale Shift.
3212        app.handle_window_event(WindowEvent::Focused(true));
3213        assert_eq!(poll(&mut app), Some(Event::FocusGained));
3214        app.handle_window_event(WindowEvent::MouseInput {
3215            device_id: winit::event::DeviceId::dummy(),
3216            state: winit::event::ElementState::Pressed,
3217            button: winit::event::MouseButton::Left,
3218        });
3219        let ev = poll(&mut app).unwrap();
3220        assert!(matches!(
3221            ev,
3222            Event::Mouse(MouseEvent { modifiers, .. }) if modifiers == KeyModifiers::NONE
3223        ));
3224    }
3225
3226    #[test]
3227    fn focus_lost_releases_stuck_active_touch() {
3228        // Regression test for #153: a finger lifted while the window is
3229        // unfocused/backgrounded never delivers `TouchPhase::Ended` or
3230        // `Cancelled`, so `active_touch` must be released on blur instead of
3231        // silently ignoring every subsequent finger down.
3232        use winit::event::TouchPhase;
3233        let mut app = test_window_app();
3234        app.handle_window_event(touch(3, TouchPhase::Started, 20.0, 18.0));
3235        poll(&mut app); // Moved
3236        poll(&mut app); // Down
3237        assert_eq!(app.active_touch, Some(3));
3238
3239        app.handle_window_event(WindowEvent::Focused(false));
3240        assert_eq!(poll(&mut app), Some(Event::FocusLost));
3241        // Synthesized Up releasing the stuck touch at its last known
3242        // position; no new Moved, since blur carries no fresh location.
3243        assert!(matches!(
3244            poll(&mut app),
3245            Some(Event::Mouse(MouseEvent {
3246                kind: MouseEventKind::Up(MouseButton::Left),
3247                ..
3248            }))
3249        ));
3250        assert_eq!(poll(&mut app), None);
3251        assert_eq!(app.active_touch, None);
3252
3253        // A new finger down after refocusing must be tracked, not ignored.
3254        app.handle_window_event(WindowEvent::Focused(true));
3255        assert_eq!(poll(&mut app), Some(Event::FocusGained));
3256        app.handle_window_event(touch(4, TouchPhase::Started, 40.0, 32.0));
3257        assert!(matches!(
3258            poll(&mut app),
3259            Some(Event::Mouse(MouseEvent {
3260                kind: MouseEventKind::Moved,
3261                ..
3262            }))
3263        ));
3264        assert!(matches!(
3265            poll(&mut app),
3266            Some(Event::Mouse(MouseEvent {
3267                kind: MouseEventKind::Down(MouseButton::Left),
3268                ..
3269            }))
3270        ));
3271        assert_eq!(app.active_touch, Some(4));
3272    }
3273
3274    #[test]
3275    fn focus_lost_without_active_touch_pushes_no_extra_events() {
3276        // No touch in progress: blur should push exactly one FocusLost, no
3277        // synthesized mouse events.
3278        let mut app = test_window_app();
3279        app.handle_window_event(WindowEvent::Focused(false));
3280        assert_eq!(poll(&mut app), Some(Event::FocusLost));
3281        assert_eq!(poll(&mut app), None);
3282    }
3283
3284    #[test]
3285    fn resized_pushes_resize_event_in_cells() {
3286        // 8x16 cells: 88x80 px -> 11 cols, 5 rows.
3287        let mut app = test_window_app();
3288        app.handle_window_event(WindowEvent::Resized(winit::dpi::PhysicalSize::new(88, 80)));
3289        assert_eq!(poll(&mut app), Some(Event::Resize(11, 5)));
3290    }
3291
3292    // ── scale factor changes ─────────────────────────────────────────────────
3293
3294    #[test]
3295    fn scale_factor_changed_notifies_presenter() {
3296        // `handle_window_event` can't be exercised directly here: winit's
3297        // `InnerSizeWriter::new` is `pub(crate)`, so a real
3298        // `WindowEvent::ScaleFactorChanged` can't be constructed outside the
3299        // winit crate. `on_scale_factor_changed` is called directly instead:
3300        // it's the same code the `WindowEvent::ScaleFactorChanged` arm in
3301        // `handle_window_event` dispatches to.
3302        let mut app = test_window_app();
3303        app.on_scale_factor_changed(2.0);
3304        assert_eq!(
3305            app.terminal
3306                .as_ref()
3307                .unwrap()
3308                .backend()
3309                .presenter()
3310                .last_scale_factor
3311                .get(),
3312            Some(2.0)
3313        );
3314    }
3315
3316    #[test]
3317    fn scale_factor_changed_without_a_window_is_a_no_op_resize() {
3318        // `test_window_app` has no real winit window (`window: None`), so
3319        // there is no physical size to re-align the surface to: this must
3320        // not panic, and must not push a spurious `Event::Resize`.
3321        let mut app = test_window_app();
3322        app.on_scale_factor_changed(2.0);
3323        assert_eq!(poll(&mut app), None);
3324    }
3325
3326    #[test]
3327    fn resize_to_clamps_to_whole_cells_and_pushes_resize_event() {
3328        // Shared helper behind both `on_resized` and
3329        // `on_scale_factor_changed`: 8x16 cells, 90x81 px clamps down to
3330        // 11 cols x 5 rows (88x80 px), not a fractional cell.
3331        let mut app = test_window_app();
3332        app.resize_to(winit::dpi::PhysicalSize::new(90, 81));
3333        assert_eq!(poll(&mut app), Some(Event::Resize(11, 5)));
3334    }
3335
3336    #[test]
3337    fn resize_to_updates_backend_size_immediately() {
3338        // Regression test for #508: previously `backend.size()` (via `Output::size`) kept
3339        // reporting the pre-resize dimensions until the app called `Terminal::resize` in
3340        // response to `Event::Resize`, so polling the backend directly for drift was useless.
3341        // `resize_to` must now also call `Output::resize` so `size()` agrees with the surface
3342        // right away, independent of whether/when the app resizes the terminal's own grid.
3343        let mut app = test_window_app();
3344        assert_eq!(
3345            app.terminal.as_ref().unwrap().backend().size(),
3346            Size {
3347                width: 10,
3348                height: 5,
3349            }
3350        );
3351        app.resize_to(winit::dpi::PhysicalSize::new(90, 81));
3352        assert_eq!(
3353            app.terminal.as_ref().unwrap().backend().size(),
3354            Size {
3355                width: 11,
3356                height: 5,
3357            }
3358        );
3359        // `Terminal::size` (the grid itself) is untouched: that stays the app's job, done by
3360        // calling `Terminal::resize` in response to the `Event::Resize` this same call pushed.
3361        assert_eq!(
3362            app.terminal.as_ref().unwrap().size(),
3363            Size {
3364                width: 10,
3365                height: 5,
3366            }
3367        );
3368    }
3369
3370    #[test]
3371    fn resized_below_one_cell_clamps_surface_and_event_to_1x1() {
3372        // Regression test for #140: an 8x16-cell presenter resized to a
3373        // window smaller than one cell (4x4 px) must not compute 0 cols/0
3374        // rows: that would ask `resize_surface` for a zero-size surface,
3375        // which crashes softbuffer.
3376        type RecordingApp = WindowApp<
3377            RecordingPresenter,
3378            fn(&mut Terminal<WindowBackend<RecordingPresenter>>),
3379            u64,
3380            fn(u64, &mut Terminal<WindowBackend<RecordingPresenter>>),
3381        >;
3382        let resize_calls = Rc::new(RefCell::new(Vec::new()));
3383        let presenter = RecordingPresenter {
3384            resize_calls: resize_calls.clone(),
3385        };
3386        let terminal = Terminal::new(WindowBackend::new(presenter));
3387        let mut app: RecordingApp = WindowApp {
3388            terminal: Some(terminal),
3389            app_loop: |_| {},
3390            on_custom_event: push_custom_event,
3391            _user_event: PhantomData,
3392            window: None,
3393            title: String::new(),
3394            init_size: InitWindowSize {
3395                width: 80,
3396                height: 80,
3397            },
3398            attrs: WindowAttrs::default(),
3399            current_modifiers: KeyModifiers::NONE,
3400            cursor_px: (0.0, 0.0),
3401            active_touch: None,
3402            held_buttons: 0,
3403            frame_interval: None,
3404            event_driven: true,
3405            #[cfg(not(target_arch = "wasm32"))]
3406            next_frame: std::time::Instant::now(),
3407            exit_requested: Rc::new(Cell::new(false)),
3408            skip_present: Rc::new(Cell::new(false)),
3409            needs_redraw: false,
3410            consecutive_present_errors: 0,
3411        };
3412
3413        app.handle_window_event(WindowEvent::Resized(winit::dpi::PhysicalSize::new(4, 4)));
3414
3415        // Surface must be resized to at least one full cell (8x16), not
3416        // 0x0.
3417        assert_eq!(resize_calls.borrow().as_slice(), &[(8, 16)]);
3418        // Event::Resize must report the same clamped 1x1 grid, not 0x0.
3419        assert_eq!(
3420            app.terminal
3421                .as_mut()
3422                .unwrap()
3423                .backend_mut()
3424                .poll_event(Duration::ZERO),
3425            Some(Event::Resize(1, 1))
3426        );
3427    }
3428
3429    // ── needs_redraw (idle/redraw-on-demand, issue #155) ─────────────────────
3430
3431    #[test]
3432    fn fresh_app_does_not_need_a_redraw() {
3433        // `test_window_app` starts with `needs_redraw: false`, unlike the real
3434        // `resumed()` path, which sets it `true` once the window/surface exists (a real winit
3435        // `ActiveEventLoop` can't be constructed in a unit test, so `resumed` itself isn't
3436        // exercised here; see `handle_window_event`/`handle_user_event` below for the parts of
3437        // the redraw-on-demand logic that are testable without one).
3438        let app = test_window_app();
3439        assert!(!app.needs_redraw);
3440    }
3441
3442    #[test]
3443    fn window_event_sets_needs_redraw() {
3444        // Any real window event (a mouse move here, but any arm other than `RedrawRequested`
3445        // behaves the same; see `handle_window_event`'s doc comment) should mark that the app
3446        // loop has something new to react to, so the next `about_to_wait` requests a redraw
3447        // instead of leaving the loop idle.
3448        let mut app = test_window_app();
3449        assert!(!app.needs_redraw);
3450        app.handle_window_event(WindowEvent::CursorMoved {
3451            device_id: winit::event::DeviceId::dummy(),
3452            position: winit::dpi::PhysicalPosition::new(1.0_f64, 1.0_f64),
3453        });
3454        assert!(app.needs_redraw);
3455    }
3456
3457    #[test]
3458    fn redraw_requested_does_not_itself_set_needs_redraw() {
3459        // `RedrawRequested` is the render this flag exists to gate, not a new event to redraw
3460        // again for: an idle app that gets exactly one `RedrawRequested` (e.g. right after
3461        // `resumed`) must not perpetually re-arm itself into another one forever.
3462        let mut app = test_window_app();
3463        app.handle_window_event(WindowEvent::RedrawRequested);
3464        assert!(!app.needs_redraw);
3465    }
3466
3467    #[test]
3468    fn user_event_sets_needs_redraw() {
3469        // A cross-thread `Event::Custom` injection (network, audio, timer, ...) must wake an
3470        // idle loop into rendering the next frame just like a real window event does.
3471        let mut app = test_window_app();
3472        assert!(!app.needs_redraw);
3473        app.handle_user_event(1);
3474        assert!(app.needs_redraw);
3475    }
3476
3477    #[test]
3478    fn unhandled_window_events_still_set_needs_redraw() {
3479        // Even a `WindowEvent` variant with no dedicated handling below (falls through to the
3480        // `_ => {}` arm in `handle_window_event`'s `match`) should still be treated as "something
3481        // happened": the flag is set once, up front, before the match runs.
3482        let mut app = test_window_app();
3483        app.handle_window_event(WindowEvent::Occluded(true));
3484        assert!(app.needs_redraw);
3485    }
3486
3487    // ── frame-rate cap (target_fps) ───────────────────────────────────────────
3488
3489    #[test]
3490    fn target_fps_none_is_redraw_on_demand() {
3491        // `target_fps: None` leaves `frame_interval` unset, i.e. uncapped whenever a redraw
3492        // happens; `event_driven: true` is what sends `about_to_wait` down the
3493        // `needs_redraw`-gated branch.
3494        let presenter = MockPresenter::default();
3495        assert_eq!(
3496            WindowConfig::fit(&presenter, "test", None, true).target_fps(),
3497            None
3498        );
3499    }
3500
3501    #[test]
3502    fn target_fps_some_survives_to_the_config() {
3503        // Regression guard for the wasm32 half of the freeze this mode fixes: `target_fps` used
3504        // to be dropped on the floor for wasm builds (`frame_interval` was `#[cfg(not(target_arch
3505        // = "wasm32"))]`), so a browser app asking for continuous rendering silently got
3506        // redraw-on-demand and rendered one frame for the life of the page. The field is
3507        // unconditional now; this pins the config end of that, and the `compile-wasm` CI job pins
3508        // the driver end.
3509        let presenter = MockPresenter::default();
3510        assert_eq!(
3511            WindowConfig::fit(&presenter, "test", Some(60), false).target_fps(),
3512            Some(60)
3513        );
3514    }
3515
3516    #[test]
3517    fn event_driven_accessor_reflects_the_config() {
3518        let presenter = MockPresenter::default();
3519        assert!(WindowConfig::fit(&presenter, "test", None, true).event_driven());
3520        assert!(!WindowConfig::fit(&presenter, "test", None, false).event_driven());
3521    }
3522
3523    #[test]
3524    fn target_fps_and_event_driven_combine_independently() {
3525        // The combination `fit` alone couldn't express before: always redraw (not event-driven)
3526        // but uncapped (no `target_fps`).
3527        let presenter = MockPresenter::default();
3528        let config = WindowConfig::fit(&presenter, "test", None, false);
3529        assert_eq!(config.target_fps(), None);
3530        assert!(!config.event_driven());
3531    }
3532
3533    #[test]
3534    fn animated_is_sugar_for_continuous_capped_fit() {
3535        let presenter = MockPresenter::default();
3536        let config = WindowConfig::animated(&presenter, "test", 60);
3537        assert_eq!(config.target_fps(), Some(60));
3538        assert!(!config.event_driven());
3539    }
3540
3541    #[cfg(not(target_arch = "wasm32"))]
3542    #[test]
3543    fn frame_deadline_in_the_future_parks_the_loop() {
3544        let now = std::time::Instant::now();
3545        let next = now + Duration::from_millis(10);
3546        assert_eq!(
3547            next_frame_deadline(now, next, Duration::from_millis(16)),
3548            None
3549        );
3550    }
3551
3552    #[cfg(not(target_arch = "wasm32"))]
3553    #[test]
3554    fn frame_deadline_reached_advances_by_exactly_one_interval() {
3555        // On time (deadline just passed): the next deadline is one interval on from the *deadline*,
3556        // not from `now`, so a steady loop doesn't drift later and later.
3557        let interval = Duration::from_millis(16);
3558        let next = std::time::Instant::now();
3559        let now = next + Duration::from_micros(200);
3560        assert_eq!(
3561            next_frame_deadline(now, next, interval),
3562            Some(next + interval)
3563        );
3564    }
3565
3566    #[cfg(not(target_arch = "wasm32"))]
3567    #[test]
3568    fn overrun_frame_deadline_clamps_to_now_instead_of_bursting() {
3569        // A frame that blew well past its budget must not leave a backlog of deadlines already in
3570        // the past, which would render several catch-up frames back to back at full speed.
3571        let interval = Duration::from_millis(16);
3572        let next = std::time::Instant::now();
3573        let now = next + Duration::from_millis(500);
3574        assert_eq!(next_frame_deadline(now, next, interval), Some(now));
3575    }
3576
3577    // ── handle_redraw_requested / present() failure recovery ─────────────────
3578
3579    type FailingApp = WindowApp<
3580        FailingPresenter,
3581        fn(&mut Terminal<WindowBackend<FailingPresenter>>),
3582        u64,
3583        fn(u64, &mut Terminal<WindowBackend<FailingPresenter>>),
3584    >;
3585
3586    fn failing_app() -> (FailingApp, Rc<Cell<bool>>, Rc<Cell<u32>>) {
3587        let failing = Rc::new(Cell::new(false));
3588        let init_surface_calls = Rc::new(Cell::new(0));
3589        let presenter = FailingPresenter {
3590            failing: failing.clone(),
3591            init_surface_calls: init_surface_calls.clone(),
3592        };
3593        let terminal = Terminal::new(WindowBackend::new(presenter));
3594        let app: FailingApp = WindowApp {
3595            terminal: Some(terminal),
3596            app_loop: (|_| {}) as fn(&mut Terminal<WindowBackend<FailingPresenter>>),
3597            on_custom_event: push_custom_event,
3598            _user_event: PhantomData,
3599            window: None,
3600            title: String::new(),
3601            init_size: InitWindowSize {
3602                width: 80,
3603                height: 80,
3604            },
3605            attrs: WindowAttrs::default(),
3606            current_modifiers: KeyModifiers::NONE,
3607            cursor_px: (0.0, 0.0),
3608            active_touch: None,
3609            held_buttons: 0,
3610            frame_interval: None,
3611            event_driven: true,
3612            #[cfg(not(target_arch = "wasm32"))]
3613            next_frame: std::time::Instant::now(),
3614            exit_requested: Rc::new(Cell::new(false)),
3615            skip_present: Rc::new(Cell::new(false)),
3616            needs_redraw: false,
3617            consecutive_present_errors: 0,
3618        };
3619        (app, failing, init_surface_calls)
3620    }
3621
3622    #[test]
3623    fn successful_presents_never_increment_the_failure_counter() {
3624        let (mut app, _failing, _init_calls) = failing_app();
3625        for _ in 0..5 {
3626            app.handle_redraw_requested();
3627        }
3628        assert_eq!(app.consecutive_present_errors, 0);
3629    }
3630
3631    #[test]
3632    fn failing_presents_increment_the_counter_and_stop_short_of_recovery() {
3633        let (mut app, failing, init_calls) = failing_app();
3634        failing.set(true);
3635        for _ in 0..PRESENT_FAILURE_RECOVERY_THRESHOLD - 1 {
3636            app.handle_redraw_requested();
3637        }
3638        assert_eq!(
3639            app.consecutive_present_errors,
3640            PRESENT_FAILURE_RECOVERY_THRESHOLD - 1
3641        );
3642        // No window to recover from in this test app (`window: None`), but recovery should not
3643        // even have been attempted yet regardless: confirmed by `try_recover_surface`'s own
3644        // no-window guard never being reached, i.e. `init_surface` was never called past the
3645        // initial 0.
3646        assert_eq!(init_calls.get(), 0);
3647    }
3648
3649    #[test]
3650    fn counter_resets_after_recovering_from_a_failure_streak() {
3651        let (mut app, failing, _init_calls) = failing_app();
3652        failing.set(true);
3653        for _ in 0..5 {
3654            app.handle_redraw_requested();
3655        }
3656        assert_eq!(app.consecutive_present_errors, 5);
3657
3658        failing.set(false);
3659        app.handle_redraw_requested();
3660        assert_eq!(app.consecutive_present_errors, 0);
3661    }
3662
3663    #[test]
3664    fn crossing_the_recovery_threshold_attempts_recovery_without_panicking() {
3665        // `test_window_app`/`failing_app` have no real winit `Window` (constructing one needs a
3666        // live event loop, unavailable in a unit test, the same limitation documented on
3667        // `scale_factor_changed_without_a_window_is_a_no_op_resize` above), so this can't assert
3668        // `init_surface` actually re-runs; `try_recover_surface`'s own no-window guard is exercised
3669        // directly below instead. What this does verify: the threshold-crossing call does not
3670        // panic, and the counter keeps incrementing through and past the threshold rather than
3671        // resetting or overflowing.
3672        let (mut app, failing, init_calls) = failing_app();
3673        failing.set(true);
3674        for _ in 0..PRESENT_FAILURE_RECOVERY_THRESHOLD {
3675            app.handle_redraw_requested();
3676        }
3677        assert_eq!(
3678            app.consecutive_present_errors,
3679            PRESENT_FAILURE_RECOVERY_THRESHOLD
3680        );
3681        assert_eq!(
3682            init_calls.get(),
3683            0,
3684            "no window means try_recover_surface's guard skips init_surface"
3685        );
3686    }
3687
3688    #[test]
3689    fn try_recover_surface_without_a_window_is_a_no_op() {
3690        let (mut app, _failing, init_calls) = failing_app();
3691        app.try_recover_surface();
3692        assert_eq!(init_calls.get(), 0);
3693    }
3694
3695    // ── automatic `Terminal::present` on redraw ───────────────────────────────
3696
3697    /// A [`Presenter`] that mirrors every drawn diff into an in-memory grid (like
3698    /// [`retroglyph_core::backend::Headless`], but implementing [`Presenter`] instead), so tests
3699    /// can assert on what was actually presented rather than just on whether `present()` returned
3700    /// `Ok`.
3701    #[derive(Default)]
3702    struct GridRecordingPresenter {
3703        /// `(x, y) -> glyph` for every cell ever written by `draw_layers`. A real display only
3704        /// keeps the latest write per cell, which is exactly what repeated `HashMap` inserts give
3705        /// us here.
3706        cells: RefCell<std::collections::HashMap<(u16, u16), char>>,
3707        /// Number of `draw_layers` calls observed, so tests can assert whether a second (and, per
3708        /// this module's `present`-erases-if-nothing-new-was-drawn finding, harmful) diff was ever
3709        /// sent.
3710        draw_calls: Cell<u32>,
3711    }
3712
3713    impl Output for GridRecordingPresenter {
3714        type Error = core::convert::Infallible;
3715
3716        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
3717        where
3718            I: Iterator<Item = DrawCell<'a>>,
3719        {
3720            Ok(())
3721        }
3722
3723        fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
3724        where
3725            I: Iterator<Item = DrawCell<'a>>,
3726        {
3727            self.draw_calls.set(self.draw_calls.get() + 1);
3728            let mut cells = self.cells.borrow_mut();
3729            for cell in content {
3730                cells.insert((cell.pos.x, cell.pos.y), cell.tile.glyph());
3731            }
3732            Ok(())
3733        }
3734
3735        fn flush(&mut self) -> Result<(), Self::Error> {
3736            Ok(())
3737        }
3738
3739        fn size(&self) -> Size {
3740            Size {
3741                width: 10,
3742                height: 5,
3743            }
3744        }
3745
3746        fn clear(&mut self) -> Result<(), Self::Error> {
3747            Ok(())
3748        }
3749
3750        fn resize(&mut self, _size: Size) {}
3751    }
3752
3753    impl Presenter for GridRecordingPresenter {
3754        type SurfaceError = core::convert::Infallible;
3755
3756        fn init_surface(
3757            &mut self,
3758            _window: Arc<dyn crate::presenter::WindowHandle>,
3759        ) -> Result<(), Self::SurfaceError> {
3760            Ok(())
3761        }
3762
3763        fn resize_surface(&mut self, _width: u32, _height: u32) {}
3764
3765        fn present(&mut self) -> Result<(), Self::SurfaceError> {
3766            Ok(())
3767        }
3768
3769        fn cell_size(&self) -> (u32, u32) {
3770            (8, 16)
3771        }
3772    }
3773
3774    type GridRecordingApp = WindowApp<
3775        GridRecordingPresenter,
3776        fn(&mut Terminal<WindowBackend<GridRecordingPresenter>>),
3777        u64,
3778        fn(u64, &mut Terminal<WindowBackend<GridRecordingPresenter>>),
3779    >;
3780
3781    /// Boxed-closure counterparts of [`GridRecordingApp`]'s type parameters, for tests (like
3782    /// [`skip_present_set_inside_app_loop_suppresses_the_automatic_present`]) whose `app_loop`
3783    /// needs to capture and mutate a shared flag, which a bare `fn` pointer cannot do.
3784    type BoxedGridRecordingAppLoop =
3785        Box<dyn FnMut(&mut Terminal<WindowBackend<GridRecordingPresenter>>)>;
3786    type BoxedGridRecordingApp = WindowApp<
3787        GridRecordingPresenter,
3788        BoxedGridRecordingAppLoop,
3789        u64,
3790        fn(u64, &mut Terminal<WindowBackend<GridRecordingPresenter>>),
3791    >;
3792
3793    fn recording_app(
3794        app_loop: fn(&mut Terminal<WindowBackend<GridRecordingPresenter>>),
3795    ) -> GridRecordingApp {
3796        let terminal = Terminal::new(WindowBackend::new(GridRecordingPresenter::default()));
3797        WindowApp {
3798            terminal: Some(terminal),
3799            app_loop,
3800            on_custom_event: push_custom_event,
3801            _user_event: PhantomData,
3802            window: None,
3803            title: String::new(),
3804            init_size: InitWindowSize {
3805                width: 80,
3806                height: 80,
3807            },
3808            attrs: WindowAttrs::default(),
3809            current_modifiers: KeyModifiers::NONE,
3810            cursor_px: (0.0, 0.0),
3811            active_touch: None,
3812            held_buttons: 0,
3813            frame_interval: None,
3814            event_driven: true,
3815            #[cfg(not(target_arch = "wasm32"))]
3816            next_frame: std::time::Instant::now(),
3817            exit_requested: Rc::new(Cell::new(false)),
3818            skip_present: Rc::new(Cell::new(false)),
3819            needs_redraw: false,
3820            consecutive_present_errors: 0,
3821        }
3822    }
3823
3824    #[test]
3825    fn app_loop_that_never_presents_is_still_drawn_by_the_automatic_present() {
3826        // Case (a): an `app_loop` that draws but never calls `term.present()` itself must still
3827        // reach the backend: that's the whole point of this driver-side automatic present.
3828        let mut app = recording_app(|term| {
3829            term.surface()
3830                .put((0, 0), '@', retroglyph_core::Style::default());
3831        });
3832        app.handle_redraw_requested();
3833        let term = app.terminal.as_ref().unwrap();
3834        let presenter = term.backend().presenter();
3835        assert_eq!(presenter.cells.borrow().get(&(0, 0)), Some(&'@'));
3836        assert_eq!(
3837            presenter.draw_calls.get(),
3838            1,
3839            "exactly one present this frame"
3840        );
3841    }
3842
3843    #[test]
3844    fn app_loop_that_already_presents_itself_is_not_double_drawn() {
3845        // Case (b): an `app_loop` that still calls `term.present()` itself (the pre-fix pattern)
3846        // must keep working, and, crucially, must not have its frame blanked by a second,
3847        // driver-side `present()` call diffing an now-empty `current` against the just-drawn
3848        // `previous` (see `Terminal::present`'s doc comment for why that second call would
3849        // otherwise erase the frame).
3850        let mut app = recording_app(|term| {
3851            term.surface()
3852                .put((0, 0), '@', retroglyph_core::Style::default());
3853            term.present().expect("app_loop's own present");
3854        });
3855        app.handle_redraw_requested();
3856        let term = app.terminal.as_ref().unwrap();
3857        let presenter = term.backend().presenter();
3858        assert_eq!(presenter.cells.borrow().get(&(0, 0)), Some(&'@'));
3859        assert_eq!(
3860            presenter.draw_calls.get(),
3861            1,
3862            "the driver must detect app_loop's own present and skip its automatic one"
3863        );
3864    }
3865
3866    #[test]
3867    fn skip_present_set_inside_app_loop_suppresses_the_automatic_present() {
3868        // Simulates an `App::update` returning `Flow::Idle`: `run_app_with_proxy`'s closure draws
3869        // nothing and sets `skip_present` from inside `app_loop`, the same point in the frame
3870        // `run_app_with_proxy`'s real closure sets it from. `handle_redraw_requested` must honor
3871        // it: `Terminal::present` always presents unconditionally (even on an untouched frame),
3872        // so without this explicit skip it would still run and erase whatever the previous frame
3873        // left on screen.
3874        let terminal = Terminal::new(WindowBackend::new(GridRecordingPresenter::default()));
3875        let skip_present = Rc::new(Cell::new(false));
3876        let skip_present_in_loop = skip_present.clone();
3877        let app_loop: BoxedGridRecordingAppLoop =
3878            Box::new(move |_term| skip_present_in_loop.set(true));
3879        let mut app: BoxedGridRecordingApp = WindowApp {
3880            terminal: Some(terminal),
3881            app_loop,
3882            on_custom_event: push_custom_event,
3883            _user_event: PhantomData,
3884            window: None,
3885            title: String::new(),
3886            init_size: InitWindowSize {
3887                width: 80,
3888                height: 80,
3889            },
3890            attrs: WindowAttrs::default(),
3891            current_modifiers: KeyModifiers::NONE,
3892            cursor_px: (0.0, 0.0),
3893            active_touch: None,
3894            held_buttons: 0,
3895            frame_interval: None,
3896            event_driven: true,
3897            #[cfg(not(target_arch = "wasm32"))]
3898            next_frame: std::time::Instant::now(),
3899            exit_requested: Rc::new(Cell::new(false)),
3900            skip_present,
3901            needs_redraw: false,
3902            consecutive_present_errors: 0,
3903        };
3904        app.handle_redraw_requested();
3905        let term = app.terminal.as_ref().unwrap();
3906        let presenter = term.backend().presenter();
3907        assert_eq!(
3908            presenter.draw_calls.get(),
3909            0,
3910            "no present reaches the backend when app_loop sets skip_present"
3911        );
3912    }
3913
3914    #[test]
3915    fn skip_present_does_not_carry_over_to_the_next_redraw() {
3916        // `handle_redraw_requested` must reset `skip_present` before running `app_loop`, so a
3917        // stale `true` from a previous `Idle` frame can't suppress the next frame's present.
3918        let mut app = recording_app(|term| {
3919            term.surface()
3920                .put((0, 0), '@', retroglyph_core::Style::default());
3921        });
3922        app.skip_present.set(true); // Stale value, as if left over from a prior Idle frame.
3923        app.handle_redraw_requested();
3924        let term = app.terminal.as_ref().unwrap();
3925        let presenter = term.backend().presenter();
3926        assert_eq!(presenter.cells.borrow().get(&(0, 0)), Some(&'@'));
3927        assert_eq!(presenter.draw_calls.get(), 1);
3928    }
3929
3930    #[test]
3931    fn present_count_advances_once_per_present_call() {
3932        let mut term = Terminal::new(WindowBackend::new(GridRecordingPresenter::default()));
3933        assert_eq!(term.present_count(), 0);
3934        term.present().expect("present");
3935        assert_eq!(term.present_count(), 1);
3936        term.present().expect("present");
3937        assert_eq!(term.present_count(), 2);
3938    }
3939
3940    // ── handle_redraw_requested / unrecoverable (`is_recoverable() == false`) errors ─────────
3941
3942    type FatalApp = WindowApp<
3943        FatalPresenter,
3944        fn(&mut Terminal<WindowBackend<FatalPresenter>>),
3945        u64,
3946        fn(u64, &mut Terminal<WindowBackend<FatalPresenter>>),
3947    >;
3948
3949    fn fatal_app() -> (FatalApp, Rc<Cell<bool>>, Rc<Cell<u32>>) {
3950        let failing = Rc::new(Cell::new(false));
3951        let init_surface_calls = Rc::new(Cell::new(0));
3952        let presenter = FatalPresenter {
3953            failing: failing.clone(),
3954            init_surface_calls: init_surface_calls.clone(),
3955        };
3956        let terminal = Terminal::new(WindowBackend::new(presenter));
3957        let app: FatalApp = WindowApp {
3958            terminal: Some(terminal),
3959            app_loop: (|_| {}) as fn(&mut Terminal<WindowBackend<FatalPresenter>>),
3960            on_custom_event: push_custom_event,
3961            _user_event: PhantomData,
3962            window: None,
3963            title: String::new(),
3964            init_size: InitWindowSize {
3965                width: 80,
3966                height: 80,
3967            },
3968            attrs: WindowAttrs::default(),
3969            current_modifiers: KeyModifiers::NONE,
3970            cursor_px: (0.0, 0.0),
3971            active_touch: None,
3972            held_buttons: 0,
3973            frame_interval: None,
3974            event_driven: true,
3975            #[cfg(not(target_arch = "wasm32"))]
3976            next_frame: std::time::Instant::now(),
3977            exit_requested: Rc::new(Cell::new(false)),
3978            skip_present: Rc::new(Cell::new(false)),
3979            needs_redraw: false,
3980            consecutive_present_errors: 0,
3981        };
3982        (app, failing, init_surface_calls)
3983    }
3984
3985    #[test]
3986    fn unrecoverable_present_failure_never_attempts_recovery_even_past_the_threshold() {
3987        // Unlike `FailingPresenter` (recoverable errors, generic threshold-based recovery), a
3988        // `FatalPresenter` failure is fatal on every single call: `present_failure_action`
3989        // returns `Fatal` immediately (see the pure-function tests above), so
3990        // `handle_redraw_requested` must never route it through `try_recover_surface`, no matter
3991        // how many consecutive failures accumulate past `PRESENT_FAILURE_RECOVERY_THRESHOLD`.
3992        let (mut app, failing, init_calls) = fatal_app();
3993        failing.set(true);
3994        for _ in 0..2 * PRESENT_FAILURE_RECOVERY_THRESHOLD {
3995            app.handle_redraw_requested();
3996        }
3997        assert_eq!(init_calls.get(), 0);
3998    }
3999
4000    #[test]
4001    fn unrecoverable_present_failure_does_not_panic_and_keeps_counting() {
4002        let (mut app, failing, _init_calls) = fatal_app();
4003        failing.set(true);
4004        for _ in 0..5 {
4005            app.handle_redraw_requested();
4006        }
4007        assert_eq!(app.consecutive_present_errors, 5);
4008    }
4009
4010    #[test]
4011    fn recovering_from_an_unrecoverable_failure_streak_still_resets_the_counter() {
4012        let (mut app, failing, _init_calls) = fatal_app();
4013        failing.set(true);
4014        for _ in 0..3 {
4015            app.handle_redraw_requested();
4016        }
4017        assert_eq!(app.consecutive_present_errors, 3);
4018
4019        failing.set(false);
4020        app.handle_redraw_requested();
4021        assert_eq!(app.consecutive_present_errors, 0);
4022    }
4023}