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