Skip to main content

retroglyph_window/winit/
run.rs

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