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