Skip to main content

retroglyph_window/winit/
run.rs

1//! The winit event loop and the windowed app drivers.
2//!
3//! [`run_windowed`] drives a raw `FnMut(&mut Terminal<..>)` closure;
4//! [`run_app`] drives an [`App`](retroglyph_core::App). This is the inverted
5//! driver: winit owns the loop and calls back into the app on each redraw,
6//! so it cannot be core's generic
7//! [`run_blocking`](retroglyph_core::run_blocking), which owns its own
8//! `while` loop.
9
10use super::translate::{
11    physical_pos_from, pixel_to_cell, translate_key, translate_modifiers, translate_mouse_button,
12};
13#[cfg(target_arch = "wasm32")]
14use super::web;
15use crate::backend::WindowBackend;
16use crate::presenter::Presenter;
17use retroglyph_core::Terminal;
18use retroglyph_core::backend::Backend;
19use retroglyph_core::event::{Event, KeyModifiers, MouseEvent, MouseEventKind, PhysicalPos};
20use std::cell::Cell;
21use std::fmt;
22use std::rc::Rc;
23use std::sync::Arc;
24#[cfg(not(target_arch = "wasm32"))]
25use std::time::Duration;
26use winit::application::ApplicationHandler;
27use winit::event::WindowEvent;
28use winit::event_loop::{ActiveEventLoop, EventLoop};
29use winit::window::{Window, WindowId};
30
31/// The user-event payload type threaded through winit's [`EventLoop`]. A plain `u64` so
32/// [`EventProxy`] stays trivially `Send`/`Sync`/`Clone`; see [`Event::Custom`]'s doc comment for
33/// why the payload is opaque rather than a boxed value.
34type UserEvent = u64;
35
36/// A thread-safe handle for injecting [`Event::Custom`] events into a running windowed event
37/// loop from another thread (network, audio, timer, ...).
38///
39/// Obtained via the `on_proxy` callback passed to [`run_windowed_with_proxy`]/
40/// [`run_app_with_proxy`], invoked synchronously right after the event loop (and this proxy) is
41/// created, before the loop starts blocking the calling thread. Clone it freely to hand a copy to
42/// each worker thread that needs to wake the loop; wraps winit's own
43/// [`EventLoopProxy`](winit::event_loop::EventLoopProxy), which is `Send + Sync` for any
44/// `'static` payload, including `u64`.
45#[derive(Clone, Debug)]
46pub struct EventProxy(winit::event_loop::EventLoopProxy<UserEvent>);
47
48impl EventProxy {
49    /// Injects `id` as [`Event::Custom(id)`](Event::Custom) into the event loop's queue, waking
50    /// it if it's asleep. The event surfaces through the app's normal `poll_event`/frame loop
51    /// like any other [`Event`].
52    ///
53    /// # Errors
54    ///
55    /// Returns [`EventProxyClosed`] if the event loop has already exited.
56    pub fn send_event(&self, id: u64) -> Result<(), EventProxyClosed> {
57        self.0.send_event(id).map_err(|e| EventProxyClosed(e.0))
58    }
59}
60
61/// Error returned by [`EventProxy::send_event`] when the event loop it targets has already
62/// exited.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub struct EventProxyClosed(u64);
65
66impl EventProxyClosed {
67    /// The event id that could not be delivered.
68    #[must_use]
69    pub const fn into_inner(self) -> u64 {
70        self.0
71    }
72}
73
74impl fmt::Display for EventProxyClosed {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "event loop closed")
77    }
78}
79
80impl std::error::Error for EventProxyClosed {}
81
82/// Window configuration for [`run_windowed`] / [`run_app`].
83///
84/// Deliberately renderer-agnostic: pixel dimensions, not grid/font/scale.
85/// Use [`fit`](Self::fit) to derive the pixel size from a presenter's own
86/// cell geometry.
87// Five independent window attribute toggles (`fill_viewport`, `resizable`, `decorations`,
88// `fullscreen`, `transparency`), not a state machine in disguise: each maps to one winit
89// `WindowAttributes` builder call and is meaningful on its own.
90#[allow(clippy::struct_excessive_bools)]
91pub struct WindowConfig {
92    title: String,
93    width: u32,
94    height: u32,
95    target_fps: Option<u32>,
96    fill_viewport: bool,
97    resizable: bool,
98    decorations: bool,
99    min_size: Option<(u32, u32)>,
100    max_size: Option<(u32, u32)>,
101    initial_position: Option<(i32, i32)>,
102    fullscreen: bool,
103    transparency: bool,
104}
105
106impl WindowConfig {
107    /// Size the window to exactly fit `presenter`'s grid:
108    /// `cols x cell_w` by `rows x cell_h` physical pixels.
109    ///
110    /// This is why renderer crates don't need their own windowing code: the
111    /// grid/cell geometry already lives behind [`Presenter::size`] and
112    /// [`Presenter::cell_size`].
113    ///
114    /// `target_fps` is an optional frame-rate cap: `None` runs uncapped on native (the event
115    /// loop re-renders as fast as the backend allows) or at display refresh on `wasm32` (always
116    /// `requestAnimationFrame`-driven there regardless of this setting).
117    #[must_use]
118    pub fn fit<P: Presenter>(
119        presenter: &P,
120        title: impl Into<String>,
121        target_fps: Option<u32>,
122    ) -> Self {
123        let grid = presenter.size();
124        let (cell_w, cell_h) = presenter.cell_size();
125        Self {
126            title: title.into(),
127            width: u32::from(grid.width) * cell_w,
128            height: u32::from(grid.height) * cell_h,
129            target_fps,
130            fill_viewport: false,
131            resizable: true,
132            decorations: true,
133            min_size: None,
134            max_size: None,
135            initial_position: None,
136            fullscreen: false,
137            transparency: false,
138        }
139    }
140
141    /// The window title, as set by [`fit`](Self::fit).
142    #[must_use]
143    pub fn title(&self) -> &str {
144        &self.title
145    }
146
147    /// Initial inner width in physical pixels, as computed by [`fit`](Self::fit).
148    #[must_use]
149    pub const fn width(&self) -> u32 {
150        self.width
151    }
152
153    /// Initial inner height in physical pixels, as computed by [`fit`](Self::fit).
154    #[must_use]
155    pub const fn height(&self) -> u32 {
156        self.height
157    }
158
159    /// The frame-rate cap passed to [`fit`](Self::fit), if any.
160    #[must_use]
161    pub const fn target_fps(&self) -> Option<u32> {
162        self.target_fps
163    }
164
165    /// Sets whether to size (and keep resizing) the canvas to fill the browser viewport on
166    /// `wasm32`, instead of the pixel size [`fit`](Self::fit) computed -- a full-screen,
167    /// mobile-web-app feel for games that want it. Has no effect on native, where the OS window
168    /// is already sized by [`fit`](Self::fit) and the window manager owns further resizing
169    /// either way.
170    ///
171    /// Defaults to `false`: most demos/examples should render at their natural grid size
172    /// (`cols x cell_w` by `rows x cell_h`) wherever they land on the page, not stretch to fill
173    /// whatever viewport happens to be hosting them. Opt in explicitly for an app-like,
174    /// full-screen game.
175    #[must_use]
176    pub const fn fill_viewport(mut self, fill_viewport: bool) -> Self {
177        self.fill_viewport = fill_viewport;
178        self
179    }
180
181    /// Sets whether the window can be resized by the user/window manager after creation.
182    ///
183    /// Defaults to `true` (winit's own default). Set to `false` for fixed-size retro windows
184    /// where the grid is meant to stay put -- resizing a pseudo-graphic UI usually means picking
185    /// a new grid size, not stretching cells, and most callers that care already size the window
186    /// to their content via [`fit`](Self::fit).
187    ///
188    /// On `wasm32`, winit's web backend ignores this (there is no OS-level resize grip on a
189    /// canvas); it's still applied for source-level parity with native, it just has no effect.
190    #[must_use]
191    pub const fn resizable(mut self, resizable: bool) -> Self {
192        self.resizable = resizable;
193        self
194    }
195
196    /// Sets whether the window has OS chrome: title bar, borders, close/minimize/maximize
197    /// buttons.
198    ///
199    /// Defaults to `true` (winit's own default). Set to `false` for a borderless window
200    /// (custom-drawn title bars, retro full-bleed layouts).
201    ///
202    /// On `wasm32`, winit's web backend ignores this (a canvas has no OS chrome to begin with);
203    /// it's still applied for source-level parity with native, it just has no effect.
204    #[must_use]
205    pub const fn decorations(mut self, decorations: bool) -> Self {
206        self.decorations = decorations;
207        self
208    }
209
210    /// Sets the minimum inner (content) size in physical pixels.
211    ///
212    /// Defaults to no minimum.
213    #[must_use]
214    pub const fn min_size(mut self, width: u32, height: u32) -> Self {
215        self.min_size = Some((width, height));
216        self
217    }
218
219    /// Sets the maximum inner (content) size in physical pixels.
220    ///
221    /// Defaults to no maximum.
222    #[must_use]
223    pub const fn max_size(mut self, width: u32, height: u32) -> Self {
224        self.max_size = Some((width, height));
225        self
226    }
227
228    /// Sets the desired initial outer window position in physical pixels.
229    ///
230    /// Defaults to letting the platform choose.
231    ///
232    /// On `wasm32`, winit's web backend maps this to the canvas's `position: absolute`
233    /// left/top, which only does anything if the page's CSS has already opted the canvas into
234    /// absolute/relative positioning; otherwise normal document flow overrides it.
235    #[must_use]
236    pub const fn initial_position(mut self, x: i32, y: i32) -> Self {
237        self.initial_position = Some((x, y));
238        self
239    }
240
241    /// Sets whether to request borderless fullscreen (on the window's current monitor) at
242    /// creation.
243    ///
244    /// Defaults to `false`. This only exposes borderless fullscreen, not winit's
245    /// exclusive-fullscreen video-mode API: retro/terminal-style apps render a fixed cell grid,
246    /// not a resolution-dependent 3D scene, so there is no benefit to an exclusive video-mode
247    /// switch, only extra platform-specific complexity (enumerating
248    /// [`VideoModeHandle`](winit::monitor::VideoModeHandle)s) for a mode real games would rarely
249    /// want here.
250    ///
251    /// On `wasm32`, winit's web backend maps this to the browser's Fullscreen API
252    /// (`Element.requestFullscreen`), which most browsers refuse to grant without a user
253    /// gesture; requesting it unconditionally at window-creation time (before any gesture) is
254    /// liable to silently fail there. Still applied for source-level parity with native.
255    #[must_use]
256    pub const fn fullscreen(mut self, fullscreen: bool) -> Self {
257        self.fullscreen = fullscreen;
258        self
259    }
260
261    /// Sets whether the window's background supports transparency (alpha blending with whatever
262    /// is behind it).
263    ///
264    /// Defaults to `false` (winit's own default).
265    ///
266    /// On `wasm32`, winit's web backend ignores this (a canvas is already alpha-blended with the
267    /// page behind it via normal CSS compositing); it's still applied for source-level parity
268    /// with native, it just has no effect.
269    #[must_use]
270    pub const fn transparency(mut self, transparency: bool) -> Self {
271        self.transparency = transparency;
272        self
273    }
274}
275
276/// Open a window and drive `app_loop` from the winit event loop.
277///
278/// On native this blocks the calling thread until the loop exits; on wasm it
279/// returns immediately and the loop continues on `requestAnimationFrame`.
280///
281/// The closure receives `&mut Terminal<WindowBackend<P>>` and is called on
282/// every frame tick. Window close pushes [`Event::Close`] into the event
283/// queue rather than exiting: the game decides when to terminate.
284///
285/// # Errors
286///
287/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
288/// created or fails while running.
289pub fn run_windowed<P, F>(
290    config: WindowConfig,
291    presenter: P,
292    app_loop: F,
293) -> Result<(), winit::error::EventLoopError>
294where
295    P: Presenter + 'static,
296    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
297{
298    run_windowed_with_proxy(config, presenter, app_loop, |_proxy| {})
299}
300
301/// Same as [`run_windowed`], but also hands `on_proxy` an [`EventProxy`] for injecting
302/// cross-thread events.
303///
304/// `on_proxy` is called synchronously right after the event loop (and the proxy) is created,
305/// before this function starts blocking the calling thread on native. Use this over
306/// [`run_windowed`] whenever another thread (network, audio, timer, ...) needs to wake the event
307/// loop and deliver an [`Event::Custom`] to the app; `on_proxy` is the hook to hand a clone of the
308/// proxy off to that thread before the loop takes over the calling thread.
309///
310/// # Examples
311///
312/// ```ignore
313/// use retroglyph_core::event::Event;
314/// use retroglyph_software::SoftwareBackendBuilder;
315/// use retroglyph_window::winit::{WindowConfig, run_windowed_with_proxy};
316/// use std::time::Duration;
317///
318/// let renderer = SoftwareBackendBuilder::new()
319///     .grid_size(80, 25)
320///     .scale(2)
321///     .build()
322///     .expect("backend init failed")
323///     .run_headless();
324/// let config = WindowConfig::fit(&renderer, "My Game", None);
325///
326/// run_windowed_with_proxy(
327///     config,
328///     renderer,
329///     move |term| {
330///         if let Some(Event::Custom(id)) = term.poll(Duration::from_millis(16)) {
331///             // Handle the tick/network/audio result tagged `id`.
332///             println!("got custom event {id}");
333///         }
334///     },
335///     |proxy| {
336///         // Runs before the blocking call below starts, so the proxy can be
337///         // handed off to a worker thread up front.
338///         std::thread::spawn(move || loop {
339///             std::thread::sleep(Duration::from_secs(1));
340///             if proxy.send_event(1).is_err() {
341///                 break; // The window closed; stop ticking.
342///             }
343///         });
344///     },
345/// )
346/// .expect("event loop failed");
347/// ```
348///
349/// # Errors
350///
351/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
352/// created or fails while running.
353pub fn run_windowed_with_proxy<P, F, O>(
354    config: WindowConfig,
355    presenter: P,
356    app_loop: F,
357    on_proxy: O,
358) -> Result<(), winit::error::EventLoopError>
359where
360    P: Presenter + 'static,
361    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
362    O: FnOnce(EventProxy),
363{
364    run_windowed_with_proxy_and_exit_flag(
365        config,
366        presenter,
367        app_loop,
368        on_proxy,
369        Rc::new(Cell::new(false)),
370    )
371}
372
373/// Shared implementation behind [`run_windowed_with_proxy`] and
374/// [`run_app_with_proxy`].
375///
376/// `exit_requested` is checked after every [`WindowEvent::RedrawRequested`] and, when set, drives
377/// [`ActiveEventLoop::exit`] so the loop unwinds normally (see [`WindowApp::exit_requested`]'s doc
378/// comment for why this can't be plumbed through `app_loop`'s return value instead).
379/// [`run_windowed_with_proxy`] passes a flag nobody ever sets (a plain `FnMut(&mut Terminal<..>)`
380/// closure has no way to reach it); [`run_app_with_proxy`] shares one with the closure it builds
381/// around `app_loop`, which sets it on [`Flow::Exit`](retroglyph_core::Flow::Exit).
382fn run_windowed_with_proxy_and_exit_flag<P, F, O>(
383    config: WindowConfig,
384    presenter: P,
385    app_loop: F,
386    on_proxy: O,
387    exit_requested: Rc<Cell<bool>>,
388) -> Result<(), winit::error::EventLoopError>
389where
390    P: Presenter + 'static,
391    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
392    O: FnOnce(EventProxy),
393{
394    let terminal = Terminal::new(WindowBackend::new(presenter));
395    let event_loop = EventLoop::<UserEvent>::with_user_event().build()?;
396    on_proxy(EventProxy(event_loop.create_proxy()));
397
398    #[cfg(not(target_arch = "wasm32"))]
399    let frame_interval = config
400        .target_fps
401        .map(|fps| Duration::from_secs_f64(1.0 / f64::from(fps)));
402
403    let attrs = WindowAttrs::from(&config);
404    let app = WindowApp {
405        terminal: Some(terminal),
406        app_loop,
407        window: None,
408        title: config.title,
409        init_size: InitWindowSize {
410            width: config.width,
411            height: config.height,
412        },
413        attrs,
414        #[cfg(target_arch = "wasm32")]
415        fill_viewport: config.fill_viewport,
416        current_modifiers: KeyModifiers::NONE,
417        cursor_px: (0.0, 0.0),
418        active_touch: None,
419        #[cfg(not(target_arch = "wasm32"))]
420        frame_interval,
421        #[cfg(not(target_arch = "wasm32"))]
422        next_frame: std::time::Instant::now(),
423        exit_requested,
424        needs_redraw: true,
425        consecutive_present_errors: 0,
426    };
427
428    #[cfg(not(target_arch = "wasm32"))]
429    {
430        let mut app = app;
431        event_loop.run_app(&mut app)
432    }
433
434    #[cfg(target_arch = "wasm32")]
435    {
436        use winit::platform::web::EventLoopExtWebSys;
437        event_loop.spawn_app(app);
438        Ok(())
439    }
440}
441
442/// Drive an [`App`](retroglyph_core::App) from the windowed event loop.
443///
444/// This is the inverted driver: winit owns the event loop and calls back
445/// into the app on each redraw, rather than the app owning a `while` loop.
446///
447/// Each frame builds a [`Frame`](retroglyph_core::Frame) with a wall-clock
448/// `dt` measured via [`web_time::Instant`] -- a plain [`std::time::Instant`]
449/// re-export on native, backed by the browser's `Performance.now()` on
450/// `wasm32` (where `std::time::Instant` itself is unavailable). Calls
451/// [`step`](retroglyph_core::step).
452///
453/// On [`Flow::Exit`](retroglyph_core::Flow) the event loop exits gracefully
454/// (via [`ActiveEventLoop::exit`]) instead of force-exiting the process, so
455/// the stack unwinds normally and `Drop` impls up the call chain (unflushed
456/// writes, GPU/surface teardown, app-level RAII) run before the process
457/// exits. This works the same on wasm: winit's web backend implements
458/// `ActiveEventLoop::exit` by stopping its `requestAnimationFrame`-driven
459/// runner rather than leaving it a no-op.
460///
461/// # Errors
462///
463/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
464/// created or fails while running.
465pub fn run_app<P, A>(
466    config: WindowConfig,
467    presenter: P,
468    app: A,
469) -> Result<(), winit::error::EventLoopError>
470where
471    P: Presenter + 'static,
472    A: retroglyph_core::App<WindowBackend<P>> + 'static,
473{
474    run_app_with_proxy(config, presenter, app, |_proxy| {})
475}
476
477/// Same as [`run_app`], but also hands `on_proxy` an [`EventProxy`] for injecting cross-thread
478/// events -- see [`run_windowed_with_proxy`] for when/why to use the `_with_proxy` variant over
479/// the plain one.
480///
481/// # Errors
482///
483/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
484/// created or fails while running.
485pub fn run_app_with_proxy<P, A, O>(
486    config: WindowConfig,
487    presenter: P,
488    mut app: A,
489    on_proxy: O,
490) -> Result<(), winit::error::EventLoopError>
491where
492    P: Presenter + 'static,
493    A: retroglyph_core::App<WindowBackend<P>> + 'static,
494    O: FnOnce(EventProxy),
495{
496    let mut frame_count = 0u64;
497    let mut last = web_time::Instant::now();
498    let exit_requested = Rc::new(Cell::new(false));
499    let exit_requested_in_loop = exit_requested.clone();
500    run_windowed_with_proxy_and_exit_flag(
501        config,
502        presenter,
503        move |term| {
504            let now = web_time::Instant::now();
505            let delta = now.duration_since(last);
506            last = now;
507            let frame = retroglyph_core::Frame {
508                delta,
509                frame: frame_count,
510            };
511            frame_count = frame_count.wrapping_add(1);
512            if retroglyph_core::step(term, &mut app, &frame) == retroglyph_core::Flow::Exit {
513                exit_requested_in_loop.set(true);
514            }
515        },
516        on_proxy,
517        exit_requested,
518    )
519}
520
521/// Initial window dimensions used before the first Resized event.
522struct InitWindowSize {
523    width: u32,
524    height: u32,
525}
526
527/// The subset of [`WindowConfig`]'s builder attributes applied once, up front, to
528/// `Window::default_attributes()` in [`create_window_and_surface`](WindowApp::create_window_and_surface).
529///
530/// Grouped into its own type (rather than six more fields directly on [`WindowApp`]) since
531/// they're only ever read in that one place, unlike `fill_viewport`, which also gates per-resize
532/// behavior elsewhere.
533// See `WindowConfig`'s matching `#[allow]` for why these bools are independent toggles, not a
534// state machine.
535#[allow(clippy::struct_excessive_bools)]
536struct WindowAttrs {
537    resizable: bool,
538    decorations: bool,
539    min_size: Option<(u32, u32)>,
540    max_size: Option<(u32, u32)>,
541    initial_position: Option<(i32, i32)>,
542    fullscreen: bool,
543    transparency: bool,
544}
545
546impl From<&WindowConfig> for WindowAttrs {
547    fn from(config: &WindowConfig) -> Self {
548        Self {
549            resizable: config.resizable,
550            decorations: config.decorations,
551            min_size: config.min_size,
552            max_size: config.max_size,
553            initial_position: config.initial_position,
554            fullscreen: config.fullscreen,
555            transparency: config.transparency,
556        }
557    }
558}
559
560impl Default for WindowAttrs {
561    /// Mirrors [`WindowConfig::fit`]'s defaults, for tests that construct a [`WindowApp`]
562    /// directly without going through a [`WindowConfig`].
563    fn default() -> Self {
564        Self {
565            resizable: true,
566            decorations: true,
567            min_size: None,
568            max_size: None,
569            initial_position: None,
570            fullscreen: false,
571            transparency: false,
572        }
573    }
574}
575
576/// The winit `ApplicationHandler`: owns the window, the terminal, and the
577/// per-frame closure.
578struct WindowApp<P: Presenter, F> {
579    terminal: Option<Terminal<WindowBackend<P>>>,
580    app_loop: F,
581    window: Option<Arc<Window>>,
582    title: String,
583    init_size: InitWindowSize,
584    /// See [`WindowConfig`]'s `resizable`/`decorations`/`min_size`/`max_size`/
585    /// `initial_position`/`fullscreen`/`transparency` fields; applied once at window creation.
586    attrs: WindowAttrs,
587    /// See [`WindowConfig::fill_viewport`]. Only meaningful on `wasm32`; not
588    /// even stored on native, where it would do nothing.
589    #[cfg(target_arch = "wasm32")]
590    fill_viewport: bool,
591    /// Current modifier key state, updated by `ModifiersChanged` events.
592    current_modifiers: KeyModifiers,
593    /// Last known cursor position in physical pixels.
594    cursor_px: (f64, f64),
595    /// The finger currently treated as the pointer, if any.
596    ///
597    /// Touch input (mobile browsers, touchscreens) arrives as
598    /// [`WindowEvent::Touch`], not as `CursorMoved`/`MouseInput`. The first
599    /// finger down is adopted as "the pointer" and synthesized into the same
600    /// left-button mouse events games already handle; other fingers are
601    /// ignored until it lifts, so a stray second finger can't teleport the
602    /// cursor mid-drag.
603    active_touch: Option<u64>,
604    /// Optional frame interval for `WaitUntil` throttling. `None` = unbounded.
605    #[cfg(not(target_arch = "wasm32"))]
606    frame_interval: Option<Duration>,
607    /// Deadline for the next frame when `frame_interval` is set.
608    #[cfg(not(target_arch = "wasm32"))]
609    next_frame: std::time::Instant,
610    /// Set by `app_loop` (specifically [`run_app_with_proxy`]'s closure) to request the event
611    /// loop stop, instead of calling `std::process::exit` directly.
612    ///
613    /// `app_loop` is a plain `FnMut(&mut Terminal<..>)` with no return value and no
614    /// [`ActiveEventLoop`] handle, so it can't call `event_loop.exit()` itself; it can only flip
615    /// this shared flag. [`handle_window_event`](Self::handle_window_event) -- which runs
616    /// `app_loop` on [`WindowEvent::RedrawRequested`] -- deliberately takes no
617    /// [`ActiveEventLoop`] either, so unit tests can drive it without a live winit loop (see its
618    /// doc comment). `ApplicationHandler::window_event`, which does have the `ActiveEventLoop`,
619    /// checks this flag right after `handle_window_event` returns and calls `event_loop.exit()`
620    /// if it's set, letting the stack unwind normally (`Drop` impls run) instead of
621    /// force-terminating the process.
622    exit_requested: Rc<Cell<bool>>,
623    /// Set whenever something happened that the app loop should get a chance to react to:
624    /// window creation, an input/window event, or an injected [`Event::Custom`]. Cleared once
625    /// [`about_to_wait`](ApplicationHandler::about_to_wait) turns it into a `request_redraw()`
626    /// call.
627    ///
628    /// Retro/terminal-style apps are event-driven, not animation-driven, so "nothing happened"
629    /// should mean "render nothing new" -- see this field's use in `about_to_wait` for why that
630    /// keeps the loop asleep (`ControlFlow::Wait`) instead of spinning at ~100% CPU redrawing an
631    /// unchanged frame forever. Only consulted when `frame_interval` is `None`: a `target_fps`
632    /// throttle already redraws unconditionally once its `WaitUntil` deadline passes, animation
633    /// or not.
634    needs_redraw: bool,
635    /// Count of consecutive `present()` failures, reset to 0 on the next success. Drives
636    /// [`present_failure_action`]'s logging-verbosity and surface-recovery decisions in the
637    /// `RedrawRequested` arm of [`handle_window_event`](Self::handle_window_event).
638    consecutive_present_errors: u32,
639}
640
641impl<P: Presenter, F> WindowApp<P, F> {
642    /// Create the window and initialize the surface.
643    ///
644    /// Returns `Some(window)` on success, logs and returns `None` on failure.
645    fn create_window_and_surface(&mut self, event_loop: &ActiveEventLoop) -> Option<Arc<Window>> {
646        // On native, size the window to fit the grid (`WindowConfig::fit`)
647        // and let the OS window manager own further resizing. On wasm, if
648        // `fill_viewport` is set, there's no OS window to fit into -- the
649        // canvas *is* the page -- so size it to the browser viewport
650        // instead, for a full-screen, mobile-web-app feel; otherwise it's
651        // sized the same as native (`init_size`, the natural grid size),
652        // which is what most demos/examples want -- see
653        // `WindowConfig::fill_viewport`'s doc comment. winit sets an inline
654        // `width`/`height` style on the canvas matching whatever size we
655        // request here; it does not derive that size from page CSS, so this
656        // has to happen in Rust.
657        //
658        // Crucially, the viewport-filling size *must* be the viewport size
659        // at the real (uncapped) device pixel ratio, not the DPR-capped size
660        // used for the software backing store below. winit's wasm backend
661        // converts whatever `PhysicalSize` we pass here back to a logical
662        // (CSS pixel) size using `window.devicePixelRatio()` -- the actual,
663        // uncapped ratio -- to set the canvas's inline `style.width`/
664        // `style.height`. Handing it a DPR-capped physical size makes it
665        // divide by a *larger* real DPR than the one used to compute that
666        // size, so the resulting CSS size comes out smaller than the
667        // viewport (the higher the real DPR above the cap, the more the
668        // canvas visibly shrinks -- on a phone with DPR 3 and our 1.5 cap,
669        // that's 50% of the screen). See `web::web_viewport_surface_physical_size`
670        // for the separate, capped size used for the raster backing store.
671        // On native, `init_size` is expressed in logical (1x) pixels --
672        // `WindowConfig::fit` derives it from the presenter's grid/cell
673        // geometry, which assumes an unscaled cell. Requesting that count
674        // directly as a `PhysicalSize` on a HiDPI display asks winit/the OS
675        // for a window with fewer true pixels than the monitor actually
676        // has, so it gets upscaled blurrily to fill the same logical space
677        // instead of rendering crisply at native resolution from the first
678        // frame. Scaling by the primary monitor's `scale_factor` up front
679        // (falling back to `1.0` when no monitor is available, e.g.
680        // headless/CI) avoids that: see `physical_size_for`.
681        #[cfg(not(target_arch = "wasm32"))]
682        let physical_size = {
683            let scale_factor = event_loop
684                .primary_monitor()
685                .map_or(1.0, |monitor| monitor.scale_factor());
686            let (width, height) =
687                physical_size_for(self.init_size.width, self.init_size.height, scale_factor);
688            winit::dpi::PhysicalSize::new(width, height)
689        };
690        #[cfg(target_arch = "wasm32")]
691        let physical_size = if self.fill_viewport {
692            web::web_viewport_layout_physical_size().unwrap_or_else(|| {
693                winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height)
694            })
695        } else {
696            winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height)
697        };
698        #[cfg(target_arch = "wasm32")]
699        let surface_physical_size = if self.fill_viewport {
700            web::web_viewport_surface_physical_size().unwrap_or(physical_size)
701        } else {
702            physical_size
703        };
704        #[cfg(not(target_arch = "wasm32"))]
705        let surface_physical_size = physical_size;
706
707        let attrs = Window::default_attributes()
708            .with_title(&self.title)
709            .with_inner_size(physical_size)
710            .with_resizable(self.attrs.resizable)
711            .with_decorations(self.attrs.decorations)
712            .with_transparent(self.attrs.transparency);
713        let attrs = match self.attrs.min_size {
714            Some((w, h)) => attrs.with_min_inner_size(winit::dpi::PhysicalSize::new(w, h)),
715            None => attrs,
716        };
717        let attrs = match self.attrs.max_size {
718            Some((w, h)) => attrs.with_max_inner_size(winit::dpi::PhysicalSize::new(w, h)),
719            None => attrs,
720        };
721        let attrs = match self.attrs.initial_position {
722            Some((x, y)) => attrs.with_position(winit::dpi::PhysicalPosition::new(x, y)),
723            None => attrs,
724        };
725        let attrs = if self.attrs.fullscreen {
726            attrs.with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)))
727        } else {
728            attrs
729        };
730
731        #[cfg(target_family = "wasm")]
732        let attrs = {
733            use winit::platform::web::WindowAttributesExtWebSys;
734            attrs.with_append(true)
735        };
736
737        let window = Arc::new(match event_loop.create_window(attrs) {
738            Ok(w) => w,
739            Err(e) => {
740                log::error!("window creation failed: {e}");
741                event_loop.exit();
742                return None;
743            }
744        });
745
746        if let Some(term) = self.terminal.as_mut() {
747            // Hand the presenter a windowing-library-agnostic handle (see
748            // `Presenter::init_surface`); the winit window stays owned here.
749            let handle: Arc<dyn crate::presenter::WindowHandle> = window.clone();
750            if let Err(e) = term.backend_mut().presenter_mut().init_surface(handle) {
751                log::error!("surface init failed: {e}");
752                event_loop.exit();
753                return None;
754            }
755            // Set the initial surface size (required on WASM before first present).
756            // Deliberately `surface_physical_size`, not `physical_size`: the
757            // raster backing store stays DPR-capped for present() cost even
758            // though the canvas's CSS size (driven by `physical_size` via
759            // winit above) matches the full, uncapped viewport.
760            term.backend_mut()
761                .presenter_mut()
762                .resize_surface(surface_physical_size.width, surface_physical_size.height);
763        }
764
765        // Keep the canvas matching the browser viewport as it changes
766        // (device rotation, browser window resize, address-bar
767        // show/hide): winit only reacts to size changes we ask for
768        // ourselves (`request_inner_size`), so a `resize` listener is
769        // required to make this genuinely responsive rather than a
770        // one-shot fit at startup. Only installed when `fill_viewport` is
771        // set -- otherwise the canvas should stay at its natural grid size
772        // regardless of viewport changes.
773        #[cfg(target_arch = "wasm32")]
774        if self.fill_viewport {
775            web::install_viewport_resize_listener(&window);
776        }
777
778        // `WindowEvent::ThemeChanged` (handled in `handle_window_event`)
779        // only fires on a *change*, so an app that never sees a system
780        // theme change would otherwise never learn the starting one.
781        // `Window::theme()` reflects the current system theme both on
782        // native and on winit's web target (backed by the
783        // `prefers-color-scheme` media query there), so query it once
784        // up-front and synthesize the same event a live change would send.
785        if let Some(theme) = window.theme()
786            && let Some(term) = self.terminal.as_mut()
787        {
788            term.backend_mut().push_event(system_theme_event(theme));
789        }
790
791        Some(window)
792    }
793}
794
795/// Scales a logical (1x) initial window size up to true physical pixels for
796/// `scale_factor`, so [`create_window_and_surface`](WindowApp::create_window_and_surface)
797/// can request a window sized to the primary monitor's actual resolution
798/// from the first frame, instead of a too-small physical window the OS then
799/// has to upscale blurrily to fill the same on-screen space.
800///
801/// Pure math, kept separate from `create_window_and_surface` so it's unit
802/// -testable without a live winit event loop / monitor.
803#[cfg(not(target_arch = "wasm32"))]
804#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
805fn physical_size_for(logical_width: u32, logical_height: u32, scale_factor: f64) -> (u32, u32) {
806    (
807        (f64::from(logical_width) * scale_factor).round() as u32,
808        (f64::from(logical_height) * scale_factor).round() as u32,
809    )
810}
811
812/// Number of consecutive `present()` failures after which
813/// [`handle_window_event`](WindowApp::handle_window_event)'s `RedrawRequested` arm attempts to
814/// recover by re-initializing the surface (see [`PresentFailureAction::Recover`]).
815///
816/// Roughly half a second at 60 FPS: long enough that a single dropped frame (a transient `VSync`
817/// hiccup, a momentarily occluded window) never triggers a surface rebuild, but short enough that
818/// a genuinely broken surface (context loss, invalidated swapchain) doesn't sit unrecovered for
819/// many seconds.
820const PRESENT_FAILURE_RECOVERY_THRESHOLD: u32 = 30;
821
822/// What [`handle_window_event`](WindowApp::handle_window_event)'s `RedrawRequested` arm should do
823/// in response to the outcome of one `present()` call, given the running count of consecutive
824/// failures *before* this call.
825///
826/// [`Presenter::SurfaceError`] is a generic associated type -- the software backend's
827/// `SurfaceError` just wraps `softbuffer::SoftBufferError`, a plain `#[non_exhaustive]` enum with
828/// no `Lost`/`Outdated`/`Timeout` discrimination the way `wgpu::SurfaceError` has -- so the driver
829/// can't pattern-match on *why* a present failed to decide whether it's recoverable the way a
830/// wgpu-based app would. All it can observe is a bare `Display`able error and whether the failure
831/// is a one-off or persistent (via the consecutive-failure count), so the recovery strategy here
832/// is deliberately generic: rate-limit logging so a persistent failure doesn't spam every frame,
833/// and after a run of failures long enough to rule out a one-off glitch, attempt the one
834/// backend-agnostic recovery available -- re-running [`Presenter::init_surface`] to rebuild the
835/// surface from scratch, the same call [`create_window_and_surface`](WindowApp::create_window_and_surface)
836/// makes at startup.
837#[derive(Debug, Clone, Copy, PartialEq, Eq)]
838enum PresentFailureAction {
839    /// Presenting succeeded; if `was_failing` is `true` the caller should log recovery at `info`
840    /// or `warn` level (a prior failure streak just ended).
841    Ok { was_failing: bool },
842    /// Presenting failed; log at `error!` (first failure in a streak, or the very first ever)
843    /// or suppress (a already-logged, ongoing streak below the recovery threshold).
844    Log { at_error_level: bool },
845    /// Presenting failed and the consecutive-failure count just crossed the recovery threshold:
846    /// log at `warn!` and attempt to reinitialize the surface.
847    Recover,
848}
849
850/// Decides the action for one `present()` outcome, given `consecutive_failures` *before* this
851/// call (0 if the previous call succeeded or this is the first call).
852///
853/// Pure decision table, kept separate from the live `RedrawRequested` handling (which needs a
854/// real `Terminal`/`Presenter`/`Window`) so the threshold and logging-level logic is unit
855/// -testable without any of those -- the same reasoning as [`physical_size_for`] and
856/// [`web::dpr_pointer_scale`] above.
857const fn present_failure_action(
858    consecutive_failures: u32,
859    succeeded: bool,
860) -> PresentFailureAction {
861    if succeeded {
862        return PresentFailureAction::Ok {
863            was_failing: consecutive_failures > 0,
864        };
865    }
866    // `consecutive_failures` is the count *before* this failure, so the count *including* this
867    // one is `consecutive_failures + 1`; recover exactly when that reaches the threshold, and
868    // again every full threshold-worth of failures after that (so a failed recovery attempt
869    // doesn't get retried on literally the next frame, hot-looping surface rebuilds).
870    if (consecutive_failures + 1).is_multiple_of(PRESENT_FAILURE_RECOVERY_THRESHOLD) {
871        return PresentFailureAction::Recover;
872    }
873    PresentFailureAction::Log {
874        at_error_level: consecutive_failures == 0,
875    }
876}
877
878/// Maps winit's [`Theme`](winit::window::Theme) to the backend-agnostic
879/// [`Event::ThemeChanged`], the only place that conversion needs to happen.
880const fn system_theme_event(theme: winit::window::Theme) -> Event {
881    use retroglyph_core::event::SystemTheme;
882    match theme {
883        winit::window::Theme::Light => Event::ThemeChanged(SystemTheme::Light),
884        winit::window::Theme::Dark => Event::ThemeChanged(SystemTheme::Dark),
885    }
886}
887
888impl<P, F> ApplicationHandler<UserEvent> for WindowApp<P, F>
889where
890    P: Presenter,
891    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
892{
893    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
894        if let Some(window) = self.create_window_and_surface(event_loop) {
895            self.window = Some(window);
896        }
897        // First frame: nothing has "happened" yet in the input-event sense, but the app still
898        // needs an initial render once the window/surface exists.
899        self.needs_redraw = true;
900    }
901
902    fn window_event(
903        &mut self,
904        event_loop: &ActiveEventLoop,
905        _window_id: WindowId,
906        event: WindowEvent,
907    ) {
908        self.handle_window_event(event);
909        // `app_loop` (run on `RedrawRequested`, inside `handle_window_event`) can only signal
910        // exit by setting `exit_requested` -- see its doc comment for why. Check it here, where
911        // an `ActiveEventLoop` is actually available, and ask winit to exit gracefully instead of
912        // the caller force-exiting the process.
913        if self.exit_requested.get() {
914            event_loop.exit();
915        }
916    }
917
918    fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) {
919        self.handle_user_event(event);
920    }
921
922    fn about_to_wait(
923        &mut self,
924        #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] event_loop: &ActiveEventLoop,
925    ) {
926        #[cfg(not(target_arch = "wasm32"))]
927        if let Some(interval) = self.frame_interval {
928            // Throttled: sleep until the next frame deadline, then render
929            // unconditionally -- a `target_fps` cap is an animation-style
930            // frame rate, not an idle/event-driven one, so it always
931            // redraws once its deadline passes regardless of `needs_redraw`.
932            let now = std::time::Instant::now();
933            if self.next_frame > now {
934                event_loop
935                    .set_control_flow(winit::event_loop::ControlFlow::WaitUntil(self.next_frame));
936                return;
937            }
938            // Advance the deadline by one interval, clamping to now so a
939            // slow frame doesn't cause a burst of catch-up renders.
940            self.next_frame = (self.next_frame + interval).max(now);
941            if let Some(window) = &self.window {
942                window.request_redraw();
943            }
944            return;
945        }
946        // Uncapped (`target_fps: None`): only redraw if something actually happened since the
947        // last one. Otherwise leave `ControlFlow` at its default `Wait` so the loop sleeps
948        // instead of spinning at ~100% CPU re-rendering an unchanged frame every iteration --
949        // retro/terminal-style apps are idle most of the time and event-driven, not
950        // animation-driven, so "nothing happened" should mean "render nothing new". See
951        // `needs_redraw`'s doc comment.
952        if self.needs_redraw {
953            self.needs_redraw = false;
954            if let Some(window) = &self.window {
955                window.request_redraw();
956            }
957        }
958    }
959}
960
961impl<P, F> WindowApp<P, F>
962where
963    P: Presenter,
964    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
965{
966    /// Drain one injected user event into the [`WindowBackend`] queue as [`Event::Custom`].
967    ///
968    /// Extracted from the `ApplicationHandler::user_event` impl for the same reason as
969    /// [`handle_window_event`](Self::handle_window_event): so the drain logic can be exercised in
970    /// unit tests without a live [`ActiveEventLoop`]. There is only ever one event to drain per
971    /// call -- winit calls `user_event` once per [`EventProxy::send_event`] -- so "drain" here
972    /// means "push the one event this call carries", not draining a whole queue at once.
973    fn handle_user_event(&mut self, event: UserEvent) {
974        if let Some(term) = self.terminal.as_mut() {
975            term.backend_mut().push_event(Event::Custom(event));
976        }
977        self.needs_redraw = true;
978    }
979
980    /// Dispatch a [`WindowEvent`] without requiring an [`ActiveEventLoop`].
981    ///
982    /// Extracted from the `ApplicationHandler` impl so the translation and
983    /// event-buffer logic can be called directly in unit tests, where
984    /// [`ActiveEventLoop`] is not constructable.
985    fn handle_window_event(&mut self, event: WindowEvent) {
986        // Every branch below (other than `RedrawRequested`, which *is* the render this flag
987        // exists to gate) represents something the app loop should get a chance to react to on
988        // the next frame -- see `needs_redraw`'s doc comment for why that matters for idle CPU.
989        // Set unconditionally up front rather than per-arm: simpler, and the only event that must
990        // *not* set it (`RedrawRequested`) already clears it again in `about_to_wait` right before
991        // requesting this same redraw, so a same-tick `RedrawRequested` can't retrigger itself.
992        if !matches!(event, WindowEvent::RedrawRequested) {
993            self.needs_redraw = true;
994        }
995        match event {
996            WindowEvent::CloseRequested => {
997                // Push the event so the game loop can process it (save game,
998                // confirm dialog, etc.).  Do not call event_loop.exit() here;
999                // the game decides when to terminate.
1000                if let Some(term) = self.terminal.as_mut() {
1001                    term.backend_mut().push_event(Event::Close);
1002                }
1003            }
1004            WindowEvent::Resized(size) => self.on_resized(size),
1005            WindowEvent::CursorMoved { position, .. } => self.on_cursor_moved(position),
1006            WindowEvent::MouseInput { state, button, .. } => self.on_mouse_input(state, button),
1007            WindowEvent::MouseWheel { delta, .. } => self.on_mouse_wheel(delta),
1008            WindowEvent::Touch(touch) => self.on_touch(touch),
1009            WindowEvent::ModifiersChanged(mods) => {
1010                self.current_modifiers = translate_modifiers(mods.state());
1011            }
1012            WindowEvent::ThemeChanged(theme) => {
1013                if let Some(term) = self.terminal.as_mut() {
1014                    term.backend_mut().push_event(system_theme_event(theme));
1015                }
1016            }
1017            WindowEvent::Focused(gained) => self.on_focus_changed(gained),
1018            WindowEvent::KeyboardInput { event, .. } => {
1019                if let Some(term) = self.terminal.as_mut()
1020                    && let Some(e) = translate_key(event, self.current_modifiers)
1021                {
1022                    term.backend_mut().push_event(e);
1023                }
1024            }
1025            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
1026                self.on_scale_factor_changed(scale_factor);
1027            }
1028
1029            WindowEvent::RedrawRequested => self.handle_redraw_requested(),
1030
1031            _ => {}
1032        }
1033    }
1034
1035    /// Runs the app closure and presents the frame, tracking consecutive `present()` failures to
1036    /// rate-limit logging and trigger surface recovery.
1037    ///
1038    /// See [`present_failure_action`] for the decision table; this method just runs the `Terminal`
1039    /// -/`Presenter`-dependent side effects (`app_loop`, `present`, `init_surface`, logging) that
1040    /// function can't perform itself since it's a pure function of the failure count alone.
1041    fn handle_redraw_requested(&mut self) {
1042        let Some(term) = self.terminal.as_mut() else {
1043            return;
1044        };
1045        (self.app_loop)(term);
1046        let result = term.backend_mut().presenter_mut().present();
1047        let succeeded = result.is_ok();
1048        match present_failure_action(self.consecutive_present_errors, succeeded) {
1049            PresentFailureAction::Ok { was_failing } => {
1050                if was_failing {
1051                    log::info!(
1052                        "frame present recovered after {} consecutive failures",
1053                        self.consecutive_present_errors
1054                    );
1055                }
1056                self.consecutive_present_errors = 0;
1057            }
1058            PresentFailureAction::Log { at_error_level } => {
1059                self.consecutive_present_errors += 1;
1060                let e = result.unwrap_err();
1061                if at_error_level {
1062                    log::error!("frame present failed: {e}");
1063                } else {
1064                    // Ongoing failure streak below the recovery threshold: already logged at
1065                    // `error!` when the streak started, so avoid re-logging every single frame
1066                    // (the log-spam this issue exists to fix) while still keeping the detail
1067                    // available at `debug!` for anyone investigating a live failure.
1068                    log::debug!("frame present still failing: {e}");
1069                }
1070            }
1071            PresentFailureAction::Recover => {
1072                self.consecutive_present_errors += 1;
1073                let e = result.unwrap_err();
1074                log::warn!(
1075                    "frame present failed {} times consecutively ({e}); attempting surface recovery",
1076                    self.consecutive_present_errors
1077                );
1078                self.try_recover_surface();
1079            }
1080        }
1081    }
1082
1083    /// Attempts to recover from a persistent `present()` failure by re-running
1084    /// [`Presenter::init_surface`], the same call
1085    /// [`create_window_and_surface`](Self::create_window_and_surface) makes at startup.
1086    ///
1087    /// This is the only recovery available generically: [`Presenter::SurfaceError`] carries no
1088    /// structured "is this recoverable" signal (see [`present_failure_action`]'s doc comment), so
1089    /// rebuilding the surface from scratch is the one action that's meaningful across every
1090    /// backend. A no-op if there is no window to rebuild the surface from (headless/pre-`resumed`
1091    /// states), or if the terminal has already been torn down.
1092    fn try_recover_surface(&mut self) {
1093        let Some(window) = self.window.clone() else {
1094            return;
1095        };
1096        let Some(term) = self.terminal.as_mut() else {
1097            return;
1098        };
1099        let handle: Arc<dyn crate::presenter::WindowHandle> = window;
1100        if let Err(e) = term.backend_mut().presenter_mut().init_surface(handle) {
1101            log::error!("surface recovery failed: {e}");
1102        }
1103    }
1104
1105    fn on_resized(&mut self, size: winit::dpi::PhysicalSize<u32>) {
1106        // On wasm with `fill_viewport` set, `size` is whatever (uncapped)
1107        // physical size we last handed winit for CSS layout purposes -- not
1108        // the backing store size. Recompute the DPR-capped surface size
1109        // independently so the raster buffer doesn't silently lose its cap
1110        // on every resize. Without `fill_viewport`, the canvas never resizes
1111        // on its own (no listener installed above), so `size` here is
1112        // already the natural grid size and needs no such override.
1113        #[cfg(target_arch = "wasm32")]
1114        let size = if self.fill_viewport {
1115            web::web_viewport_surface_physical_size().unwrap_or(size)
1116        } else {
1117            size
1118        };
1119        self.resize_to(size);
1120    }
1121
1122    /// React to a scale-factor (DPI) change: notify the presenter, then
1123    /// realign the surface and grid to the window's new physical size.
1124    ///
1125    /// Every modern `HiDPI` display is scaled, so without this the surface
1126    /// silently keeps rendering at the old (pre-change) physical size --
1127    /// e.g. half the true resolution after moving to a 2x-scale display --
1128    /// until (if ever) an independent `Resized` event happens to arrive.
1129    /// Reusing [`resize_to`](Self::resize_to) here mirrors
1130    /// [`on_resized`](Self::on_resized), so both paths clamp/align the
1131    /// surface to whole cells the same way.
1132    fn on_scale_factor_changed(&mut self, scale_factor: f64) {
1133        if let Some(term) = self.terminal.as_mut() {
1134            term.backend_mut()
1135                .presenter_mut()
1136                .scale_factor_changed(scale_factor);
1137        }
1138        let Some(window) = self.window.clone() else {
1139            return;
1140        };
1141        self.resize_to(window.inner_size());
1142    }
1143
1144    /// Recompute the grid size (in cells) from a physical pixel size, resize
1145    /// the presenter's surface to the whole-cell-aligned pixel size, and push
1146    /// [`Event::Resize`] with the new cell dimensions.
1147    ///
1148    /// Shared by [`on_resized`](Self::on_resized) and
1149    /// [`on_scale_factor_changed`](Self::on_scale_factor_changed): both need
1150    /// the same clamp-to-cell-grid math, just triggered by different winit
1151    /// events.
1152    fn resize_to(&mut self, size: winit::dpi::PhysicalSize<u32>) {
1153        let Some(term) = self.terminal.as_mut() else {
1154            return;
1155        };
1156        let (cell_w, cell_h) = term.backend().presenter().cell_size();
1157        // Clamp to at least one cell: a window smaller than one cell in
1158        // either dimension would otherwise divide down to 0 cols/rows,
1159        // which in turn asks `resize_surface` for a zero-size surface --
1160        // softbuffer (and likely other presenters) can't handle that and
1161        // panics. `Event::Resize` must report the same clamped grid the
1162        // surface was actually sized to, or callers reading `Event::Resize`
1163        // and querying the presenter's surface size would disagree.
1164        //
1165        // Integer division here also truncates any sub-cell remainder: when
1166        // `size` isn't an exact multiple of the cell size, `cols`/`rows`
1167        // round down and the surface below is sized to exactly
1168        // `cols * cell_w` x `rows * cell_h`, which can be smaller than
1169        // `size` itself. The OS window stays at the full physical `size`
1170        // the window manager gave it -- retroglyph never resizes the OS
1171        // window to match -- so a non-exact-multiple resize leaves a thin
1172        // strip at the window's trailing (right/bottom) edge outside the
1173        // surface entirely. That strip is not cleared or painted by
1174        // retroglyph; whatever the OS/windowing backend leaves there (old
1175        // frame content, backdrop color) shows through until the window is
1176        // resized again to a size the presenter does cover. See
1177        // `Presenter::resize_surface` for the documented contract.
1178        let cols = (size.width / cell_w).max(1);
1179        let rows = (size.height / cell_h).max(1);
1180        term.backend_mut()
1181            .presenter_mut()
1182            .resize_surface(cols * cell_w, rows * cell_h);
1183        #[allow(clippy::cast_possible_truncation)]
1184        term.backend_mut()
1185            .push_event(Event::Resize(cols as u16, rows as u16));
1186    }
1187
1188    fn on_cursor_moved(&mut self, position: winit::dpi::PhysicalPosition<f64>) {
1189        // winit always reports pointer positions in real-DPR physical
1190        // pixels; rescale to the (possibly DPR-capped, on wasm) backing-store
1191        // pixel space that `cell_size`/`pixel_to_cell` use, so taps land on
1192        // the cell actually under the finger/cursor instead of drifting
1193        // south-east of it as the real DPR grows past the cap. `1.0` on
1194        // native (no such cap exists there) *and* on wasm when
1195        // `fill_viewport` is off: `create_window_and_surface` only computes
1196        // a DPR-capped `surface_physical_size` when `fill_viewport` is set
1197        // (see its branch above) -- without it, the backing store already
1198        // matches the real, uncapped DPR 1:1, so applying the cap
1199        // correction anyway scales every reported position *down* toward
1200        // the origin for no reason, biasing every tap/click up-and-left of
1201        // where it actually landed on any real_dpr > 1.5 device (most
1202        // phones, and Retina/HiDPI desktops).
1203        #[cfg(target_arch = "wasm32")]
1204        let scale = if self.fill_viewport {
1205            web::wasm_pointer_scale()
1206        } else {
1207            1.0
1208        };
1209        #[cfg(not(target_arch = "wasm32"))]
1210        let scale = 1.0;
1211        let (x, y) = (position.x * scale, position.y * scale);
1212        self.cursor_px = (x, y);
1213        let px = physical_pos_from(x, y);
1214        let Some(term) = self.terminal.as_mut() else {
1215            return;
1216        };
1217        let (cell_w, cell_h) = term.backend().presenter().cell_size();
1218        let pos = pixel_to_cell(x, y, cell_w, cell_h);
1219        term.backend_mut().push_event(Event::Mouse(MouseEvent {
1220            kind: MouseEventKind::Moved,
1221            position: pos,
1222            pixel_position: Some(px),
1223            modifiers: self.current_modifiers,
1224        }));
1225    }
1226
1227    fn on_mouse_input(
1228        &mut self,
1229        state: winit::event::ElementState,
1230        button: winit::event::MouseButton,
1231    ) {
1232        let Some(btn) = translate_mouse_button(button) else {
1233            return;
1234        };
1235        let px = self.cursor_physical_pos();
1236        let Some(term) = self.terminal.as_mut() else {
1237            return;
1238        };
1239        let (cell_w, cell_h) = term.backend().presenter().cell_size();
1240        let pos = pixel_to_cell(self.cursor_px.0, self.cursor_px.1, cell_w, cell_h);
1241        let kind = if state.is_pressed() {
1242            MouseEventKind::Down(btn)
1243        } else {
1244            MouseEventKind::Up(btn)
1245        };
1246        term.backend_mut().push_event(Event::Mouse(MouseEvent {
1247            kind,
1248            position: pos,
1249            pixel_position: Some(px),
1250            modifiers: self.current_modifiers,
1251        }));
1252    }
1253
1254    fn on_mouse_wheel(&mut self, delta: winit::event::MouseScrollDelta) {
1255        let px = self.cursor_physical_pos();
1256        let Some(term) = self.terminal.as_mut() else {
1257            return;
1258        };
1259        let (cell_w, cell_h) = term.backend().presenter().cell_size();
1260        let pos = pixel_to_cell(self.cursor_px.0, self.cursor_px.1, cell_w, cell_h);
1261        let scroll_y = match delta {
1262            winit::event::MouseScrollDelta::LineDelta(_, y) => f64::from(y),
1263            winit::event::MouseScrollDelta::PixelDelta(p) => p.y,
1264        };
1265        let kind = if scroll_y > 0.0 {
1266            MouseEventKind::ScrollUp
1267        } else {
1268            MouseEventKind::ScrollDown
1269        };
1270        term.backend_mut().push_event(Event::Mouse(MouseEvent {
1271            kind,
1272            position: pos,
1273            pixel_position: Some(px),
1274            modifiers: self.current_modifiers,
1275        }));
1276    }
1277
1278    /// Synthesize mouse events from a touch so tap/drag work out of the box.
1279    ///
1280    /// Mobile browsers (and native touchscreens) deliver touch input as
1281    /// [`WindowEvent::Touch`], which has no `CursorMoved`/`MouseInput`
1282    /// counterpart. Games shouldn't need a second input path for it, so the
1283    /// first finger down becomes the pointer: its start is a `Moved` +
1284    /// left-button `Down`, its motion is `Moved` (a drag), and its lift is
1285    /// `Up`. Additional simultaneous fingers are ignored.
1286    fn on_touch(&mut self, touch: winit::event::Touch) {
1287        use winit::event::TouchPhase;
1288
1289        match touch.phase {
1290            TouchPhase::Started => {
1291                if self.active_touch.is_some() {
1292                    return; // a second finger; keep tracking the first
1293                }
1294                self.active_touch = Some(touch.id);
1295                self.on_cursor_moved(touch.location);
1296                self.on_mouse_input(
1297                    winit::event::ElementState::Pressed,
1298                    winit::event::MouseButton::Left,
1299                );
1300            }
1301            TouchPhase::Moved => {
1302                if self.active_touch == Some(touch.id) {
1303                    self.on_cursor_moved(touch.location);
1304                }
1305            }
1306            TouchPhase::Ended | TouchPhase::Cancelled => {
1307                if self.active_touch != Some(touch.id) {
1308                    return;
1309                }
1310                self.active_touch = None;
1311                self.on_cursor_moved(touch.location);
1312                self.on_mouse_input(
1313                    winit::event::ElementState::Released,
1314                    winit::event::MouseButton::Left,
1315                );
1316            }
1317        }
1318    }
1319
1320    /// Convert the cached cursor pixel position to [`PhysicalPos`].
1321    const fn cursor_physical_pos(&self) -> PhysicalPos {
1322        physical_pos_from(self.cursor_px.0, self.cursor_px.1)
1323    }
1324
1325    /// Push [`Event::FocusGained`]/[`Event::FocusLost`], and on loss, reset state that only makes
1326    /// sense while the window is focused.
1327    ///
1328    /// Winit keeps delivering `ModifiersChanged` only while focused, so a modifier key held down
1329    /// when focus is lost (e.g. alt-tabbing away while holding Shift) never generates the release
1330    /// that would normally clear it: without this, `current_modifiers` stays stuck "held" for
1331    /// every event after focus returns. Similarly, a finger lifted while the window is
1332    /// unfocused/backgrounded never delivers `TouchPhase::Ended`/`Cancelled`, so `active_touch`
1333    /// would otherwise stay set forever, permanently ignoring the next finger down. The stuck
1334    /// touch is released the same way a real lift is (see [`on_touch`](Self::on_touch)'s
1335    /// `Ended`/`Cancelled` arm): a left-button `Up` at the last known cursor position, so the app
1336    /// sees a normal, balanced Down/Up pair instead of a Down with no matching Up. No `Moved` is
1337    /// synthesized first, unlike a real lift -- blur carries no new pointer location, and
1338    /// `cursor_px` already holds the touch's last reported position from the `Started`/`Moved`
1339    /// arms that got it there.
1340    fn on_focus_changed(&mut self, gained: bool) {
1341        if let Some(term) = self.terminal.as_mut() {
1342            let event = if gained {
1343                Event::FocusGained
1344            } else {
1345                Event::FocusLost
1346            };
1347            term.backend_mut().push_event(event);
1348        }
1349        if !gained {
1350            self.current_modifiers = KeyModifiers::NONE;
1351            if self.active_touch.take().is_some() {
1352                self.on_mouse_input(
1353                    winit::event::ElementState::Released,
1354                    winit::event::MouseButton::Left,
1355                );
1356            }
1357        }
1358    }
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363    use super::*;
1364    use retroglyph_core::event::{MouseButton, MouseEvent, MouseEventKind};
1365    use retroglyph_core::grid::{Pos, Size};
1366    use retroglyph_core::tile::Tile;
1367    use std::cell::RefCell;
1368    use std::time::Duration;
1369
1370    // ── physical_size_for ─────────────────────────────────────────────────────
1371
1372    #[test]
1373    fn physical_size_for_unscaled_monitor_is_unchanged() {
1374        assert_eq!(physical_size_for(80, 80, 1.0), (80, 80));
1375    }
1376
1377    #[test]
1378    fn physical_size_for_hidpi_monitor_scales_up() {
1379        // 2x display: a 80x80 logical window needs 160x160 true physical
1380        // pixels to render crisply instead of being upscaled by the OS.
1381        assert_eq!(physical_size_for(80, 80, 2.0), (160, 160));
1382    }
1383
1384    #[test]
1385    fn physical_size_for_fractional_scale_rounds() {
1386        // 1.5x display: 81x81 rounds to the nearest physical pixel rather
1387        // than truncating.
1388        assert_eq!(physical_size_for(81, 81, 1.5), (122, 122));
1389    }
1390
1391    // ── WindowConfig builder chain ───────────────────────────────────────────
1392
1393    #[test]
1394    fn fit_defaults_match_winit_defaults() {
1395        // `fit` should start from the same defaults winit itself uses for a plain
1396        // `Window::default_attributes()`, so a caller that never touches the new builder
1397        // methods gets identical behavior to before this API existed.
1398        let presenter = MockPresenter::default();
1399        let config = WindowConfig::fit(&presenter, "test", None);
1400        assert!(config.resizable);
1401        assert!(config.decorations);
1402        assert_eq!(config.min_size, None);
1403        assert_eq!(config.max_size, None);
1404        assert_eq!(config.initial_position, None);
1405        assert!(!config.fullscreen);
1406        assert!(!config.transparency);
1407        assert!(!config.fill_viewport);
1408    }
1409
1410    #[test]
1411    fn builder_chain_sets_each_attribute() {
1412        let presenter = MockPresenter::default();
1413        let config = WindowConfig::fit(&presenter, "test", None)
1414            .resizable(false)
1415            .decorations(false)
1416            .min_size(320, 240)
1417            .max_size(1920, 1080)
1418            .initial_position(10, 20)
1419            .fullscreen(true)
1420            .transparency(true);
1421        assert!(!config.resizable);
1422        assert!(!config.decorations);
1423        assert_eq!(config.min_size, Some((320, 240)));
1424        assert_eq!(config.max_size, Some((1920, 1080)));
1425        assert_eq!(config.initial_position, Some((10, 20)));
1426        assert!(config.fullscreen);
1427        assert!(config.transparency);
1428    }
1429
1430    #[test]
1431    fn window_attrs_from_config_copies_all_fields() {
1432        let presenter = MockPresenter::default();
1433        let config = WindowConfig::fit(&presenter, "test", None)
1434            .resizable(false)
1435            .decorations(false)
1436            .min_size(1, 2)
1437            .max_size(3, 4)
1438            .initial_position(5, 6)
1439            .fullscreen(true)
1440            .transparency(true);
1441        let attrs = WindowAttrs::from(&config);
1442        assert!(!attrs.resizable);
1443        assert!(!attrs.decorations);
1444        assert_eq!(attrs.min_size, Some((1, 2)));
1445        assert_eq!(attrs.max_size, Some((3, 4)));
1446        assert_eq!(attrs.initial_position, Some((5, 6)));
1447        assert!(attrs.fullscreen);
1448        assert!(attrs.transparency);
1449    }
1450
1451    // ── present_failure_action ───────────────────────────────────────────────
1452
1453    #[test]
1454    fn present_success_with_no_prior_failures_is_plain_ok() {
1455        assert_eq!(
1456            present_failure_action(0, true),
1457            PresentFailureAction::Ok { was_failing: false }
1458        );
1459    }
1460
1461    #[test]
1462    fn present_success_after_a_failure_streak_reports_recovery() {
1463        assert_eq!(
1464            present_failure_action(5, true),
1465            PresentFailureAction::Ok { was_failing: true }
1466        );
1467    }
1468
1469    #[test]
1470    fn first_failure_in_a_streak_logs_at_error_level() {
1471        assert_eq!(
1472            present_failure_action(0, false),
1473            PresentFailureAction::Log {
1474                at_error_level: true
1475            }
1476        );
1477    }
1478
1479    #[test]
1480    fn subsequent_failures_below_threshold_log_below_error_level() {
1481        for count in 1..PRESENT_FAILURE_RECOVERY_THRESHOLD - 1 {
1482            assert_eq!(
1483                present_failure_action(count, false),
1484                PresentFailureAction::Log {
1485                    at_error_level: false
1486                },
1487                "consecutive_failures = {count}"
1488            );
1489        }
1490    }
1491
1492    #[test]
1493    fn failure_crossing_the_threshold_triggers_recovery() {
1494        // consecutive_failures is the count *before* this call, so
1495        // `PRESENT_FAILURE_RECOVERY_THRESHOLD - 1` failures already happened; this call is the
1496        // one that reaches the threshold.
1497        assert_eq!(
1498            present_failure_action(PRESENT_FAILURE_RECOVERY_THRESHOLD - 1, false),
1499            PresentFailureAction::Recover
1500        );
1501    }
1502
1503    #[test]
1504    fn failure_recovers_again_every_full_threshold_after_the_first() {
1505        // A failed recovery attempt must not be retried on literally the next frame: the next
1506        // `Recover` only fires after another full threshold's worth of failures.
1507        assert_eq!(
1508            present_failure_action(2 * PRESENT_FAILURE_RECOVERY_THRESHOLD - 1, false),
1509            PresentFailureAction::Recover
1510        );
1511        for count in
1512            PRESENT_FAILURE_RECOVERY_THRESHOLD..(2 * PRESENT_FAILURE_RECOVERY_THRESHOLD - 1)
1513        {
1514            assert_eq!(
1515                present_failure_action(count, false),
1516                PresentFailureAction::Log {
1517                    at_error_level: false
1518                },
1519                "consecutive_failures = {count}"
1520            );
1521        }
1522    }
1523
1524    /// A dependency-free [`Presenter`] with fixed 8x16 cells.
1525    ///
1526    /// The `WindowApp` tests only exercise event translation, cell math, and
1527    /// the `WindowBackend` queue — no rasterization or surface is needed.
1528    #[derive(Default)]
1529    struct MockPresenter {
1530        /// Records the last [`Presenter::scale_factor_changed`] argument, if any.
1531        last_scale_factor: Cell<Option<f64>>,
1532    }
1533
1534    impl Presenter for MockPresenter {
1535        type Error = core::convert::Infallible;
1536        type SurfaceError = core::convert::Infallible;
1537
1538        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
1539        where
1540            I: Iterator<Item = (Pos, &'a Tile, Option<&'a str>)>,
1541        {
1542            Ok(())
1543        }
1544
1545        fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
1546        where
1547            I: Iterator<Item = (u8, Pos, &'a Tile, Option<&'a str>)>,
1548        {
1549            Ok(())
1550        }
1551
1552        fn flush(&mut self) -> Result<(), Self::Error> {
1553            Ok(())
1554        }
1555
1556        fn size(&self) -> Size {
1557            Size {
1558                width: 10,
1559                height: 5,
1560            }
1561        }
1562
1563        fn clear(&mut self) -> Result<(), Self::Error> {
1564            Ok(())
1565        }
1566
1567        fn resize(&mut self, _size: Size) {}
1568
1569        fn init_surface(
1570            &mut self,
1571            _window: Arc<dyn crate::presenter::WindowHandle>,
1572        ) -> Result<(), Self::SurfaceError> {
1573            Ok(())
1574        }
1575
1576        fn resize_surface(&mut self, _width: u32, _height: u32) {}
1577
1578        fn present(&mut self) -> Result<(), Self::SurfaceError> {
1579            Ok(())
1580        }
1581
1582        fn cell_size(&self) -> (u32, u32) {
1583            (8, 16)
1584        }
1585
1586        fn scale_factor_changed(&mut self, scale_factor: f64) {
1587            self.last_scale_factor.set(Some(scale_factor));
1588        }
1589    }
1590
1591    /// A [`Presenter`] that records every `resize_surface` call, so tests
1592    /// can assert on the pixel dimensions `on_resized` actually requests.
1593    #[derive(Default)]
1594    struct RecordingPresenter {
1595        resize_calls: Rc<RefCell<Vec<(u32, u32)>>>,
1596    }
1597
1598    impl Presenter for RecordingPresenter {
1599        type Error = core::convert::Infallible;
1600        type SurfaceError = core::convert::Infallible;
1601
1602        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
1603        where
1604            I: Iterator<Item = (Pos, &'a Tile, Option<&'a str>)>,
1605        {
1606            Ok(())
1607        }
1608
1609        fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
1610        where
1611            I: Iterator<Item = (u8, Pos, &'a Tile, Option<&'a str>)>,
1612        {
1613            Ok(())
1614        }
1615
1616        fn flush(&mut self) -> Result<(), Self::Error> {
1617            Ok(())
1618        }
1619
1620        fn size(&self) -> Size {
1621            Size {
1622                width: 10,
1623                height: 5,
1624            }
1625        }
1626
1627        fn clear(&mut self) -> Result<(), Self::Error> {
1628            Ok(())
1629        }
1630
1631        fn resize(&mut self, _size: Size) {}
1632
1633        fn init_surface(
1634            &mut self,
1635            _window: Arc<dyn crate::presenter::WindowHandle>,
1636        ) -> Result<(), Self::SurfaceError> {
1637            Ok(())
1638        }
1639
1640        fn resize_surface(&mut self, width: u32, height: u32) {
1641            self.resize_calls.borrow_mut().push((width, height));
1642        }
1643
1644        fn present(&mut self) -> Result<(), Self::SurfaceError> {
1645            Ok(())
1646        }
1647
1648        fn cell_size(&self) -> (u32, u32) {
1649            (8, 16)
1650        }
1651    }
1652
1653    /// A [`Presenter`] whose `present()` fails on demand, and which counts `init_surface` calls
1654    /// so tests can assert whether [`WindowApp::try_recover_surface`] actually ran.
1655    #[derive(Default)]
1656    struct FailingPresenter {
1657        /// `present()` returns `Err` while this is `true`.
1658        failing: Rc<Cell<bool>>,
1659        /// Number of `init_surface` calls observed (1 at construction time in real use; extra
1660        /// calls here are surface-recovery attempts).
1661        init_surface_calls: Rc<Cell<u32>>,
1662    }
1663
1664    impl Presenter for FailingPresenter {
1665        type Error = core::convert::Infallible;
1666        type SurfaceError = &'static str;
1667
1668        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
1669        where
1670            I: Iterator<Item = (Pos, &'a Tile, Option<&'a str>)>,
1671        {
1672            Ok(())
1673        }
1674
1675        fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
1676        where
1677            I: Iterator<Item = (u8, Pos, &'a Tile, Option<&'a str>)>,
1678        {
1679            Ok(())
1680        }
1681
1682        fn flush(&mut self) -> Result<(), Self::Error> {
1683            Ok(())
1684        }
1685
1686        fn size(&self) -> Size {
1687            Size {
1688                width: 10,
1689                height: 5,
1690            }
1691        }
1692
1693        fn clear(&mut self) -> Result<(), Self::Error> {
1694            Ok(())
1695        }
1696
1697        fn resize(&mut self, _size: Size) {}
1698
1699        fn init_surface(
1700            &mut self,
1701            _window: Arc<dyn crate::presenter::WindowHandle>,
1702        ) -> Result<(), Self::SurfaceError> {
1703            self.init_surface_calls
1704                .set(self.init_surface_calls.get() + 1);
1705            Ok(())
1706        }
1707
1708        fn resize_surface(&mut self, _width: u32, _height: u32) {}
1709
1710        fn present(&mut self) -> Result<(), Self::SurfaceError> {
1711            if self.failing.get() {
1712                Err("simulated present failure")
1713            } else {
1714                Ok(())
1715            }
1716        }
1717
1718        fn cell_size(&self) -> (u32, u32) {
1719            (8, 16)
1720        }
1721    }
1722
1723    type MockApp = WindowApp<MockPresenter, fn(&mut Terminal<WindowBackend<MockPresenter>>)>;
1724
1725    fn test_window_app() -> MockApp {
1726        let terminal = Terminal::new(WindowBackend::new(MockPresenter::default()));
1727        WindowApp {
1728            terminal: Some(terminal),
1729            app_loop: |_| {},
1730            window: None,
1731            title: String::new(),
1732            init_size: InitWindowSize {
1733                width: 80,
1734                height: 80,
1735            },
1736            attrs: WindowAttrs::default(),
1737            current_modifiers: KeyModifiers::NONE,
1738            cursor_px: (0.0, 0.0),
1739            active_touch: None,
1740            #[cfg(not(target_arch = "wasm32"))]
1741            frame_interval: None,
1742            #[cfg(not(target_arch = "wasm32"))]
1743            next_frame: std::time::Instant::now(),
1744            exit_requested: Rc::new(Cell::new(false)),
1745            needs_redraw: false,
1746            consecutive_present_errors: 0,
1747        }
1748    }
1749
1750    fn poll(app: &mut MockApp) -> Option<Event> {
1751        app.terminal
1752            .as_mut()
1753            .unwrap()
1754            .backend_mut()
1755            .poll_event(Duration::ZERO)
1756    }
1757
1758    // ── WindowBackend queue ───────────────────────────────────────────────────
1759
1760    #[test]
1761    fn mouse_event_round_trips_through_event_buffer() {
1762        let mut backend = WindowBackend::new(MockPresenter::default());
1763        let ev = Event::Mouse(MouseEvent {
1764            kind: MouseEventKind::Down(MouseButton::Left),
1765            position: Pos { x: 3, y: 1 },
1766            pixel_position: None,
1767            modifiers: KeyModifiers::NONE,
1768        });
1769        backend.push_event(ev.clone());
1770        assert_eq!(backend.poll_event(Duration::ZERO), Some(ev));
1771        assert_eq!(backend.poll_event(Duration::ZERO), None);
1772    }
1773
1774    #[test]
1775    fn multiple_mouse_events_preserve_fifo_order() {
1776        let mut backend = WindowBackend::new(MockPresenter::default());
1777        let moved = Event::Mouse(MouseEvent {
1778            kind: MouseEventKind::Moved,
1779            position: Pos { x: 1, y: 2 },
1780            pixel_position: None,
1781            modifiers: KeyModifiers::NONE,
1782        });
1783        let clicked = Event::Mouse(MouseEvent {
1784            kind: MouseEventKind::Down(MouseButton::Left),
1785            position: Pos { x: 1, y: 2 },
1786            pixel_position: None,
1787            modifiers: KeyModifiers::NONE,
1788        });
1789        backend.push_event(moved.clone());
1790        backend.push_event(clicked.clone());
1791        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved));
1792        assert_eq!(backend.poll_event(Duration::ZERO), Some(clicked));
1793    }
1794
1795    // ── handle_window_event ──────────────────────────────────────────────────
1796
1797    #[test]
1798    fn cursor_moved_pushes_moved_event_at_correct_cell() {
1799        // 8-wide × 16-tall cells; cursor at pixel (20, 32) → col 2, row 2.
1800        let mut app = test_window_app();
1801        app.handle_window_event(WindowEvent::CursorMoved {
1802            device_id: winit::event::DeviceId::dummy(),
1803            position: winit::dpi::PhysicalPosition::new(20.0_f64, 32.0_f64),
1804        });
1805        assert_eq!(
1806            poll(&mut app),
1807            Some(Event::Mouse(MouseEvent {
1808                kind: MouseEventKind::Moved,
1809                position: Pos { x: 2, y: 2 },
1810                pixel_position: Some(PhysicalPos { x: 20, y: 32 }),
1811                modifiers: KeyModifiers::NONE,
1812            }))
1813        );
1814    }
1815
1816    #[test]
1817    fn cursor_moved_caches_position_for_subsequent_click() {
1818        // Move to pixel (16, 16) = col 2, row 1, then click — button event
1819        // must reuse the cached position.
1820        let mut app = test_window_app();
1821        app.handle_window_event(WindowEvent::CursorMoved {
1822            device_id: winit::event::DeviceId::dummy(),
1823            position: winit::dpi::PhysicalPosition::new(16.0_f64, 16.0_f64),
1824        });
1825        let _ = poll(&mut app); // discard the Moved event
1826        app.handle_window_event(WindowEvent::MouseInput {
1827            device_id: winit::event::DeviceId::dummy(),
1828            state: winit::event::ElementState::Pressed,
1829            button: winit::event::MouseButton::Left,
1830        });
1831        assert_eq!(
1832            poll(&mut app),
1833            Some(Event::Mouse(MouseEvent {
1834                kind: MouseEventKind::Down(MouseButton::Left),
1835                position: Pos { x: 2, y: 1 },
1836                pixel_position: Some(PhysicalPos { x: 16, y: 16 }),
1837                modifiers: KeyModifiers::NONE,
1838            }))
1839        );
1840    }
1841
1842    #[test]
1843    fn mouse_button_release_produces_up_event() {
1844        let mut app = test_window_app();
1845        app.handle_window_event(WindowEvent::MouseInput {
1846            device_id: winit::event::DeviceId::dummy(),
1847            state: winit::event::ElementState::Released,
1848            button: winit::event::MouseButton::Right,
1849        });
1850        assert_eq!(
1851            poll(&mut app),
1852            Some(Event::Mouse(MouseEvent {
1853                kind: MouseEventKind::Up(MouseButton::Right),
1854                position: Pos { x: 0, y: 0 },
1855                pixel_position: Some(PhysicalPos { x: 0, y: 0 }),
1856                modifiers: KeyModifiers::NONE,
1857            }))
1858        );
1859    }
1860
1861    #[test]
1862    fn unknown_mouse_button_produces_no_event() {
1863        let mut app = test_window_app();
1864        app.handle_window_event(WindowEvent::MouseInput {
1865            device_id: winit::event::DeviceId::dummy(),
1866            state: winit::event::ElementState::Pressed,
1867            button: winit::event::MouseButton::Other(99),
1868        });
1869        assert_eq!(poll(&mut app), None);
1870    }
1871
1872    fn touch(id: u64, phase: winit::event::TouchPhase, x: f64, y: f64) -> WindowEvent {
1873        WindowEvent::Touch(winit::event::Touch {
1874            device_id: winit::event::DeviceId::dummy(),
1875            phase,
1876            location: winit::dpi::PhysicalPosition::new(x, y),
1877            force: None,
1878            id,
1879        })
1880    }
1881
1882    #[test]
1883    fn touch_tap_synthesizes_left_click() {
1884        use winit::event::TouchPhase;
1885        let mut app = test_window_app();
1886        // MockPresenter cells are 8x16 px; a tap at (20, 18) lands on cell (2, 1).
1887        app.handle_window_event(touch(7, TouchPhase::Started, 20.0, 18.0));
1888        // Moved (from the synthesized cursor move) then Down.
1889        assert!(matches!(
1890            poll(&mut app),
1891            Some(Event::Mouse(MouseEvent {
1892                kind: MouseEventKind::Moved,
1893                position: Pos { x: 2, y: 1 },
1894                ..
1895            }))
1896        ));
1897        assert!(matches!(
1898            poll(&mut app),
1899            Some(Event::Mouse(MouseEvent {
1900                kind: MouseEventKind::Down(MouseButton::Left),
1901                position: Pos { x: 2, y: 1 },
1902                ..
1903            }))
1904        ));
1905
1906        app.handle_window_event(touch(7, TouchPhase::Ended, 20.0, 18.0));
1907        assert!(matches!(
1908            poll(&mut app),
1909            Some(Event::Mouse(MouseEvent {
1910                kind: MouseEventKind::Moved,
1911                ..
1912            }))
1913        ));
1914        assert!(matches!(
1915            poll(&mut app),
1916            Some(Event::Mouse(MouseEvent {
1917                kind: MouseEventKind::Up(MouseButton::Left),
1918                position: Pos { x: 2, y: 1 },
1919                ..
1920            }))
1921        ));
1922        assert_eq!(poll(&mut app), None);
1923    }
1924
1925    #[test]
1926    fn touch_drag_synthesizes_moves_between_down_and_up() {
1927        use winit::event::TouchPhase;
1928        let mut app = test_window_app();
1929        app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
1930        poll(&mut app); // Moved
1931        poll(&mut app); // Down
1932
1933        app.handle_window_event(touch(1, TouchPhase::Moved, 40.0, 32.0));
1934        assert!(matches!(
1935            poll(&mut app),
1936            Some(Event::Mouse(MouseEvent {
1937                kind: MouseEventKind::Moved,
1938                position: Pos { x: 5, y: 2 },
1939                ..
1940            }))
1941        ));
1942
1943        app.handle_window_event(touch(1, TouchPhase::Cancelled, 40.0, 32.0));
1944        poll(&mut app); // Moved
1945        assert!(matches!(
1946            poll(&mut app),
1947            Some(Event::Mouse(MouseEvent {
1948                kind: MouseEventKind::Up(MouseButton::Left),
1949                ..
1950            }))
1951        ));
1952    }
1953
1954    #[test]
1955    fn second_finger_is_ignored_while_first_is_down() {
1956        use winit::event::TouchPhase;
1957        let mut app = test_window_app();
1958        app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
1959        poll(&mut app); // Moved
1960        poll(&mut app); // Down
1961
1962        // A second finger goes down, moves, and lifts: all ignored.
1963        app.handle_window_event(touch(2, TouchPhase::Started, 80.0, 80.0));
1964        app.handle_window_event(touch(2, TouchPhase::Moved, 88.0, 80.0));
1965        app.handle_window_event(touch(2, TouchPhase::Ended, 88.0, 80.0));
1966        assert_eq!(poll(&mut app), None);
1967
1968        // The first finger still completes its gesture.
1969        app.handle_window_event(touch(1, TouchPhase::Ended, 8.0, 0.0));
1970        poll(&mut app); // Moved
1971        assert!(matches!(
1972            poll(&mut app),
1973            Some(Event::Mouse(MouseEvent {
1974                kind: MouseEventKind::Up(MouseButton::Left),
1975                position: Pos { x: 1, y: 0 },
1976                ..
1977            }))
1978        ));
1979    }
1980
1981    #[test]
1982    fn scroll_up_line_delta() {
1983        let mut app = test_window_app();
1984        app.handle_window_event(WindowEvent::MouseWheel {
1985            device_id: winit::event::DeviceId::dummy(),
1986            delta: winit::event::MouseScrollDelta::LineDelta(0.0, 1.0),
1987            phase: winit::event::TouchPhase::Moved,
1988        });
1989        let ev = poll(&mut app).unwrap();
1990        assert!(matches!(
1991            ev,
1992            Event::Mouse(MouseEvent {
1993                kind: MouseEventKind::ScrollUp,
1994                ..
1995            })
1996        ));
1997    }
1998
1999    #[test]
2000    fn scroll_down_line_delta() {
2001        let mut app = test_window_app();
2002        app.handle_window_event(WindowEvent::MouseWheel {
2003            device_id: winit::event::DeviceId::dummy(),
2004            delta: winit::event::MouseScrollDelta::LineDelta(0.0, -1.0),
2005            phase: winit::event::TouchPhase::Moved,
2006        });
2007        let ev = poll(&mut app).unwrap();
2008        assert!(matches!(
2009            ev,
2010            Event::Mouse(MouseEvent {
2011                kind: MouseEventKind::ScrollDown,
2012                ..
2013            })
2014        ));
2015    }
2016
2017    #[test]
2018    fn scroll_up_pixel_delta() {
2019        let mut app = test_window_app();
2020        app.handle_window_event(WindowEvent::MouseWheel {
2021            device_id: winit::event::DeviceId::dummy(),
2022            delta: winit::event::MouseScrollDelta::PixelDelta(winit::dpi::PhysicalPosition::new(
2023                0.0_f64, 15.0_f64,
2024            )),
2025            phase: winit::event::TouchPhase::Moved,
2026        });
2027        let ev = poll(&mut app).unwrap();
2028        assert!(matches!(
2029            ev,
2030            Event::Mouse(MouseEvent {
2031                kind: MouseEventKind::ScrollUp,
2032                ..
2033            })
2034        ));
2035    }
2036
2037    #[test]
2038    fn modifiers_propagate_to_mouse_event() {
2039        let mut app = test_window_app();
2040        // Simulate a ModifiersChanged before the click.
2041        app.handle_window_event(WindowEvent::ModifiersChanged(
2042            winit::event::Modifiers::from(winit::keyboard::ModifiersState::SHIFT),
2043        ));
2044        let _ = poll(&mut app); // no event emitted for modifiers
2045        app.handle_window_event(WindowEvent::MouseInput {
2046            device_id: winit::event::DeviceId::dummy(),
2047            state: winit::event::ElementState::Pressed,
2048            button: winit::event::MouseButton::Left,
2049        });
2050        let ev = poll(&mut app).unwrap();
2051        assert!(matches!(
2052            ev,
2053            Event::Mouse(MouseEvent {
2054                modifiers,
2055                ..
2056            }) if modifiers.contains(KeyModifiers::SHIFT)
2057        ));
2058    }
2059
2060    // ── user events (EventProxy) ─────────────────────────────────────────────
2061
2062    #[test]
2063    fn user_event_pushes_custom_event() {
2064        let mut app = test_window_app();
2065        app.handle_user_event(42);
2066        assert_eq!(poll(&mut app), Some(Event::Custom(42)));
2067    }
2068
2069    #[test]
2070    fn multiple_user_events_preserve_fifo_order() {
2071        let mut app = test_window_app();
2072        app.handle_user_event(1);
2073        app.handle_user_event(2);
2074        assert_eq!(poll(&mut app), Some(Event::Custom(1)));
2075        assert_eq!(poll(&mut app), Some(Event::Custom(2)));
2076        assert_eq!(poll(&mut app), None);
2077    }
2078
2079    #[test]
2080    fn user_events_interleave_with_window_events_in_arrival_order() {
2081        let mut app = test_window_app();
2082        app.handle_user_event(7);
2083        app.handle_window_event(WindowEvent::CloseRequested);
2084        assert_eq!(poll(&mut app), Some(Event::Custom(7)));
2085        assert_eq!(poll(&mut app), Some(Event::Close));
2086    }
2087
2088    #[test]
2089    fn event_proxy_closed_reports_the_undelivered_id() {
2090        let err = EventProxyClosed(42);
2091        assert_eq!(err.into_inner(), 42);
2092        assert_eq!(err.to_string(), "event loop closed");
2093    }
2094
2095    #[test]
2096    fn close_requested_pushes_close_event() {
2097        let mut app = test_window_app();
2098        app.handle_window_event(WindowEvent::CloseRequested);
2099        assert_eq!(poll(&mut app), Some(Event::Close));
2100    }
2101
2102    // ── graceful exit (issue #157) ────────────────────────────────────────────
2103
2104    /// A `WindowApp` whose `app_loop` is a boxed closure, so a test can capture and flip a
2105    /// shared flag from inside it -- mirroring how `run_app_with_proxy`'s real closure sets
2106    /// `exit_requested` on `Flow::Exit` (it can't return a value or reach `ActiveEventLoop`
2107    /// itself; see `exit_requested`'s doc comment).
2108    type BoxedAppLoop = Box<dyn FnMut(&mut Terminal<WindowBackend<MockPresenter>>)>;
2109    type BoxedApp = WindowApp<MockPresenter, BoxedAppLoop>;
2110
2111    #[test]
2112    fn redraw_requested_runs_app_loop_and_does_not_set_exit_by_default() {
2113        let mut app = test_window_app();
2114        app.handle_window_event(WindowEvent::RedrawRequested);
2115        assert!(!app.exit_requested.get());
2116    }
2117
2118    #[test]
2119    fn app_loop_setting_exit_requested_is_observed_after_redraw() {
2120        // Simulates `run_app_with_proxy`'s closure: on `Flow::Exit` it sets the shared flag
2121        // instead of calling `std::process::exit`. `handle_window_event` itself never calls
2122        // `event_loop.exit()` (it can't -- no `ActiveEventLoop` -- see its doc comment); that
2123        // happens in `ApplicationHandler::window_event`, which this flag lets the test assert
2124        // on without a live winit event loop.
2125        let terminal = Terminal::new(WindowBackend::new(MockPresenter::default()));
2126        let exit_requested = Rc::new(Cell::new(false));
2127        let exit_requested_in_loop = exit_requested.clone();
2128        let app_loop: BoxedAppLoop = Box::new(move |_term| exit_requested_in_loop.set(true));
2129        let mut app: BoxedApp = WindowApp {
2130            terminal: Some(terminal),
2131            app_loop,
2132            window: None,
2133            title: String::new(),
2134            init_size: InitWindowSize {
2135                width: 80,
2136                height: 80,
2137            },
2138            attrs: WindowAttrs::default(),
2139            current_modifiers: KeyModifiers::NONE,
2140            cursor_px: (0.0, 0.0),
2141            active_touch: None,
2142            #[cfg(not(target_arch = "wasm32"))]
2143            frame_interval: None,
2144            #[cfg(not(target_arch = "wasm32"))]
2145            next_frame: std::time::Instant::now(),
2146            exit_requested,
2147            needs_redraw: false,
2148            consecutive_present_errors: 0,
2149        };
2150
2151        assert!(!app.exit_requested.get());
2152        app.handle_window_event(WindowEvent::RedrawRequested);
2153        assert!(app.exit_requested.get());
2154    }
2155
2156    #[test]
2157    fn theme_changed_pushes_mapped_system_theme_event() {
2158        let mut app = test_window_app();
2159        app.handle_window_event(WindowEvent::ThemeChanged(winit::window::Theme::Light));
2160        assert_eq!(
2161            poll(&mut app),
2162            Some(Event::ThemeChanged(
2163                retroglyph_core::event::SystemTheme::Light
2164            ))
2165        );
2166
2167        app.handle_window_event(WindowEvent::ThemeChanged(winit::window::Theme::Dark));
2168        assert_eq!(
2169            poll(&mut app),
2170            Some(Event::ThemeChanged(
2171                retroglyph_core::event::SystemTheme::Dark
2172            ))
2173        );
2174    }
2175
2176    #[test]
2177    fn focused_pushes_focus_gained_and_lost_events() {
2178        let mut app = test_window_app();
2179        app.handle_window_event(WindowEvent::Focused(true));
2180        assert_eq!(poll(&mut app), Some(Event::FocusGained));
2181
2182        app.handle_window_event(WindowEvent::Focused(false));
2183        assert_eq!(poll(&mut app), Some(Event::FocusLost));
2184    }
2185
2186    #[test]
2187    fn focus_lost_resets_stuck_modifiers() {
2188        // Regression test for #153: a modifier held down when focus is lost
2189        // (e.g. alt-tabbing away while holding Shift) must not stay "held"
2190        // for events delivered after focus returns.
2191        let mut app = test_window_app();
2192        app.handle_window_event(WindowEvent::ModifiersChanged(
2193            winit::event::Modifiers::from(winit::keyboard::ModifiersState::SHIFT),
2194        ));
2195        let _ = poll(&mut app); // no event emitted for modifiers
2196        assert_eq!(app.current_modifiers, KeyModifiers::SHIFT);
2197
2198        app.handle_window_event(WindowEvent::Focused(false));
2199        assert_eq!(poll(&mut app), Some(Event::FocusLost));
2200        assert_eq!(app.current_modifiers, KeyModifiers::NONE);
2201
2202        // A click after refocusing must not still carry the stale Shift.
2203        app.handle_window_event(WindowEvent::Focused(true));
2204        assert_eq!(poll(&mut app), Some(Event::FocusGained));
2205        app.handle_window_event(WindowEvent::MouseInput {
2206            device_id: winit::event::DeviceId::dummy(),
2207            state: winit::event::ElementState::Pressed,
2208            button: winit::event::MouseButton::Left,
2209        });
2210        let ev = poll(&mut app).unwrap();
2211        assert!(matches!(
2212            ev,
2213            Event::Mouse(MouseEvent { modifiers, .. }) if modifiers == KeyModifiers::NONE
2214        ));
2215    }
2216
2217    #[test]
2218    fn focus_lost_releases_stuck_active_touch() {
2219        // Regression test for #153: a finger lifted while the window is
2220        // unfocused/backgrounded never delivers `TouchPhase::Ended` or
2221        // `Cancelled`, so `active_touch` must be released on blur instead of
2222        // silently ignoring every subsequent finger down.
2223        use winit::event::TouchPhase;
2224        let mut app = test_window_app();
2225        app.handle_window_event(touch(3, TouchPhase::Started, 20.0, 18.0));
2226        poll(&mut app); // Moved
2227        poll(&mut app); // Down
2228        assert_eq!(app.active_touch, Some(3));
2229
2230        app.handle_window_event(WindowEvent::Focused(false));
2231        assert_eq!(poll(&mut app), Some(Event::FocusLost));
2232        // Synthesized Up releasing the stuck touch at its last known
2233        // position; no new Moved, since blur carries no fresh location.
2234        assert!(matches!(
2235            poll(&mut app),
2236            Some(Event::Mouse(MouseEvent {
2237                kind: MouseEventKind::Up(MouseButton::Left),
2238                ..
2239            }))
2240        ));
2241        assert_eq!(poll(&mut app), None);
2242        assert_eq!(app.active_touch, None);
2243
2244        // A new finger down after refocusing must be tracked, not ignored.
2245        app.handle_window_event(WindowEvent::Focused(true));
2246        assert_eq!(poll(&mut app), Some(Event::FocusGained));
2247        app.handle_window_event(touch(4, TouchPhase::Started, 40.0, 32.0));
2248        assert!(matches!(
2249            poll(&mut app),
2250            Some(Event::Mouse(MouseEvent {
2251                kind: MouseEventKind::Moved,
2252                ..
2253            }))
2254        ));
2255        assert!(matches!(
2256            poll(&mut app),
2257            Some(Event::Mouse(MouseEvent {
2258                kind: MouseEventKind::Down(MouseButton::Left),
2259                ..
2260            }))
2261        ));
2262        assert_eq!(app.active_touch, Some(4));
2263    }
2264
2265    #[test]
2266    fn focus_lost_without_active_touch_pushes_no_extra_events() {
2267        // No touch in progress: blur should push exactly one FocusLost, no
2268        // synthesized mouse events.
2269        let mut app = test_window_app();
2270        app.handle_window_event(WindowEvent::Focused(false));
2271        assert_eq!(poll(&mut app), Some(Event::FocusLost));
2272        assert_eq!(poll(&mut app), None);
2273    }
2274
2275    #[test]
2276    fn resized_pushes_resize_event_in_cells() {
2277        // 8x16 cells: 88x80 px -> 11 cols, 5 rows.
2278        let mut app = test_window_app();
2279        app.handle_window_event(WindowEvent::Resized(winit::dpi::PhysicalSize::new(88, 80)));
2280        assert_eq!(poll(&mut app), Some(Event::Resize(11, 5)));
2281    }
2282
2283    // ── scale factor changes ─────────────────────────────────────────────────
2284
2285    #[test]
2286    fn scale_factor_changed_notifies_presenter() {
2287        // `handle_window_event` can't be exercised directly here: winit's
2288        // `InnerSizeWriter::new` is `pub(crate)`, so a real
2289        // `WindowEvent::ScaleFactorChanged` can't be constructed outside the
2290        // winit crate. `on_scale_factor_changed` is called directly instead
2291        // -- it's the same code the `WindowEvent::ScaleFactorChanged` arm in
2292        // `handle_window_event` dispatches to.
2293        let mut app = test_window_app();
2294        app.on_scale_factor_changed(2.0);
2295        assert_eq!(
2296            app.terminal
2297                .as_ref()
2298                .unwrap()
2299                .backend()
2300                .presenter()
2301                .last_scale_factor
2302                .get(),
2303            Some(2.0)
2304        );
2305    }
2306
2307    #[test]
2308    fn scale_factor_changed_without_a_window_is_a_no_op_resize() {
2309        // `test_window_app` has no real winit window (`window: None`), so
2310        // there is no physical size to re-align the surface to -- this must
2311        // not panic, and must not push a spurious `Event::Resize`.
2312        let mut app = test_window_app();
2313        app.on_scale_factor_changed(2.0);
2314        assert_eq!(poll(&mut app), None);
2315    }
2316
2317    #[test]
2318    fn resize_to_clamps_to_whole_cells_and_pushes_resize_event() {
2319        // Shared helper behind both `on_resized` and
2320        // `on_scale_factor_changed`: 8x16 cells, 90x81 px clamps down to
2321        // 11 cols x 5 rows (88x80 px), not a fractional cell.
2322        let mut app = test_window_app();
2323        app.resize_to(winit::dpi::PhysicalSize::new(90, 81));
2324        assert_eq!(poll(&mut app), Some(Event::Resize(11, 5)));
2325    }
2326
2327    #[test]
2328    fn resized_below_one_cell_clamps_surface_and_event_to_1x1() {
2329        // Regression test for #140: an 8x16-cell presenter resized to a
2330        // window smaller than one cell (4x4 px) must not compute 0 cols/0
2331        // rows -- that would ask `resize_surface` for a zero-size surface,
2332        // which crashes softbuffer.
2333        type RecordingApp =
2334            WindowApp<RecordingPresenter, fn(&mut Terminal<WindowBackend<RecordingPresenter>>)>;
2335        let resize_calls = Rc::new(RefCell::new(Vec::new()));
2336        let presenter = RecordingPresenter {
2337            resize_calls: resize_calls.clone(),
2338        };
2339        let terminal = Terminal::new(WindowBackend::new(presenter));
2340        let mut app: RecordingApp = WindowApp {
2341            terminal: Some(terminal),
2342            app_loop: |_| {},
2343            window: None,
2344            title: String::new(),
2345            init_size: InitWindowSize {
2346                width: 80,
2347                height: 80,
2348            },
2349            attrs: WindowAttrs::default(),
2350            current_modifiers: KeyModifiers::NONE,
2351            cursor_px: (0.0, 0.0),
2352            active_touch: None,
2353            #[cfg(not(target_arch = "wasm32"))]
2354            frame_interval: None,
2355            #[cfg(not(target_arch = "wasm32"))]
2356            next_frame: std::time::Instant::now(),
2357            exit_requested: Rc::new(Cell::new(false)),
2358            needs_redraw: false,
2359            consecutive_present_errors: 0,
2360        };
2361
2362        app.handle_window_event(WindowEvent::Resized(winit::dpi::PhysicalSize::new(4, 4)));
2363
2364        // Surface must be resized to at least one full cell (8x16), not
2365        // 0x0.
2366        assert_eq!(resize_calls.borrow().as_slice(), &[(8, 16)]);
2367        // Event::Resize must report the same clamped 1x1 grid, not 0x0.
2368        assert_eq!(
2369            app.terminal
2370                .as_mut()
2371                .unwrap()
2372                .backend_mut()
2373                .poll_event(Duration::ZERO),
2374            Some(Event::Resize(1, 1))
2375        );
2376    }
2377
2378    // ── needs_redraw (idle/redraw-on-demand, issue #155) ─────────────────────
2379
2380    #[test]
2381    fn fresh_app_does_not_need_a_redraw() {
2382        // `test_window_app` starts with `needs_redraw: false` -- unlike the real
2383        // `resumed()` path, which sets it `true` once the window/surface exists (a real winit
2384        // `ActiveEventLoop` can't be constructed in a unit test, so `resumed` itself isn't
2385        // exercised here; see `handle_window_event`/`handle_user_event` below for the parts of
2386        // the redraw-on-demand logic that are testable without one).
2387        let app = test_window_app();
2388        assert!(!app.needs_redraw);
2389    }
2390
2391    #[test]
2392    fn window_event_sets_needs_redraw() {
2393        // Any real window event (a mouse move here, but any arm other than `RedrawRequested`
2394        // behaves the same -- see `handle_window_event`'s doc comment) should mark that the app
2395        // loop has something new to react to, so the next `about_to_wait` requests a redraw
2396        // instead of leaving the loop idle.
2397        let mut app = test_window_app();
2398        assert!(!app.needs_redraw);
2399        app.handle_window_event(WindowEvent::CursorMoved {
2400            device_id: winit::event::DeviceId::dummy(),
2401            position: winit::dpi::PhysicalPosition::new(1.0_f64, 1.0_f64),
2402        });
2403        assert!(app.needs_redraw);
2404    }
2405
2406    #[test]
2407    fn redraw_requested_does_not_itself_set_needs_redraw() {
2408        // `RedrawRequested` is the render this flag exists to gate, not a new event to redraw
2409        // again for -- an idle app that gets exactly one `RedrawRequested` (e.g. right after
2410        // `resumed`) must not perpetually re-arm itself into another one forever.
2411        let mut app = test_window_app();
2412        app.handle_window_event(WindowEvent::RedrawRequested);
2413        assert!(!app.needs_redraw);
2414    }
2415
2416    #[test]
2417    fn user_event_sets_needs_redraw() {
2418        // A cross-thread `Event::Custom` injection (network, audio, timer, ...) must wake an
2419        // idle loop into rendering the next frame just like a real window event does.
2420        let mut app = test_window_app();
2421        assert!(!app.needs_redraw);
2422        app.handle_user_event(1);
2423        assert!(app.needs_redraw);
2424    }
2425
2426    #[test]
2427    fn unhandled_window_events_still_set_needs_redraw() {
2428        // Even a `WindowEvent` variant with no dedicated handling below (falls through to the
2429        // `_ => {}` arm in `handle_window_event`'s `match`) should still be treated as "something
2430        // happened": the flag is set once, up front, before the match runs.
2431        let mut app = test_window_app();
2432        app.handle_window_event(WindowEvent::Occluded(true));
2433        assert!(app.needs_redraw);
2434    }
2435
2436    // ── handle_redraw_requested / present() failure recovery ─────────────────
2437
2438    type FailingApp =
2439        WindowApp<FailingPresenter, fn(&mut Terminal<WindowBackend<FailingPresenter>>)>;
2440
2441    fn failing_app() -> (FailingApp, Rc<Cell<bool>>, Rc<Cell<u32>>) {
2442        let failing = Rc::new(Cell::new(false));
2443        let init_surface_calls = Rc::new(Cell::new(0));
2444        let presenter = FailingPresenter {
2445            failing: failing.clone(),
2446            init_surface_calls: init_surface_calls.clone(),
2447        };
2448        let terminal = Terminal::new(WindowBackend::new(presenter));
2449        let app = WindowApp {
2450            terminal: Some(terminal),
2451            app_loop: (|_| {}) as fn(&mut Terminal<WindowBackend<FailingPresenter>>),
2452            window: None,
2453            title: String::new(),
2454            init_size: InitWindowSize {
2455                width: 80,
2456                height: 80,
2457            },
2458            attrs: WindowAttrs::default(),
2459            current_modifiers: KeyModifiers::NONE,
2460            cursor_px: (0.0, 0.0),
2461            active_touch: None,
2462            #[cfg(not(target_arch = "wasm32"))]
2463            frame_interval: None,
2464            #[cfg(not(target_arch = "wasm32"))]
2465            next_frame: std::time::Instant::now(),
2466            exit_requested: Rc::new(Cell::new(false)),
2467            needs_redraw: false,
2468            consecutive_present_errors: 0,
2469        };
2470        (app, failing, init_surface_calls)
2471    }
2472
2473    #[test]
2474    fn successful_presents_never_increment_the_failure_counter() {
2475        let (mut app, _failing, _init_calls) = failing_app();
2476        for _ in 0..5 {
2477            app.handle_redraw_requested();
2478        }
2479        assert_eq!(app.consecutive_present_errors, 0);
2480    }
2481
2482    #[test]
2483    fn failing_presents_increment_the_counter_and_stop_short_of_recovery() {
2484        let (mut app, failing, init_calls) = failing_app();
2485        failing.set(true);
2486        for _ in 0..PRESENT_FAILURE_RECOVERY_THRESHOLD - 1 {
2487            app.handle_redraw_requested();
2488        }
2489        assert_eq!(
2490            app.consecutive_present_errors,
2491            PRESENT_FAILURE_RECOVERY_THRESHOLD - 1
2492        );
2493        // No window to recover from in this test app (`window: None`), but recovery should not
2494        // even have been attempted yet regardless -- confirmed by `try_recover_surface`'s own
2495        // no-window guard never being reached, i.e. `init_surface` was never called past the
2496        // initial 0.
2497        assert_eq!(init_calls.get(), 0);
2498    }
2499
2500    #[test]
2501    fn counter_resets_after_recovering_from_a_failure_streak() {
2502        let (mut app, failing, _init_calls) = failing_app();
2503        failing.set(true);
2504        for _ in 0..5 {
2505            app.handle_redraw_requested();
2506        }
2507        assert_eq!(app.consecutive_present_errors, 5);
2508
2509        failing.set(false);
2510        app.handle_redraw_requested();
2511        assert_eq!(app.consecutive_present_errors, 0);
2512    }
2513
2514    #[test]
2515    fn crossing_the_recovery_threshold_attempts_recovery_without_panicking() {
2516        // `test_window_app`/`failing_app` have no real winit `Window` (constructing one needs a
2517        // live event loop, unavailable in a unit test -- the same limitation documented on
2518        // `scale_factor_changed_without_a_window_is_a_no_op_resize` above), so this can't assert
2519        // `init_surface` actually re-runs; `try_recover_surface`'s own no-window guard is exercised
2520        // directly below instead. What this does verify: the threshold-crossing call does not
2521        // panic, and the counter keeps incrementing through and past the threshold rather than
2522        // resetting or overflowing.
2523        let (mut app, failing, init_calls) = failing_app();
2524        failing.set(true);
2525        for _ in 0..PRESENT_FAILURE_RECOVERY_THRESHOLD {
2526            app.handle_redraw_requested();
2527        }
2528        assert_eq!(
2529            app.consecutive_present_errors,
2530            PRESENT_FAILURE_RECOVERY_THRESHOLD
2531        );
2532        assert_eq!(
2533            init_calls.get(),
2534            0,
2535            "no window means try_recover_surface's guard skips init_surface"
2536        );
2537    }
2538
2539    #[test]
2540    fn try_recover_surface_without_a_window_is_a_no_op() {
2541        let (mut app, _failing, init_calls) = failing_app();
2542        app.try_recover_surface();
2543        assert_eq!(init_calls.get(), 0);
2544    }
2545}