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